Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sample of container with persistent volume mount #52

Merged
merged 3 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<PackageVersion Include="Aspire.Hosting.Dapr" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Npgsql" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Microsoft.Data.SqlClient" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.StackExchange.Redis" Version="$(AspireVersion)" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="$(AspireVersion)" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery.Abstractions" Version="$(AspireVersion)" />
Expand Down
71 changes: 71 additions & 0 deletions samples/VolumeMount/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
languages:
- csharp
products:
- dotnet
- dotnet-aspire
page_type: sample
name: ".NET Aspire persistent volume mount sample"
urlFragment: "aspire-volume-mount"
description: "An example demonstrating how to configure a local SQL Server container to use a persistent volume mount in .NET Aspire."
---

# Persistent Volume Mount

This sample demonstrates how to configure a SQL Server container to use a persistent volume mount in .NET Aspire, so that the data is persisted across app launches. This method can be used to persist data across instances of other container types configured in .NET Aspire apps too, e.g. PostgreSQL, Redis, etc.

The app consists of a single service, **VolumeMount.BlazorWeb**, that is configured with a SQL Server container instance via the AppHost project. This Blazor Web app has been setup to use ASP.NET Core Identity for local user account registration and authentication, including the new [Blazor identity UI](https://devblogs.microsoft.com/dotnet/whats-new-with-identity-in-dotnet-8/#the-blazor-identity-ui). Using a persistent volume mount means that user accounts created when running locally are persisted across launches of the app.

![Screenshot of the account login page on the web front](./images/volume-mount-frontend-login.png)

The app also includes a standard class library project, **VolumeMount.ServiceDefaults**, that contains the service defaults used by the service project.

## Pre-requisites

- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- [Docker Desktop](https://www.docker.com/products/docker-desktop/)
- **Optional** [Visual Studio 2022 17.9 Preview](https://visualstudio.microsoft.com/vs/preview/)

## Running the app

1. If using Visual Studio, open the solution file `VolumeMount.sln` and launch/debug the `VolumeMount.AppHost` project.

If using the .NET CLI, run `dotnet run` from the `VolumeMount.AppHost` directory.

1. The first time you run the app, you'll receieve an error indicating that a password for the SQL Server container has not been configured. To configure a password, set the `sqlpassword` key in the User Secrets store of the `VolumeMount.AppHost` project using the `dotnet user-secrets` command in the AppHost project directory:

```
dotnet user-secrets set sqlpassword <password>
```

A stable password is required when using a persistent volume mount for SQL Server data, rather than the default auto-generated password.

1. Once a password is configured, run the `VolumeMount.AppHost` project again and navigate to the URL for the `VolumeMount.BlazorWeb` from the dashboard.

1. From the home page, click the "Register" link and enter a email and password to create a local user:

![Screenshot of the account registration page on the web front end](./images/volume-mount-frontend-register.png)

1. After a short while (30-60s) an error page will be displayed stating that the database is not initialized and suggesting that EF Core migration be run. Click the "Apply Migrations" button and once the button text changes to "Migrations Applied", refresh the browser and confirm the form resubmission when prompted by the browser:

![Screenshot of the database operation failed error page](./images/volume-mount-frontend-dbcontext-error.png)

1. A page will be shown confirming the registration of the account and a message detailing that a real email sender is not registered. Find and click the link at the end of the message to confirm the created account:

![Screenshot of the account registration confirmation page](./images/volume-mount-frontend-account-registered.png)

1. Verify that the email confirmation page is displayed, indicating that the account is now registered and can be used to login to the site, and then click on the "Login" link in the left-hand navigation menu:

![Screenshot of the email confirmation page](./images/volume-mount-frontend-email-confirmed.png)

1. Enter the email and password you used in the account registration page to login:

![Screenshot of the login page](./images/volume-mount-frontend-login.png)

1. Once logged in, click the "Logout" link in the left-hand navigation menu to log out of the site, and then stop the app, followed by starting it again, and verifying that the account you just created can still be used to login to the site once restarted, indicating that the database was using the persistent volume to store the data. You can verify the named volume existance using the Docker CLI too (`docker volume ls`):

```
> docker volume ls -f name=sqlserver
DRIVER VOLUME NAME
local VolumeMount.sqlserver.data
```
24 changes: 24 additions & 0 deletions samples/VolumeMount/VolumeMount.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Extensions.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

// Using a persistent volume mount requires a stable password for 'sa' rather than the default generated one.
var sqlpassword = builder.Configuration["sqlpassword"];

if (builder.Environment.IsDevelopment() && string.IsNullOrEmpty(sqlpassword))
{
throw new InvalidOperationException("""
A password for the local SQL Server container is not configured.
Add one to the AppHost project's user secrets with the key 'sqlpassword', e.g. dotnet user-secrets set sqlpassword <password>
""");
}

// To have a persistent volume mount across container instances, it must be named (VolumeMountType.Named).
var database = builder.AddSqlServerContainer("sqlserver", sqlpassword)
.WithVolumeMount($"VolumeMount.sqlserver.data", "/var/opt/mssql", VolumeMountType.Named)
.AddDatabase("appdb");

builder.AddProject<Projects.VolumeMount_BlazorWeb>("blazorweb")
.WithReference(database);

builder.Build().Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15050",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16187"
}
}
}
}
20 changes: 20 additions & 0 deletions samples/VolumeMount/VolumeMount.AppHost/VolumeMount.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>969c2ebd-aca5-4e75-aac2-cffc93ee7e80</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\VolumeMount.BlazorWeb\VolumeMount.BlazorWeb.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions samples/VolumeMount/VolumeMount.AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using VolumeMount.BlazorWeb.Components.Account.Pages;
using VolumeMount.BlazorWeb.Components.Account.Pages.Manage;
using VolumeMount.BlazorWeb.Data;

namespace Microsoft.AspNetCore.Routing;

internal static class IdentityComponentsEndpointRouteBuilderExtensions
{
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

var accountGroup = endpoints.MapGroup("/Account");

accountGroup.MapPost("/PerformExternalLogin", (
HttpContext context,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromForm] string provider,
[FromForm] string returnUrl) =>
{
IEnumerable<KeyValuePair<string, StringValues>> query = [
new("ReturnUrl", returnUrl),
new("Action", ExternalLogin.LoginCallbackAction)];

var redirectUrl = UriHelper.BuildRelative(
context.Request.PathBase,
"/Account/ExternalLogin",
QueryString.Create(query));

var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return TypedResults.Challenge(properties, [provider]);
});

accountGroup.MapPost("/Logout", async (
ClaimsPrincipal user,
SignInManager<ApplicationUser> signInManager,
[FromForm] string returnUrl) =>
{
await signInManager.SignOutAsync();
return TypedResults.LocalRedirect($"~/{returnUrl}");
});

var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();

manageGroup.MapPost("/LinkExternalLogin", async (
HttpContext context,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromForm] string provider) =>
{
// Clear the existing external cookie to ensure a clean login process
await context.SignOutAsync(IdentityConstants.ExternalScheme);

var redirectUrl = UriHelper.BuildRelative(
context.Request.PathBase,
"/Account/Manage/ExternalLogins",
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));

var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
return TypedResults.Challenge(properties, [provider]);
});

var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");

manageGroup.MapPost("/DownloadPersonalData", async (
HttpContext context,
[FromServices] UserManager<ApplicationUser> userManager,
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
}

var userId = await userManager.GetUserIdAsync(user);
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);

// Only include personal data for download
var personalData = new Dictionary<string, string>();
var personalDataProps = typeof(ApplicationUser).GetProperties().Where(
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
foreach (var p in personalDataProps)
{
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
}

var logins = await userManager.GetLoginsAsync(user);
foreach (var l in logins)
{
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
}

personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);

context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
});

return accountGroup;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using VolumeMount.BlazorWeb.Data;

namespace VolumeMount.BlazorWeb.Components.Account;

// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
{
private readonly IEmailSender emailSender = new NoOpEmailSender();

public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");

public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");

public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;

namespace VolumeMount.BlazorWeb.Components.Account;

internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
{
public const string StatusCookieName = "Identity.StatusMessage";

private static readonly CookieBuilder StatusCookieBuilder = new()
{
SameSite = SameSiteMode.Strict,
HttpOnly = true,
IsEssential = true,
MaxAge = TimeSpan.FromSeconds(5),
};

[DoesNotReturn]
public void RedirectTo(string? uri)
{
uri ??= "";

// Prevent open redirects.
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
{
uri = navigationManager.ToBaseRelativePath(uri);
}

// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect.
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown.
navigationManager.NavigateTo(uri);
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering.");
}

[DoesNotReturn]
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
{
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
RedirectTo(newUri);
}

[DoesNotReturn]
public void RedirectToWithStatus(string uri, string message, HttpContext context)
{
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
RedirectTo(uri);
}

private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);

[DoesNotReturn]
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);

[DoesNotReturn]
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
=> RedirectToWithStatus(CurrentPath, message, context);
}
Loading