Skip to content

Instantly share code, notes, and snippets.

@egil
Last active December 18, 2024 14:03
Show Gist options
  • Save egil/f3a9d42f58862913d95dbc0b6bba494e to your computer and use it in GitHub Desktop.
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.
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);
// 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);
}
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
Copy link

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.

@egil
Copy link
Author

egil commented Dec 18, 2024

@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