Uruchamianie migracji bazy w Azure DevOps

Wprowadzenie

Już kiedyś opisywałem na tym blogu, że jednym z większych problemów, które widzę w projektach moich kursantów, są migracje bazy danych. Kilka miesięcy temu opisywałem trzy różne podejścia: migrację w Entity Framework, Fluent Migratora oraz DbUp. Podejścia te, jak sądzę, pokrywają większość przypadków, które możesz mieć w swojej aplikacji.

Kolejnym problemem związanym z migracjami jest ich uruchamianie. O ile w jakimś projekcie jest już użyty jakiś mechanizm (na ogół są to migracje z Entity Framework), to pojawia się problem z jego uruchomieniem. Na ogół niestety migracje wykonują się przy starcie aplikacji, z użyciem tego samego connection stringa, na którym działa później aplikacja. To niestety później, w przypadku wystąpienia podatności SQL Injection w aplikacji, może prowadzić do tragicznych skutków. Użyty użytkownik musi mieć podwyższone uprawnienia, aby mógł wykonać migracje i zmienić strukturę bazy danych.

W tym wpisie pokażę Ci, jak można wykonać migrację bazy danych w trakcie wdrażania aplikacji (mówimy o aplikacjach webowych). Dorzucę tutaj również możliwość tworzenia migawek bazy danych, aby można było później na danej instancji aplikacji wykonywać automatyczne testy Postmana.

Migrator

We wspomnianych wyżej wpisach utworzyłem dedykowaną aplikację konsolową do wykonywania migracji baz danych. W tym wpisie rozbuduję trochę migratora z wpisu o migracjach w Entity Framework. Jeśli nie czytałeś tego wpisu, to najlepiej, abyś zrobił to przed przeczytaniem dalszej części.

Rozbudowa migratora będzie polegać na dodaniu możliwości tworzenia migawek bazy danych, aby można było je tworzyć po zmigrowaniu bazy danych do nowego schematu. Tutaj podobnie, jeśli nie czytałeś wpisu o wykorzystaniu Sql Server Snapsthops do resetowania danych w testach, zapraszam do przeczytania.

Migrator w przypadku wykonywania migawki będzie działał w następujący sposób: cofnie stan bazy na podstawie migawki, usunie migawkę, wykona migrację bazy, utworzy nową migawkę. W pozostałych sytuacjach wykona tylko samą migrację bazy.

W tym celu rozbudowałem DatabaseRestoreService o możliwość tworzenia oraz usuwania migawek. Dodatkowo usługa loguje operacje z użyciem nLoga:

public interface IDatabaseRestoreService
{
Result Restore(string connectionString);
Result CreateSnapshot(string connectionString, string path);
Result DropSnapshot(string connectionString);
}
public class DatabaseRestoreService : IDatabaseRestoreService
{
private static NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();
public Result Restore(string connectionString)
{
var connectionBuilder = new SqlConnectionStringBuilder(connectionString);
var databaseName = connectionBuilder.InitialCatalog;
_logger.Info($"Restore snapshot for {databaseName} database");
var connectionToMasterBuilder = new SqlConnectionStringBuilder(connectionString)
{ InitialCatalog = "master" };
using (var conn = new SqlConnection(connectionToMasterBuilder.ConnectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"IF DB_ID('{databaseName}_Snapshot') IS NOT NULL
BEGIN
ALTER DATABASE {databaseName} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
RESTORE DATABASE {databaseName} FROM DATABASE_SNAPSHOT = '{databaseName}_Snapshot';
ALTER DATABASE {databaseName} SET MULTI_USER;
END";
_logger.Trace(cmd.CommandText);
cmd.ExecuteNonQuery();
}
}
return Result.Ok();
}
public Result CreateSnapshot(string connectionString, string path)
{
var connectionBuilder = new SqlConnectionStringBuilder(connectionString);
var databaseName = connectionBuilder.InitialCatalog;
_logger.Info($"Create snapshot for {databaseName} database");
var connectionToMasterBuilder = new SqlConnectionStringBuilder(connectionString)
{ InitialCatalog = "master" };
using (var conn = new SqlConnection(connectionToMasterBuilder.ConnectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"CREATE DATABASE {databaseName}_Snapshot ON
( NAME = {databaseName}, FILENAME = '{path}' )
AS SNAPSHOT OF {databaseName};";
_logger.Trace(cmd.CommandText);
cmd.ExecuteNonQuery();
}
}
return Result.Ok();
}
public Result DropSnapshot(string connectionString)
{
var connectionBuilder = new SqlConnectionStringBuilder(connectionString);
var databaseName = connectionBuilder.InitialCatalog;
_logger.Info($"Drop snapshot for {databaseName} database");
var connectionToMasterBuilder = new SqlConnectionStringBuilder(connectionString)
{ InitialCatalog = "master" };
using (var conn = new SqlConnection(connectionToMasterBuilder.ConnectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"IF DB_ID('{databaseName}_Snapshot') IS NOT NULL
DROP DATABASE {databaseName}_Snapshot;";
_logger.Trace(cmd.CommandText);
cmd.ExecuteNonQuery();
}
}
return Result.Ok();
}
}

W Migratorze rozszerzyłem klasę Options poprzez dodanie dwóch nowych właściwości. CreateSnapshot służy do przekazania informacji, czy podczas migracji wykonywane będą operacje na migawkach. Natomiast SnapshotPath posłuży do przekazania ścieżki, gdzie ma zostać zapisany plik migawki:

public class Options
{
[Option('c', "connectionString", Required = true, HelpText = "The connection string to database that needs to be updated.")]
public string ConnectionString { get; set; }
[Option('s', "createSnapshot", Required = false, HelpText = "Should create database snapshot after running migrations.")]
public bool CreateSnapshot { get; set; }
[Option('p', "snapshotPath", Required = false, HelpText = "The path for snapshot database.")]
public string SnapshotPath { get; set; }
}
view raw Options.cs hosted with ❤ by GitHub

Oczywiście tutaj można by dodać więcej parametrów. Na przykład parametr do przekazania nazwy migawki, zamiast budowania jej w określony sposób w DatabaseRestoreService.

Po wprowadzeniu nowych opcji zmieniła się również klasa Program Migratora:

class Program
{
private static DatabaseRestoreService _databaseRestoreService;
protected static DatabaseRestoreService DatabaseRestoreService
{
get
{
if(_databaseRestoreService == null)
{
_databaseRestoreService = new DatabaseRestoreService();
}
return _databaseRestoreService;
}
}
static void Main(string[] args)
{
var result = Parser.Default.ParseArguments<Options>(args);
result
.WithParsed(r => Migrate(r));
}
private static void Migrate(Options options)
{
if(options.CreateSnapshot)
{
DatabaseRestoreService.Restore(options.ConnectionString);
DatabaseRestoreService.DropSnapshot(options.ConnectionString);
}
var configuration = new Configuration();
configuration.TargetDatabase = new DbConnectionInfo(
options.ConnectionString,
"System.Data.SqlClient");
var migrator = new DbMigrator(configuration);
MigratorLoggingDecorator logger = new MigratorLoggingDecorator(migrator, new MigrationLogger());
logger.Update();
if(options.CreateSnapshot)
{
DatabaseRestoreService.CreateSnapshot(options.ConnectionString, options.SnapshotPath);
}
}
}
view raw Program.cs hosted with ❤ by GitHub

Tak jak wspomniałem wyżej, w metodzie Migrate sprawdzamy wartość właściwości CreateSnapshot. Gdy jest na true, przywracamy bazę do stanu z migawki i migawkę usuwamy. Na końcu po wykonaniu migracji ponownie sprawdzamy CreateSnapshot i tworzymy nową migawkę, gdy właściwość ma wartość true.

Uruchomienie tak przygotowanego migratora wygląda w ten sposób:

WebApiTests.Migrator.exe -c "Server=.\sqlexpress;Database=WebApiTests;Trusted_Connection=True;" -s true -p "C:\db\snapshot\WebApiTests_Snapshot.ss"
view raw cmd hosted with ❤ by GitHub

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Uruchomienie migracji w Azure DevOps

Mając już przygotowanego migratora, możemy uruchomić migrację podczas wdrażania aplikacji. W tym wpisie rozbuduję definicję build oraz release z wpisu o Sql Server Snapsthops do resetowania danych w testach.

W definicji build dodałem kopiowanie plików „*.exe|*.dll|*.pdb|*.config” do folderu bin w artefaktach, aby później pliki te (w tym i migrator) były dostępne podczas wykonywania wdrożenia aplikacji. Sam krok wygląda tak:

- task: CopyFiles@2
inputs:
SourceFolder: '$(Build.SourcesDirectory)'
Contents: '**/$(BuildConfiguration)/**/?(*.exe|*.dll|*.pdb|*.config)'
TargetFolder: '$(Build.ArtifactStagingDirectory)/bin'
view raw build.yml hosted with ❤ by GitHub

Cały plik definicji builda znajdziesz na githubie: https://github.com/danielplawgo/WebApiTests/blob/migration/azure-pipelines.yml.

W definicji release na początku dodałem dwie nowe zmienne (MigrationConnectionString oraz SnapshotPath), które później wykorzystam podczas wywoływania migratora:

azure devops run migration variables

W zmiennej MigrationConnectionString  znajduje się connection string do bazy, który zostanie wykorzystany podczas migracji bazy danych do nowej wersji. Tak jak wspomniałem wcześniej, tutaj używamy użytkownika, który ma zwiększone uprawnienia i może zmieniać schemat bazy. Natomiast zmienna SnapshotPath zawiera ścieżkę do pliku migawki.

Do listy zadań dodałem na samej górze wywołanie komendy wiersza poleceń:

azure devops run migration cmd task

W konfiguracji taska poza zmianą nazwy ustawiłem dwie rzeczy:

  • Working Directory (w sekcji Advanced) – wybrałem folder, w którym znajduje się skompilowany migrator – są to właśnie te pliki kopiowane przez dodany do buildu aplikacji task. Ustawienie tego folderu w ten sposób powoduje, że w Script mogę już wywoływać bezpośrednio migrator
  • Script – wywołanie samego migratora z przekazaniem odpowiednich parametrów. Parametry są pobierane ze zmiennych z użyciem składni: $(nazwa zmiennej)

Dokładną zawartością Script jest:

WebApiTests.Migrator.exe -c "$(MigrationConnectionString)" -s true -p "$(SnapshotPath)"
view raw cmd.task hosted with ❤ by GitHub

Tak skonfigurowany task spowoduje, że przed wdrożeniem nowej wersji aplikacji zostanie wykonana migracja bazy danych oraz snapshot na potrzeby testów WebApi z Postmana.

Przykładowy log wykonania tego nowego taska:

2019-09-15T06:55:52.2510007Z ##[section]Starting: Run database migration
2019-09-15T06:55:52.2637001Z ==============================================================================
2019-09-15T06:55:52.2637104Z Task : Command line
2019-09-15T06:55:52.2637193Z Description : Run a command line script using Bash on Linux and macOS and cmd.exe on Windows
2019-09-15T06:55:52.2637265Z Version : 2.151.2
2019-09-15T06:55:52.2637340Z Author : Microsoft Corporation
2019-09-15T06:55:52.2637418Z Help : https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/command-line
2019-09-15T06:55:52.2637511Z ==============================================================================
2019-09-15T06:55:53.5204729Z Generating script.
2019-09-15T06:55:53.5431189Z Script contents:
2019-09-15T06:55:53.5438365Z WebApiTests.Migrator.exe -c "Server=104.40.135.99;Database=WebApiTests;User Id=daniel;Password='PasswordToSqlServer';" -s true -p "C:\db\snapshot\WebApiTests_Snapshot.ss"
2019-09-15T06:55:53.5787804Z ========================== Starting Command Output ===========================
2019-09-15T06:55:53.6058032Z ##[command]"C:\windows\system32\cmd.exe" /D /E:ON /V:OFF /S /C "CALL "d:\a\_temp\942d3048-d85e-4997-8ea3-aa3becf6f5ca.cmd""
2019-09-15T06:56:00.1463923Z 2019-09-15 06:56:00.0025 INFO Restore snapshot for WebApiTests database
2019-09-15T06:56:01.4606166Z 2019-09-15 06:56:01.4271 TRACE IF DB_ID('WebApiTests_Snapshot') IS NOT NULL
2019-09-15T06:56:01.4606744Z BEGIN
2019-09-15T06:56:01.4606943Z ALTER DATABASE WebApiTests SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
2019-09-15T06:56:01.4607153Z RESTORE DATABASE WebApiTests FROM DATABASE_SNAPSHOT = 'WebApiTests_Snapshot';
2019-09-15T06:56:01.4607338Z ALTER DATABASE WebApiTests SET MULTI_USER;
2019-09-15T06:56:01.4607716Z END
2019-09-15T06:56:02.1985831Z 2019-09-15 06:56:02.1939 INFO Drop snapshot for WebApiTests database
2019-09-15T06:56:02.1986673Z 2019-09-15 06:56:02.1939 TRACE IF DB_ID('WebApiTests_Snapshot') IS NOT NULL
2019-09-15T06:56:02.1986915Z DROP DATABASE WebApiTests_Snapshot;
2019-09-15T06:56:06.6874425Z 2019-09-15 06:56:06.6766 TRACE Target database is: 'WebApiTests' (DataSource: 104.40.135.99, Provider: System.Data.SqlClient, Origin: Explicit).
2019-09-15T06:56:07.7639754Z 2019-09-15 06:56:07.7615 INFO No pending explicit migrations.
2019-09-15T06:56:07.8954448Z 2019-09-15 06:56:07.8708 INFO Running Seed method.
2019-09-15T06:56:07.8981609Z 2019-09-15 06:56:07.8865 INFO Create snapshot for WebApiTests database
2019-09-15T06:56:07.8984340Z 2019-09-15 06:56:07.8865 TRACE CREATE DATABASE WebApiTests_Snapshot ON
2019-09-15T06:56:07.8985341Z ( NAME = WebApiTests, FILENAME = 'C:\db\snapshot\WebApiTests_Snapshot.ss' )
2019-09-15T06:56:07.8985500Z AS SNAPSHOT OF WebApiTests;
2019-09-15T06:56:09.8687363Z ##[section]Finishing: Run database migration
view raw cmd.log hosted with ❤ by GitHub

Przykład

Kod wykorzystany w tym wpisie znajdziesz na githubie. Rozbudowałem wcześniejszy przykład i wszystkie dodatkowe zmiany znajdują się w branch migration – https://github.com/danielplawgo/WebApiTests/tree/migration. W przykładzie poza zmianą connection stringa w Web.config w projekcie WebApi należy jeszcze zmienić connection stringa we właściwościach projektu migratora w zakładce Debug, w której ustawia się parametry przekazywane w wierszu poleceń podczas uruchamiania aplikacji z poziomu Visual Studio.

Podsumowanie

Mam nadzieję, że udało mi się pokazać Ci, że wykonywanie migracji bazy danych z użyciem innego użytkownika podczas wdrażania jej nie musi być czymś skomplikowanym. Gorąco Ci polecam, abyś nie wykonywał migracji podczas startu aplikacji, bo niestety zawsze niesie to za sobą jakieś dodatkowe ryzyko.

A może u Ciebie w projekcie ten problem rozwiązaliście w inny sposób? Z chęcią dowiem się, jak to jest u Was. Zostaw komentarz pod wpisem!

2 thoughts on “Uruchamianie migracji bazy w Azure DevOps

  • Pingback: dotnetomaniak.pl
  • Bardzo przydatny tekst – dużo ciekawych i nowych rzeczy się dowiedziałam. Przyjemnie się czytało, będę zaglądała częściej. Migracja azure jest naprawdę świetnym rozwiązaniem.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *