A Flyway Teams Callback Script for Auditing SQL Migrations
Demonstrates a cross-database PowerShell callback script for reporting on and auditing Flyway migrations, telling you which scripts were used to create each version, when they were run, who ran them and more.
When a new version of a database is successfully created for the first time, it is probably the right moment to kick off some other processes that are outside Flyway’s remit. There are several things that might need to happen, depending on the type of database and its importance.
For example, do you need to generate a build script for the new version? Probably. Do you need to load a full set of data? Maybe. What about checking to see that the version that you’ve built can run automated tests successfully? What about a backup? Should you check the code for possible issues that could cause technical debt? Does anyone in the team need an alert that there is a new version? Should you send a ‘Slack’ message? Do you need to generate reports, logs, documentation, or a summary of what’s changed?
Sure, all this can all be done in Flyway Community if you remember to kick off the process, or if you embed Flyway within a script. I’ve written several articles previously to demonstrate how you can script a process that uses Flyway both to create the new version and then run “script block” tasks to do these other things. It is, however, a bit restricting for the team when it gets more complicated because Flyway was designed to be run and be used interactively.
With Flyway Teams, it is Flyway automatically kicks off the callback script for these other processes, which makes the work a lot simpler. It also means that you can then use Flyway interactively in a console, and let Flyway worry about setting all these processes going at the correct time and in the correct order. These callback scripts reside with the SQL or Java scripts in the Scripts folder that is used by Flyway. The type of callback you are going to populate with a script is denoted by the filename.
Writing and debugging Flyway scripts
As I described in my previous article, Scripting with Flyway Teams and PowerShell, Flyway passes the parameters to the script as environment variables. One of the challenges in writing PowerShell callback scripts is that Flyway Teams passes in certain useful information to the callback, automatically, but not everything you need.
Currently Flyway passes in the name of the user (FLYWAY_USER
), the URL used by JDBC to access the database (FLYWAY_URL
), and the default placeholders. These don’t include the current schemas, the current version of the database, the last file that it has processed, or whether it is doing a ‘dry run’. You can, of course supply some of this information, such as the current schemas, as custom placeholders or as ordinary parameters, but some bits of information, such as the version of the database at the point a callback is executed, are known only to Flyway. You are also probably going to need passwords, which could never be tolerated in an environment variable.
The second challenge is in debugging. A script that is going to be executed by Flyway may display its output stream on the console, but then again it might not. If Flyway is set to direct its output to JSON, then this output doesn’t go to the screen. I’ve never found out where it goes. For this reason, I always use a log since this could be of long-term use.
It is easy to debug any PowerShell script in the ISE or Visual Studio, but in that case, you can execute just selected portions of code or see the value in variables. When the execution context is different and you can’t do conventional debugging, it makes the process more complex.
In the following PowerShell callback script, for auditing migrations, I’ll also show how to get around all these problems, by sending useful error information to a developer log (usage.log), so that you can debug the code.
Auditing and reporting for Flyway migrations
We’ve been asked, let’s imagine, for a way of reporting and auditing on the migrations that are run on a database. The management might want reports based on the version number, server, database, a description of the version, when it was first created, what migration file was used, who did it, when, how long it took, what Flyway version was used. It sounds as if we need to collect all the relevant information into a database and use this as a basis for reports. I’ll show how to do this using a PowerShell callback and SQLite.
Until one stops to think about it, it is possible to underestimate what’s involved. Flyway can tell us quite a bit about what is going on with individual migrations via the info
command, but we want to collect and store information about all the actions that can affect all the databases for a whole project, not just for a particular database. Therefore, we need to collect this information from all the databases at the time the Flyway commands are executed.
The flyway_schema_history
table is only relevant for one database, and any one project is likely to involve several of them, which might be ephemeral. You may, for example, use Flyway for making copies of the database. You might move the development database or use branching. You may have several copies of the database already out there that need to be kept up to date; these may be on laptops or servers, each of which could have been created by different people at different times. There will be the test cell. Not only that, but you might even deploy the same database to different RDBMSs. For example, I generally use MySQL or MariaDB for websites because they are so often provided free as part of a LAMP stack, but will develop in an RDBMS such as SQL Server, which has the best Dev tools. Knowing the name of the server isn’t enough unless you have a different server for each database system you use (I don’t!).
We need to keep tabs on who did what and when. If we record it all in an audit database, we can create reports on what’s been done for co-workers or managers. At some point you are at peril of getting distracted by the complexity or bored by the repetitive detail. This is the point at which you really need to start using Flyway Teams, and PowerShell callbacks, to create a better way of doing it.
Getting audit information for a migration
The PowerShell script will create a log entry that tells you when a new version was created, who did it and so on.
Database : MyDatabase Server : MyServer RDBMS : sqlserver Schema names : dbo, person, sales, shipping, HR Flyway Version : 7.15.0 category : Versioned description : BugFixViewParameters executionTime : 35 filepath : <myFilepathTo>\Scripts\V1.1.9__BugFixViewParameters.sql installedBy : Phil Factor installedOn : 2021-04-13 10:47:48.113 installedOnUTC : 2021-04-13T09:47:48.113Z state : Success type : SQL undoable : No version : 1.1.9
We can pack this type of record away in a log, database or report very easily, if it is a PowerShell custom object. In our case, we put it in an SQLite database that the script will create in the PubsFlywayTeamsMigration GitHub project folder.
We’ll use Flyway info
command to give us a lot of this information. We’ll call Flyway from the callback script, which is, itself, called from Flyway. This works, as long as you don’t do this routine as an ‘EACH’ callback. The rest of the information we need, such as the server name, scheme names, type, Flyway version, who ran the script, execution time and script location, we get from various environment variables, both default and custom.
The great advantage of using Flyway with an info
command to determine the current version of a database is that it provides a device-independent way of getting this information that accounts for any changes in the place where the flyway_schema_history
is located and named. The disadvantage is that it is slow. One also must be careful to provide all the parameters that it needs such as the schemas, otherwise it can provide incorrect information.
The PowerShell callback
Having created and debugged the routine for collecting all the audit information, we can turn it into what Flyway terms a “script callback”. It will write a log for every new version created by Flyway, as described above. It saves the audit log records to a SQLite database file (FlywayHistory.db), in a table called versions
. The idea is that you just add it as a callback to any project where you need a complete audit of migrations. It will work with any RDBMS.
We want to generate an “audit record” for every successful versioned migration run, rather than every version within the run, since it is likely that every migration file will have been, at one time, the end of a successful migration run. We’ll therefore, after careful examination of my “Flyway callback crib sheet“, create it as an afterVersioned
callback.
Here is the callback script. You’ll find the file (afterVersioned_Report.ps1) in the Scripts folder of my PubFlywayTeamsMigration project on GitHub. Note that I’m accessing SQLite via the command line. I think that the SQLite ODBC interface is rather more robust as an interface with SQLite, but the command line interface is blazingly fast, if you can tame it. I’ve designed it so that it doesn’t write the same records twice, so you’ll only see one entry for each version. As mentioned earlier, the script also demonstrates a way for developers to get an internal “usage log”, for debugging purposes.
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 |
#create an alias for the commandline Flyway and SQLite, Set-Alias Flyway 'C:\ProgramData\chocolatey\lib\flyway.commandline\tools\flyway-7.15.0\flyway.cmd' -Scope local <# tell PowerShell where Flyway is. You need to change that to the correct path for your installation. You only need to do this if your flyway installation has not provideded a path cvommand #> Set-Alias SQLite 'C:\ProgramData\chocolatey\lib\SQLite\tools\sqlite-tools-win32-x86-3360000\sqlite3.exe' -Scope local #test these aliases if (!(test-path ((Get-alias -Name Flyway).definition) -PathType Leaf)) { write-error 'The alias for Flyway is not set correctly yet' } if (!(test-path ((Get-alias -Name SQLite).definition) -PathType Leaf)) { write-error 'The alias for SQLite is not set correctly yet' } # we collect all the useful information in a log. Here we start it up $internalLog=@("$(Get-date)- started with $env:FLYWAY_USER ") # we create a variable to work out if we can go ahead $WeCanContinue = $true #start off with optimism #our regex for gathering variables from Flyway's URL $FlywayURLRegex = 'jdbc:(?<RDBMS>[\w]{1,20})://(?<server>[\w\-\.]{1,40})(?<port>:[\d]{1,4}|)(;.+databaseName=|/)(?<database>[\w]{1,20})' $FlywayURLTruncationRegex = 'jdbc:.{1,30}://.{1,30}(;|/)' #this FLYWAY_URL contains the current database, port and server so # it is worth grabbing $ConnectionInfo = $env:FLYWAY_URL #get the environment variable if ($ConnectionInfo -eq $null) #OMG... it isn't there for some reason { $internalLog += 'missing value for flyway url'; $WeCanContinue = $false; } #get the user ID $uid = $env:FLYWAY_USER; $ProjectFolder = $PWD.Path; #test the path if (!(test-path $ProjectFolder -PathType Container)) { write-error "There isn't a valid scripts folder at $ProjectFolder" } if ($ConnectionInfo -imatch $FlywayURLRegex) { #we can extract all the info we need $RDBMS = $matches['RDBMS']; $server = $matches['server']; $port = $matches['port']; $database = $matches['database'] } elseif ($ConnectionInfo -imatch 'jdbc:(?<RDBMS>[\w]{1,20}):(?<database>[\w:\\]{1,80})') { #no server or port $RDBMS = $matches['RDBMS']; $server = 'LocalHost'; $port = 'default'; $database = $matches['database'] } else { $internalLog += 'unmatched connection info' } #write to our log for debugging purposes $internalLog += "RDBMS=$RDBMS, Server='$server' Port='$port' and Database= '$database'" if ($weCanContinue) { # now we get the password if necessary if ($username -ne '' -and $RDBMS -ne 'sqlite') #then it is using Credentials { # we see if we've got these stored already $escapedServer=($server.Split([IO.Path]::GetInvalidFileNameChars()) -join '_') -ireplace '\.','-' $SqlEncryptedPasswordFile = "$env:USERPROFILE\$($uid)-$escapedServer-$($RDBMS).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 $uid # 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. #> } $FlyWayArgs = @("-url=$ConnectionInfo", "-locations=filesystem:$PWD", <# the migration folder #> "-user=$($SqlCredentials.UserName)", "-password=$($SqlCredentials.GetNetworkCredential().password)") } else { $FlyWayArgs = @("-url=$($ConnectionInfo)(if $RDBMS -eq 'sqlserver'){'integratedSecurity=true'}else {''})". "-locations=filesystem:$PWD")<# the migration folder #> } $FlyWayArgs+=gci env:FP__* | Foreach { "$($_.Name -ireplace 'FP__(?<Variable>[\w]*)__', '-placeholders.${Variable}=')$($_.Value.ToLower())"} #flush the usage log $internalLog | foreach{ $_ >> "$(Split-Path $PWD -Parent)\Usage.log" } $internalLog = @() #get a JSON report of the history. We only want the record for the current version try{ $report = Flyway info @FlywayArgs -outputType=json | convertFrom-json } catch { $report.error ="We got an uncaugt error from Flyway $($_.ScriptStackTrace)" } if ($report.error -ne $null) #if an error was reported by Flyway { #if an error (usually bad parameters) error out. $report.error | foreach { $internalLog += "$($_.errorCode): $($_.message)" } } if ($report.allSchemasEmpty) #if it is an empty database { $internalLog += "all schemas of $database are empty. No version has been created here" } else { #looking good, so first get the paramaters that are not specific to the version $RecordOfCurrentVersion = [pscustomobject]@{ # get the global variables 'Database' = $report.database; 'Server' = $server; 'RDBMS' = $RDBMS; 'Schema names' = $report.schemaName; 'Flyway Version' = $report.flywayVersion; } } # now add all the values from the record for the current version $Report.migrations | where { $_.version -eq $Report.schemaVersion } | select -Last 1| foreach{ $rec = $_; #remember this for gettinmg it's value $rec | gm -MemberType NoteProperty } | foreach{ # do each key/value pair in turn $RecordOfCurrentVersion | Add-Member -MemberType NoteProperty -Name $_.Name -Value $Rec.($_.Name) } if ($RecordOfCurrentVersion.state -ne 'Success') { write-error "Have you given the correct project folder?" } #now all we have to do is to write it into the database if the record #does not already exist $internalLog += "Now saving $($RecordOfCurrentVersion) to SQLite" $internalLog | foreach{ $_ >> "$(Split-Path $PWD -Parent)\Usage.log" } $internalLog = @() #in this case, we have only one record but we'll use a pipeline #just for convenience. $RecordOfCurrentVersion | foreach{ sqlite '..\flywayHistory.db' " CREATE TABLE IF NOT EXISTS Versions( Database Varchar(80), Server Varchar(80), RDBMS Varchar(20), Schema_names Varchar(200), Flyway_Version Varchar(20), category Varchar(80), description Varchar(80), executionTime int, filepath Varchar(200), installedBy Varchar(80), installedOn Varchar(25), installedOnUTC Varchar(25), state Varchar(80), type Varchar(10), undoable Varchar(5), version Varchar(80), PRIMARY KEY (Database, Server, RDBMS, Version)) ; INSERT INTO Versions( Database, Server, RDBMS, Schema_names, Flyway_Version, category, description, executionTime, filepath, installedBy, installedOn, installedOnUTC, state, type, undoable, version) SELECT '$($_.Database)', '$($_.Server)', '$($_.RDBMS)', '$($_.'Schema names')', '$($_.'Flyway Version')', '$($_.category)', '$($_.description)', '$($_.executionTime)', '$($_.filepath)', '$($_.installedBy)', '$($_.installedOn)', '$($_.installedOnUTC)', '$($_.state)', '$($_.type)', '$($_.undoable)', '$($_.version)' WHERE NOT EXISTS(SELECT 1 FROM Versions WHERE Database='$($_.Database)' AND Server= '$($_.Server)' AND RDBMS= '$($_.RDBMS)' AND version = '$($_.Version)'); " .exit } } else { $internalLog += "$(Get-date)- had to abandon reporting" } $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' $internalLog | foreach{ "Logged $_" } $internalLog | foreach{ $_ >> "$(Split-Path $PWD -Parent)\Usage.log" } |
Trying it out
Just place the afterVersioned_Report.ps1 in the Scripts folder of your Flyway project (remove the DontDo prefix if you downloaded the copy from my GitHub project). You’ll need to set the correct paths to Flyway and SQLite.
Then, just execute a Flyway migration on one of your databases, or across a range of different RDBMSs. For example, you can use the UndoBuild.ps1 script from the project folder. All you need to do is fill in the $details
of your server, database and project. The script does everything else for you, including populating the custom placeholders that provide “missing” information to the callback.
It is slow to run, but all the slowness is caused by Flyway doing its lengthy initialization.
Each successful versioned migration of a database should generate a new audit record in the versions table of our SQLite database. Here is the result of running a single migration script file, on several copies of the same PolyGlotPubs
database, each installed on a different RDBMS.
Once you have collected a whole lot of information in your audit database, you’ll want to use it, import it into a spreadsheet, or into another database. To read the information from the database in PowerShell you may want to use…
1 2 3 4 5 6 |
sqlite "$(Split-Path $PWD -Parent)\flywayHistory.db" '.mode json' @" Select * from Versions; "@ ".exit" if ($LASTEXITCODE -ne 0){ $InternalLog +="SQLite returned error code $LASTEXITCODE" } |
…to get JSON output from this SQLite database that can then be read easily into a script or converted into a PowerShell object that can be placed into a spreadsheet via ODBC. Your SQL will vary according to the information you want!
Conclusions
Callback scripts have been around for quite a while in Flyway, but with Flyway Teams it’s now possible to get user-defined placeholders into a callback script, as environment variables. This makes callbacks a more practical as a way of adding value and simplifying the day-to-day use of Flyway.
Tools in this post
Flyway Teams
Ideal for organizations looking to improve collaboration and fine tune their processes during development and the deployment of database changes.