Last active
December 18, 2024 14:03
-
-
Save egil/f3a9d42f58862913d95dbc0b6bba494e to your computer and use it in GitHub Desktop.
Custom "Import database" and "Import BacPac" Aspire commands for a SQL Server Database resource. The first will create a bacpac from a source database and import that bacpac into the Aspire SQL Database resource. The latter will use an existing bacpac file and import that.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var builder = DistributedApplication.CreateBuilder(args); | |
var sqlPassword = builder.AddParameter("sql-password", secret: true); | |
var sqlserver = builder | |
.AddSqlServer("sqlserver", password: sqlPassword) | |
.WithDataVolume() | |
.WithLifetime(ContainerLifetime.Persistent); | |
var importSourceConnectionString = "Server=[REPLACE].database.windows.net;Authentication=Active Directory Interactive;Database=[REPLACE];Encrypt=True;" | |
var db = sqlserver | |
.AddDatabase("[REPLACE]") | |
.WithDatabaseImportCommand(importSourceConnectionString); | |
var conStrResource = builder.AddConnectionString("my-source-database"); | |
var db2 = sqlserver | |
.AddDatabase("[REPLACE]") | |
.WithDatabaseImportCommand(conStrResource); | |
var bacPacFilename = @"c:\backups\my-database.bac"; | |
var db3 = sqlserver | |
.AddDatabase("[REPLACE]") | |
.WithBacPacImportCommand(bacPacFilename); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// requires <PackageVersion Include="Microsoft.SqlServer.DacFx" Version="162.5.57" /> | |
using Aspire.Hosting.Eventing; | |
using Microsoft.Data.SqlClient; | |
using Microsoft.Extensions.Logging; | |
using Microsoft.SqlServer.Dac; | |
namespace Aspire.Hosting.ApplicationModel; | |
internal sealed partial class SqlServerDatabaseImportService( | |
ResourceLoggerService resourceLoggerService, | |
ResourceNotificationService resourceNotificationService, | |
IDistributedApplicationEventing eventing, | |
IServiceProvider serviceProvider) | |
{ | |
public const string ImportingState = "Importing"; | |
public async Task ImportDatabase(string sourceConnectionString, SqlServerDatabaseResource target, CancellationToken cancellationToken) | |
{ | |
var logger = resourceLoggerService.GetLogger(target); | |
var bacPacFilename = string.Empty; | |
try | |
{ | |
await resourceNotificationService.PublishUpdateAsync( | |
target, | |
state => state with { State = new ResourceStateSnapshot(ImportingState, KnownResourceStateStyles.Info) }); | |
bacPacFilename = ExportBacPac( | |
target.DatabaseName, | |
sourceConnectionString, | |
logger, | |
cancellationToken); | |
} | |
catch (Exception ex) | |
{ | |
LogFailedDatabaseImport(logger, ex, target.Name); | |
await resourceNotificationService.PublishUpdateAsync( | |
target, | |
state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Error), }); | |
} | |
await ImportBacPacFile(bacPacFilename, target, cancellationToken); | |
} | |
public async Task ImportBacPacFile(string bacPacFileName, SqlServerDatabaseResource target, CancellationToken cancellationToken) | |
{ | |
var logger = resourceLoggerService.GetLogger(target); | |
try | |
{ | |
await resourceNotificationService.PublishUpdateAsync( | |
target, | |
state => state with { State = new ResourceStateSnapshot(ImportingState, KnownResourceStateStyles.Info) }); | |
await DropExistingDatabase( | |
target, | |
logger, | |
cancellationToken); | |
LogImportingDatabase(logger, bacPacFileName, target.Name); | |
await ImportBacPac( | |
target, | |
BacPackage.Load(bacPacFileName), | |
logger, | |
cancellationToken); | |
await resourceNotificationService.PublishUpdateAsync( | |
target, | |
state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success) }); | |
await eventing.PublishAsync(new ResourceReadyEvent(target, serviceProvider), cancellationToken); | |
} | |
catch (Exception ex) | |
{ | |
LogFailedDatabaseImport(logger, ex, target.Name); | |
await resourceNotificationService.PublishUpdateAsync( | |
target, | |
state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Error), }); | |
} | |
} | |
private static string ExportBacPac(string databaseName, string sourceConnectionString, ILogger logger, CancellationToken cancellationToken) | |
{ | |
var bacPacFile = Path.Combine(Directory.CreateTempSubdirectory().FullName, databaseName + ".bacpac"); | |
LogExportingDatabase(logger, sourceConnectionString, bacPacFile); | |
var exportDacService = new DacServices(sourceConnectionString); | |
exportDacService.Message += (sender, args) => LogExportMessage(logger, args.Message.ToString()); | |
exportDacService.ExportBacpac( | |
bacPacFile, | |
databaseName, | |
cancellationToken: cancellationToken); | |
return bacPacFile; | |
} | |
private static async Task DropExistingDatabase(SqlServerDatabaseResource target, ILogger logger, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
LogAttemptToDropDatabase(logger, target.Name); | |
var targetConnectionString = await target.ConnectionStringExpression.GetValueAsync(cancellationToken); | |
await using var connection = new SqlConnection(targetConnectionString); | |
await connection.OpenAsync(cancellationToken); | |
await using var command = connection.CreateCommand(); | |
command.CommandText = $""" | |
USE master; | |
ALTER DATABASE [{target.DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; | |
DROP DATABASE [{target.DatabaseName}]; | |
"""; | |
await command.ExecuteNonQueryAsync(cancellationToken); | |
LogDatabaseDropped(logger, target.Name); | |
} | |
catch (SqlException ex) when (ex.ErrorCode == -2146232060) | |
{ | |
LogNoExistingDatabase(logger, ex, target.Name); | |
} | |
} | |
private static async Task ImportBacPac(SqlServerDatabaseResource target, BacPackage bacpac, ILogger logger, CancellationToken cancellationToken) | |
{ | |
var targetConnectionString = await target.ConnectionStringExpression.GetValueAsync(cancellationToken); | |
var dacService = new DacServices(targetConnectionString); | |
dacService.Message += (sender, args) => LogImportMessage(logger, args.Message.ToString()); | |
dacService.ImportBacpac( | |
bacpac, | |
target.DatabaseName, | |
cancellationToken: cancellationToken); | |
} | |
[LoggerMessage(Level = LogLevel.Information, Message = "Exporting database backup from '{SourceConnectionString}' to '{BacPacFile}'.")] | |
public static partial void LogExportingDatabase(ILogger logger, string sourceConnectionString, string bacPacFile); | |
[LoggerMessage(Level = LogLevel.Information, Message = "{Message}")] | |
public static partial void LogExportMessage(ILogger logger, string message); | |
[LoggerMessage(Level = LogLevel.Information, Message = "Importing database backup from '{BacPacFile}' to '{TargetDatabaseResourceName}'.")] | |
public static partial void LogImportingDatabase(ILogger logger, string bacPacFile, string targetDatabaseResourceName); | |
[LoggerMessage(Level = LogLevel.Information, Message = "{Message}")] | |
public static partial void LogImportMessage(ILogger logger, string message); | |
[LoggerMessage(Level = LogLevel.Information, Message = "Attempting to drop existing database {TargetDatabaseResourceName}.")] | |
public static partial void LogAttemptToDropDatabase(ILogger logger, string targetDatabaseResourceName); | |
[LoggerMessage(Level = LogLevel.Information, Message = "Database {TargetDatabaseResourceName} dropped.")] | |
public static partial void LogDatabaseDropped(ILogger logger, string targetDatabaseResourceName); | |
[LoggerMessage(Level = LogLevel.Information, Message = "The target database {TargetDatabaseResourceName} did not exist.")] | |
public static partial void LogNoExistingDatabase(ILogger logger, Exception exception, string targetDatabaseResourceName); | |
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to retrieve connection string for target database {TargetDatabaseResourceName}.")] | |
public static partial void LogNoConnectionString(ILogger logger, string targetDatabaseResourceName); | |
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to import database {TargetDatabaseResourceName}.")] | |
public static partial void LogFailedDatabaseImport(ILogger logger, Exception exception, string targetDatabaseResourceName); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Microsoft.Extensions.DependencyInjection; | |
using Microsoft.Extensions.DependencyInjection.Extensions; | |
using Microsoft.Extensions.Diagnostics.HealthChecks; | |
namespace Aspire.Hosting.ApplicationModel; | |
public static class SqlServerDatabaseResourceExtensions | |
{ | |
public static IResourceBuilder<SqlServerDatabaseResource> WithDatabaseImportCommand( | |
this IResourceBuilder<SqlServerDatabaseResource> target, | |
IResourceBuilder<IResourceWithConnectionString> importSource) | |
{ | |
target.ApplicationBuilder.Services.TryAddSingleton<SqlServerDatabaseImportService>(); | |
target.WithCommand( | |
name: "ImportDatabase", | |
displayName: "Import database", | |
executeCommand: async context => | |
{ | |
var service = context.ServiceProvider.GetRequiredService<SqlServerDatabaseImportService>(); | |
var importSourceConnectionString = await importSource.Resource.GetConnectionStringAsync(context.CancellationToken); | |
if (importSourceConnectionString is null) | |
{ | |
return new ExecuteCommandResult { Success = false, ErrorMessage = "Import source connection string is null." }; | |
} | |
await service.ImportDatabase(importSourceConnectionString, target.Resource, context.CancellationToken); | |
return new ExecuteCommandResult { Success = true }; | |
}, | |
updateState: (context) => context.ResourceSnapshot.HealthStatus == HealthStatus.Healthy && context.ResourceSnapshot?.State != SqlServerDatabaseImportService.ImportingState | |
? ResourceCommandState.Enabled | |
: ResourceCommandState.Disabled, | |
displayDescription: "Create a BacPac from the import source, drops the existing database and imports the BacPac to replace it.", | |
confirmationMessage: "This will drop the existing database. Are you sure you want to proceed?", | |
iconName: "ArrowImport"); | |
return target; | |
} | |
public static IResourceBuilder<SqlServerDatabaseResource> WithDatabaseImportCommand( | |
this IResourceBuilder<SqlServerDatabaseResource> target, | |
string importSourceConnectionString) | |
{ | |
target.ApplicationBuilder.Services.TryAddSingleton<SqlServerDatabaseImportService>(); | |
target.WithCommand( | |
name: "ImportDatabase", | |
displayName: "Import database", | |
executeCommand: async context => | |
{ | |
var service = context.ServiceProvider.GetRequiredService<SqlServerDatabaseImportService>(); | |
await service.ImportDatabase(importSourceConnectionString, target.Resource, context.CancellationToken); | |
return new ExecuteCommandResult { Success = true }; | |
}, | |
updateState: (context) => context.ResourceSnapshot.HealthStatus == HealthStatus.Healthy && context.ResourceSnapshot?.State != SqlServerDatabaseImportService.ImportingState | |
? ResourceCommandState.Enabled | |
: ResourceCommandState.Disabled, | |
displayDescription: "Create a BacPac from the import source, drops the existing database and imports the BacPac to replace it.", | |
confirmationMessage: "This will drop the existing database. Are you sure you want to proceed?", | |
iconName: "ArrowImport"); | |
return target; | |
} | |
public static IResourceBuilder<SqlServerDatabaseResource> WithBacPacImportCommand( | |
this IResourceBuilder<SqlServerDatabaseResource> target, | |
string bacPacFilename) | |
{ | |
target.ApplicationBuilder.Services.TryAddSingleton<SqlServerDatabaseImportService>(); | |
target.WithCommand( | |
name: "ImportBacPac", | |
displayName: "Import BacPac", | |
executeCommand: async context => | |
{ | |
var service = context.ServiceProvider.GetRequiredService<SqlServerDatabaseImportService>(); | |
await service.ImportBacPacFile(bacPacFilename, target.Resource, context.CancellationToken); | |
return new ExecuteCommandResult { Success = true }; | |
}, | |
updateState: (context) => context.ResourceSnapshot.HealthStatus == HealthStatus.Healthy && context.ResourceSnapshot?.State != SqlServerDatabaseImportService.ImportingState | |
? ResourceCommandState.Enabled | |
: ResourceCommandState.Disabled, | |
displayDescription: $"Drops the existing database and imports the '{bacPacFilename}' to replace it.", | |
confirmationMessage: "This will drop the existing database. Are you sure you want to proceed?", | |
iconName: "ArrowImport"); | |
return target; | |
} | |
} |
@PureKrome added another version that takes a filepath to an existing BacPac file. That should cover your needs.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Would be kewl to have an bacpac file already, instead of grabbing from another existing server.
For example -> lets have a fake db with fake data. great! now lets manually make a bacpac. now lets store that in git with the repo. Now, when we start the app we check if there's a db. if not, import bacpac. nice.
during the dev lifecycle we can then drop the db and restart the app ... and it will load that bacpac again.
and that bacpac is a state of data / snapshot in a point in time.
If we change any data (schema changes, data changes like add a new user, or product, or car, or whatever), we update the db, export the bacpac and commit the bacpac back into the repo.
I'm currently doing this but not with a bacpac. I'm just doing this with a simple EF Core 'EnsureDatabase' and then manual data seeding.
This starts to 'hurt' when I do my integration tests and I wish to have a unique copy of the db -per test- (so it's REALLY isolated). Sure, it works... it's just slow. I'm hoping a bacpac import is faster.