Recently, while working on a personal project, I found myself needing a lightweight way to deploy database changes to multiple environments. In the past I have used a wide range of methods, ranging from applying the changes manually to applying changes via a diff tool (SQL Compare), to automatically applying manually created change scripts, to automatically applying diff scripts that were automatically generated, to working directly in production..er, pretend you didn’t see that one.
Why not _____ tool?
There are a lot of tools out there to handle database deployments, but this is a small project that I am building incrementally as a minimum viable product. Rather than tie up a bunch of time researching and experimenting with database deployment tools early on, I decided to do something simple that would work for the time being and free me up to work on the actual product.
What I want from the deployment is to:
- Spend as little time on this as possible
- Work against SQL Azure and a local 2008 R2 Server
- Call it from TeamCity for a local server or a remote one
- Produce readable output for TeamCity logs
- Create the databases and users from the ground up
- Include randomly generated data
- Manage scripts for 2 independent databases in the same build
- Allow real SQL (I’m not scared of SQL and I don’t want to learn a code abstraction just to deploy changes)
- Not worry about rollbacks. I am deploying small changes and if something breaks I’ll be charging forward
- Not expose credentials, as the code will be visible to the public
- Be replaceable. I might replace it with a tool one day, so keep the deployment logic separate from the application
Looking back at this list and what I eventually created, I probably could have used something like <a href=“https://github.com/brunomlopes/dbdeploy.net" “dbdploy on github”>DBDeploy. The scripts I created ended up taking a very similar approach.
The Deployment Scripts
My deployment consists of 4 PowerShell scripts:
- ApplyDatabaseUpdates.ps1 – Responsible for generically applying changes from a folder to a specified database
- UpdateCoreDatabase.ps1 – Responsible for the application’s Core Database, calls ApplyDatabaseUpdates
- UpdateSampleDatabase.ps1 – Response for the application’s Sample Database, calls ApplyDatabaseUpdates
- RunLocally.ps1 – Executes the two Update scripts against the local database on my development machine(s)
This project has been spread out over 6 months, intermixed with life, other projects, blog posts, etc. Along the way I also upgraded my local development machine to SQL Server 2012 but my main test database server is on 2008 still and my release environment is Azure Database/SQL Azure/(whatever the name is this week).
ApplyDatabaseUpdates.ps1
The purpose of the ApplyDatabaseUpdates script is to apply all of the scripts in a specified folder to the specified server. To do this it creates a tracking table on the target database, then iterates through the contents of the folder, building a script containing any files that were not previously logged in the tracking table.
Core Database Scripts Folder
The deployment script wraps the contents of each script file in an EXECUTE statement, followed by an INSERT to add it to the tracking table for the database.
function ApplyDatabaseUpdates
{
param (
[parameter(Mandatory=$true)]
[string]
$UpdatesFolder,
[parameter(Mandatory=$true)]
[string]
$Server,
[parameter(Mandatory=$true)]
[string]
$Database,
[parameter(Mandatory=$true)]
[string]
$AdminUserName,
[parameter(Mandatory=$true)]
[string]
$AdminPassword
)
$path = (Get-Location).Path
# For SQL 2008 - load the modules
try{
if ( (Get-PSSnapin -Name SqlServerCmdletSnapin100 -ErrorAction SilentlyContinue) -eq $null -and (Get-PSSnapin -Registered -Name SqlServerCmdletSnapin100 -ErrorAction SilentlyContinue) -ne $null){
Add-PSSnapin SqlServerCmdletSnapin100 -ErrorAction SilentlyContinue
Add-PSSnapin SqlServerProviderSnapin100 -ErrorAction SilentlyContinue
}
}
catch{
Write-Error "Powershell Script error: $_" -EA Stop
}
#updates tracking
try{
Write-Host "Creating Update Tracking Table If Not Exists"
Invoke-Sqlcmd -Query "IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'UpdateTracking') CREATE TABLE UpdateTracking (UpdateTrackingKey int IDENTITY(1,1) PRIMARY KEY, Name varchar(255) NOT NULL, Applied DateTime NOT NULL);" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "$Database" -ErrorAction Stop
Write-Host "Done"
}
catch{
Write-Error "Powershell Script error: $_" -EA Stop
}
#database updates
$outputPath = "$pathUpdatesBatch.sql"
$stream = [System.IO.StreamWriter] "$outputPath"
$fileUpdates = Get-ChildItem "$UpdatesFolder"
$datestamp = $(get-date -f "yyyy-MM-dd HH:mm")
$stream.WriteLine("/* SQL Core Updates - Updated $datestamp */")
$stream.WriteLine("BEGIN TRANSACTION")
foreach($file in $fileUpdates)
{
$name = ($file.Name)
$namewe = ([System.IO.Path]::GetFileNameWithoutExtension($name))
$stream.WriteLine("")
$stream.WriteLine("/* File: $name */")
$stream.WriteLine("IF NOT EXISTS (SELECT 1 FROM UpdateTracking WHERE Name = '$namewe')")
$stream.WriteLine("BEGIN")
$stream.WriteLine("`tPrint 'Applying Update: $namewe'")
$stream.WriteLine("`tEXEC('")
(Get-Content "$UpdatesFolder$name") | % {$_ -replace "'", "''"} | % {$stream.WriteLine("`t`t$_")}
$stream.WriteLine("`t');")
$stream.WriteLine("`tINSERT INTO UpdateTracking(Name, Applied) SELECT '$namewe', GETUTCDATE();")
$stream.WriteLine("END")
}
$stream.WriteLine("COMMIT TRANSACTION")
$stream.Close()
Write-Host "Update Script Created."
Write-Host "Running updates..."
Invoke-SqlCmd -InputFile "$outputPath" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "$Database" -Verbose -ErrorAction Stop
Remove-Item "$outputPath"
Write-Host "Updates completed."
}
Note: this has only been run in the context of my personal project. That means don’t copy, paste, and run it immediately against your production environment. Running stuff blindly from the internet is known as both a bad idea and a career limiting maneuver.
Update____Database.ps1
My application has two databases which it will access via different accounts. I want the ability to rebuild these databases from scratch as well as manage their credentials from an external system (in this case, TeamCity). If the worst should happen and these databases are compromised or overwritten in some fashion, I want to be able to recreate them with new credentials, account names, the works.
To make life more difficult, many of these commands have to be executed individually in order to work with Azure Databases.
Both scripts detect if their specified database exists and, if not, create them. The UpdateSampleDatabase is capable of recreating the database in Azure, provided some extra options are passed in to it (the Core Database script is missing this bit, unfortunately):
Excerpt from UpdateSampleDatabase.ps1:
# ...
#database
Write-Host "Checking database exists...";
$result = Invoke-Sqlcmd -Query "SELECT [name] FROM [sys].[databases] WHERE [name] = N'$database'" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "master" -ErrorAction Stop
if($result.name){
Write-Host "Database already exists";
}
else{
Write-Host "Creating Database: $database"
Invoke-Sqlcmd -Query "CREATE DATABASE $database" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "master" -ErrorAction Stop
Invoke-Sqlcmd -Query "ALTER DATABASE $database SET RECOVERY SIMPLE" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "master" -ErrorAction Stop
Write-Host "Created."
}
# ...
They also generate the users specified by the build server (which will also be dynamically added into the relevant web.config files for the website):
Excerpt from UpdateCoreDatabase.ps1:
# ...
#user
try{
Write-Host "Creating User: $NewUserName"
$result = Invoke-Sqlcmd -Query "SELECT [name] FROM sys.sql_logins WHERE name = '$NewUserName'" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "master" -ErrorAction Stop
if($result.name){
Write-Host "Login already exists"
}
else{
Write-Host "Creating login..."
Invoke-Sqlcmd -Query "CREATE LOGIN $NewUserName WITH PASSWORD = '$NewPassword'" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "master" -ErrorAction Stop
Write-Host "Login Created."
}
$result = Invoke-Sqlcmd -Query "SELECT [name] FROM sys.sysusers WHERE name = '$NewUserName'" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "$Database" -ErrorAction Stop
if($result.name){
Write-Host "User already exists"
}
else{
Write-Host "Creating user..."
Invoke-Sqlcmd -Query "CREATE USER $NewUserName FOR LOGIN $NewUserName WITH DEFAULT_SCHEMA = dbo" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "$Database" -ErrorAction Stop
Invoke-Sqlcmd -Query "EXEC sp_addrolemember 'db_datareader','$NewUserName'; EXEC sp_addrolemember 'db_datawriter','$NewUserName'" -ServerInstance "$Server" -Username "$AdminUserName" -Password "$AdminPassword" -Database "$Database" -ErrorAction Stop
Write-Host "User Created."
}
}
catch{
Write-Error "Powershell Script error: $_" -EA Stop
}
# ...
Once the database and users are created, the SampleDatabase script produces a replacement for one of it’s script files that will contain some randomized data. The original file is a placeholder and produces an error if it hasn’t been replaced.
Excerpt from UpdateSampleDatabase.ps1:
# ...
# ---------------------------------- Content Generation ---------------------------------------------
# Scripts to generate content dynamically and update the appropriate update script
#generate customers table content
$CustomersContentPath = "$UpdatesFolder