Skip to content

Commit dd3ab12

Browse files
authored
Add ability to skip .dacpac deployment (#896)
* Add ability to skip deployment if metadata in the target database indicates that the database has already been deployed fixes #860 var builder = DistributedApplication.CreateBuilder(args); var server = builder.AddSqlServer("sql") .WithDataVolume("sampledata") .WithLifetime(ContainerLifetime.Persistent); var database = server.AddDatabase("TargetDatabase"); var sdkProject = builder.AddSqlProject<Projects.SdkProject>("sdk-project") .WithSkipWhenDeployed() .WithReference(database); builder.Build().Run(); * fix typo * fix typo * fix DI * Rename class * Review feedback * PR suggestions * exit early * preserve extended properties * fix checksum! (after focused smoke testing) * Include predeploy.sql and postdeploy.sql * fix name * PR updates * fix comment
1 parent 0246511 commit dd3ab12

File tree

9 files changed

+340
-3
lines changed

9 files changed

+340
-3
lines changed

examples/sql-database-projects/SdkProject/SdkProject.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="MSBuild.Sdk.SqlProj/3.0.0">
1+
<Project Sdk="MSBuild.Sdk.SqlProj/3.2.0">
22
<PropertyGroup>
33
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
44
<SqlServerVersion>Sql150</SqlServerVersion>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
using Microsoft.Data.SqlClient;
2+
using Microsoft.Extensions.Logging;
3+
using System.Data;
4+
using System.Security.Cryptography;
5+
6+
namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects;
7+
8+
internal class DacpacChecksumService : IDacpacChecksumService
9+
{
10+
public async Task<string?> CheckIfDeployedAsync(string dacpacPath, string targetConnectionString, ILogger logger, CancellationToken cancellationToken)
11+
{
12+
var targetDatabaseName = GetDatabaseName(targetConnectionString);
13+
14+
var dacpacPathChecksum = GetStringChecksum(dacpacPath);
15+
16+
var dacpacChecksum = await GetChecksumAsync(dacpacPath);
17+
18+
using var connection = new SqlConnection(targetConnectionString);
19+
20+
try
21+
{
22+
// Try to connect to the target database to see if it exists and fail fast if it does not.
23+
await connection.OpenAsync(SqlConnectionOverrides.OpenWithoutRetry, cancellationToken);
24+
}
25+
catch (Exception ex) when (ex is InvalidOperationException || ex is SqlException)
26+
{
27+
logger.LogWarning(ex, "Target database {TargetDatabase} is not available.", targetDatabaseName);
28+
return dacpacChecksum;
29+
}
30+
31+
var deployed = await CheckExtendedPropertyAsync(connection, dacpacPathChecksum, dacpacChecksum, cancellationToken);
32+
33+
if (deployed)
34+
{
35+
logger.LogInformation("The .dacpac with checksum {DacpacChecksum} has already been deployed to database {TargetDatabaseName}.", dacpacChecksum, targetDatabaseName);
36+
return null;
37+
}
38+
39+
logger.LogInformation("The .dacpac with checksum {DacpacChecksum} has not been deployed to database {TargetDatabaseName}.", dacpacChecksum, targetDatabaseName);
40+
41+
return dacpacChecksum;
42+
}
43+
44+
public async Task SetChecksumAsync(string dacpacPath, string targetConnectionString, string dacpacChecksum, ILogger logger, CancellationToken cancellationToken)
45+
{
46+
var targetDatabaseName = GetDatabaseName(targetConnectionString);
47+
48+
var dacpacPathChecksum = GetStringChecksum(dacpacPath);
49+
50+
using var connection = new SqlConnection(targetConnectionString);
51+
52+
await connection.OpenAsync(SqlConnectionOverrides.OpenWithoutRetry, cancellationToken);
53+
54+
await UpdateExtendedPropertyAsync(connection, dacpacPathChecksum, dacpacChecksum, cancellationToken);
55+
56+
logger.LogInformation("The .dacpac with checksum {DacpacChecksum} has been registered in database {TargetDatabaseName}.", dacpacChecksum, targetDatabaseName);
57+
}
58+
59+
private static string GetDatabaseName(string connectionString)
60+
{
61+
var builder = new SqlConnectionStringBuilder(connectionString);
62+
return builder.InitialCatalog;
63+
}
64+
65+
private static async Task<string> GetChecksumAsync(string file)
66+
{
67+
var output = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
68+
69+
System.IO.Compression.ZipFile.ExtractToDirectory(file, output);
70+
71+
var bytes = await File.ReadAllBytesAsync(Path.Join(output, "model.xml"));
72+
73+
var predeployPath = Path.Join(output, "predeploy.sql");
74+
75+
if (File.Exists(predeployPath))
76+
{
77+
var predeployBytes = await File.ReadAllBytesAsync(predeployPath);
78+
bytes = bytes.Concat(predeployBytes).ToArray();
79+
}
80+
81+
var postdeployPath = Path.Join(output, "postdeploy.sql");
82+
83+
if (File.Exists(postdeployPath))
84+
{
85+
var postdeployBytes = await File.ReadAllBytesAsync(postdeployPath);
86+
bytes = bytes.Concat(postdeployBytes).ToArray();
87+
}
88+
89+
using var sha = SHA256.Create();
90+
var checksum = sha.ComputeHash(bytes);
91+
92+
// Clean up the extracted files
93+
try
94+
{
95+
Directory.Delete(output, true);
96+
}
97+
catch
98+
{
99+
// Ignore any errors during cleanup
100+
}
101+
102+
return BitConverter.ToString(checksum).Replace("-", string.Empty);
103+
}
104+
105+
private static string GetStringChecksum(string text)
106+
{
107+
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
108+
using var sha = SHA256.Create();
109+
var checksum = sha.ComputeHash(bytes);
110+
return BitConverter.ToString(checksum).Replace("-", string.Empty);
111+
}
112+
113+
private static async Task<bool> CheckExtendedPropertyAsync(SqlConnection connection, string dacpacPathChecksum, string dacpacChecksum, CancellationToken cancellationToken)
114+
{
115+
var command = new SqlCommand(
116+
@$"SELECT CAST(1 AS BIT) FROM fn_listextendedproperty(NULL, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT)
117+
WHERE [value] = @Expected
118+
AND [name] = @dacpacId;",
119+
connection);
120+
121+
command.Parameters.AddRange(GetParameters(dacpacChecksum, dacpacPathChecksum));
122+
123+
var result = await command.ExecuteScalarAsync(cancellationToken);
124+
125+
return result == null ? false : (bool)result;
126+
}
127+
128+
private static async Task UpdateExtendedPropertyAsync(SqlConnection connection, string dacpacPathChecksum, string dacpacChecksum, CancellationToken cancellationToken)
129+
{
130+
var command = new SqlCommand($@"
131+
IF EXISTS
132+
(
133+
SELECT 1 FROM fn_listextendedproperty(null, default, default, default, default, default, default)
134+
WHERE [name] = @dacpacId
135+
)
136+
BEGIN
137+
EXEC sp_updateextendedproperty @name = @dacpacId, @value = @Expected;
138+
END
139+
ELSE
140+
BEGIN
141+
EXEC sp_addextendedproperty @name = @dacpacId, @value = @Expected;
142+
END;",
143+
connection);
144+
145+
command.Parameters.AddRange(GetParameters(dacpacChecksum, dacpacPathChecksum));
146+
147+
await command.ExecuteNonQueryAsync(cancellationToken);
148+
}
149+
150+
private static SqlParameter[] GetParameters(string dacpacChecksum, string dacpacPathChecksum)
151+
{
152+
return
153+
[
154+
new SqlParameter("@Expected", SqlDbType.VarChar)
155+
{
156+
Value = dacpacChecksum
157+
},
158+
new SqlParameter("@dacpacId", SqlDbType.NVarChar, 128)
159+
{
160+
Value = dacpacPathChecksum
161+
},
162+
];
163+
}
164+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Aspire.Hosting.ApplicationModel;
2+
3+
/// <summary>
4+
/// Represents a metadata annotation that specifies that .dacpac deployment should be skipped if metadata in the target database indicates that the .dacpac has already been deployed in it's current state.
5+
/// </summary>
6+
public sealed class DacpacSkipWhenDeployedAnnotation : IResourceAnnotation
7+
{
8+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Microsoft.Extensions.Logging;
2+
3+
namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects;
4+
5+
/// <summary>
6+
/// Abstracts the check of the .dacpac file already having been deployed to the target SQL Server database.
7+
/// </summary>
8+
internal interface IDacpacChecksumService
9+
{
10+
/// <summary>
11+
/// Checks if the <paramref name="dacpacPath" /> file has already been deployed to the specified <paramref name="targetConnectionString" />
12+
/// </summary>
13+
/// <param name="dacpacPath">Path to the .dacpac file to deploy.</param>
14+
/// <param name="targetConnectionString">Connection string to the SQL Server.</param>
15+
/// <param name="deploymentSkipLogger">An <see cref="ILogger" /> to write the log to.</param>
16+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the deployment operation.</param>
17+
/// <returns>the checksum calculated for the .dacpac if it has not been deployed, otherwise null</returns>
18+
Task<string?> CheckIfDeployedAsync(string dacpacPath, string targetConnectionString, ILogger deploymentSkipLogger, CancellationToken cancellationToken);
19+
20+
/// <summary>
21+
/// Sets the checksum extended property on the target database to indicate that the <paramref name="dacpacPath" /> file has been deployed.
22+
/// </summary>
23+
/// <param name="dacpacPath">Path to the .dacpac file to deploy.</param>
24+
/// <param name="targetConnectionString">Connection string to the SQL Server.</param>
25+
/// <param name="dacpacChecksum">Checksum for the .dacpac </param>
26+
/// <param name="deploymentSkipLogger">An <see cref="ILogger" /> to write the log to.</param>
27+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the deployment operation.</param>
28+
/// <returns>A task that represents the asynchronous operation.</returns>
29+
Task SetChecksumAsync(string dacpacPath, string targetConnectionString, string dacpacChecksum, ILogger deploymentSkipLogger, CancellationToken cancellationToken);
30+
}

src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects library
2+
23
This package provides [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) integration for SQL Server Database Projects. It allows you to publish SQL Database Projects as part of your .NET Aspire AppHost projects. It currently works with both [MSBuild.Sdk.SqlProj](https://github.com/rr-wfm/MSBuild.Sdk.SqlProj) and [Microsoft.Build.Sql](https://github.com/microsoft/DacFx) (aka .sqlprojx) based projects.
34

45
## Usage
6+
57
To use this package, install it into your .NET Aspire AppHost project:
68

79
```bash
@@ -33,6 +35,7 @@ builder.Build().Run();
3335
Now when you run your .NET Aspire AppHost project you will see the SQL Database Project being published to the specified SQL Server.
3436

3537
## Local .dacpac file support
38+
3639
If you are sourcing your .dacpac file from somewhere other than a project reference, you can also specify the path to the .dacpac file directly:
3740

3841
```csharp
@@ -49,6 +52,7 @@ builder.Build().Run();
4952
```
5053

5154
## Support for existing SQL Server
55+
5256
Instead of using the `AddSqlServer` method to use a SQL Server container, you can specify a connection string to an existing server:
5357

5458
```csharp
@@ -64,6 +68,7 @@ builder.Build().Run();
6468
```
6569

6670
## Deployment options support
71+
6772
Define options that affect the behavior of package deployment.
6873

6974
```csharp
@@ -77,4 +82,24 @@ builder.AddSqlProject("mysqlproj")
7782
.WithReference(sql);
7883

7984
builder.Build().Run();
80-
```
85+
```
86+
87+
## Ability to skip deployment
88+
89+
You can use the `WithSkipWhenDeployed` method to avoid re-deploying your SQL Database Project if no changes have been made. This is useful in scenarios where the SQL container database is persisted to permanent disk and will significantly improve the .NET Aspire AppHost project startup time.
90+
91+
```csharp
92+
var builder = DistributedApplication.CreateBuilder(args);
93+
94+
var server = builder.AddSqlServer("sql")
95+
.WithDataVolume("testdata")
96+
.WithLifetime(ContainerLifetime.Persistent);
97+
98+
var database = server.AddDatabase("test");
99+
100+
var sdkProject = builder.AddSqlProject<Projects.SdkProject>("mysqlproj")
101+
.WithSkipWhenDeployed()
102+
.WithReference(database);
103+
104+
builder.Build().Run();
105+
```

src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectBuilderExtensions.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,29 @@ internal static IResourceBuilder<TResource> InternalWithDacpac<TResource>(this I
114114
return builder.WithAnnotation(new DacpacMetadataAnnotation(dacpacPath));
115115
}
116116

117+
/// <summary>
118+
/// Specifies that .dacpac deployment should be skipped if metadata in the target database indicates that the .dacpac has already been deployed in its current state.
119+
/// </summary>
120+
/// <param name="builder">An <see cref="IResourceBuilder{T}"/> representing the SQL Server Database project.</param>
121+
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
122+
public static IResourceBuilder<SqlProjectResource> WithSkipWhenDeployed(this IResourceBuilder<SqlProjectResource> builder)
123+
=> InternalWithSkipWhenDeployed(builder);
124+
125+
/// <summary>
126+
/// Specifies that .dacpac deployment should be skipped if metadata in the target database indicates that the .dacpac has already been deployed in its current state.
127+
/// </summary>
128+
/// <param name="builder">An <see cref="IResourceBuilder{T}"/> representing the SQL Server Database project.</param>
129+
/// <returns>An <see cref="IResourceBuilder{T}"/> that can be used to further customize the resource.</returns>
130+
public static IResourceBuilder<SqlPackageResource<TPackage>> WithSkipWhenDeployed<TPackage>(this IResourceBuilder<SqlPackageResource<TPackage>> builder)
131+
where TPackage : IPackageMetadata => InternalWithSkipWhenDeployed(builder);
132+
133+
134+
internal static IResourceBuilder<TResource> InternalWithSkipWhenDeployed<TResource>(this IResourceBuilder<TResource> builder)
135+
where TResource : IResourceWithDacpac
136+
{
137+
return builder.WithAnnotation(new DacpacSkipWhenDeployedAnnotation());
138+
}
139+
117140
/// <summary>
118141
/// Adds a delegate annotation for configuring dacpac deployment options to the <see cref="SqlProjectResource"/>.
119142
/// </summary>
@@ -218,6 +241,7 @@ internal static IResourceBuilder<TResource> InternalWithReference<TResource>(thi
218241
where TResource : IResourceWithDacpac
219242
{
220243
builder.ApplicationBuilder.Services.TryAddSingleton<IDacpacDeployer, DacpacDeployer>();
244+
builder.ApplicationBuilder.Services.TryAddSingleton<IDacpacChecksumService, DacpacChecksumService>();
221245
builder.ApplicationBuilder.Services.TryAddSingleton<SqlProjectPublishService>();
222246

223247
builder.WithParentRelationship(target.Resource);

src/CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects/SqlProjectPublishService.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
namespace CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects;
77

8-
internal class SqlProjectPublishService(IDacpacDeployer deployer, IHostEnvironment hostEnvironment, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider)
8+
internal class SqlProjectPublishService(IDacpacDeployer deployer, IDacpacChecksumService dacpacChecksumService, IHostEnvironment hostEnvironment, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider)
99
{
1010
public async Task PublishSqlProject(IResourceWithDacpac resource, IResourceWithConnectionString target, string? targetDatabaseName, CancellationToken cancellationToken)
1111
{
@@ -42,11 +42,33 @@ await resourceNotificationService.PublishUpdateAsync(resource,
4242
return;
4343
}
4444

45+
string? checksum = null;
46+
47+
if (resource.HasAnnotationOfType<DacpacSkipWhenDeployedAnnotation>())
48+
{
49+
options.DropExtendedPropertiesNotInSource = false;
50+
51+
var result = await dacpacChecksumService.CheckIfDeployedAsync(dacpacPath, connectionString, logger, cancellationToken);
52+
if (result is null)
53+
{
54+
await resourceNotificationService.PublishUpdateAsync(resource,
55+
state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success) });
56+
return;
57+
}
58+
59+
checksum = result;
60+
}
61+
4562
await resourceNotificationService.PublishUpdateAsync(resource,
4663
state => state with { State = new ResourceStateSnapshot("Publishing", KnownResourceStateStyles.Info) });
4764

4865
deployer.Deploy(dacpacPath, options, connectionString, targetDatabaseName, logger, cancellationToken);
4966

67+
if (!string.IsNullOrEmpty(checksum) && resource.HasAnnotationOfType<DacpacSkipWhenDeployedAnnotation>())
68+
{
69+
await dacpacChecksumService.SetChecksumAsync(dacpacPath, connectionString, checksum, logger, cancellationToken);
70+
}
71+
5072
await resourceNotificationService.PublishUpdateAsync(resource,
5173
state => state with { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Success) });
5274

0 commit comments

Comments
 (0)