Tuesday, August 18, 2020

Deploying ASP.Net Boilerplate Apps for Devs in a Hurry


I've deployed two apps based on ASP.Net Boilerplate (Angular + ASP.Net Core) through to production.  The second used Azure Kubernetes Services.  It's slick, sophisticated, self-healing, extremely scalable, uses infrastructure as code with a multi-stage Azure DevOps build pipeline, and it's micro-services are independently deploy-able.

That second site is awesome.  It's also complicated, took a lot of effort to build, and will take a lot of effort to maintain.  This is not its story.


This is the story of my first site, where I threw the SPA (Single Page Application) into ASP.Net Core's wwwroot directory, and slung it up to Azure App Services as a single site, and called it a day.  Kind of mostly.

Anyway, it didn't need micro-services, massive scalability, self-healing, and all that mumbo jumbo.  It just needed to be simple, quick to build, and easy to maintain.

If deploying a SPA in an ASP.Net Core site is in your future, you're in a hurry, and quick and easy sounds good: this article is for you.  I'll go over it in five steps:

  1. Create Database
  2. Deploy Database
  3. Create App Service
  4. Compile and Deploy Host
  5. Compile and Deploy Angular

If you're already familiar with deploying Azure Resources via the Azure CLI skip to step 5.  If you'd like to watch this as a video check out E31 of Code Hour.


Otherwise, here's the how to create and deploy starting from zero, optimizing for simplicity:

1. Create Database

Obviously we need a database.  Using the Azure Portal is wonderful, but let's say we want repeatability.  Perhaps for other environments (e.g. test/prod), or for subsequent projects.  So let's use a script.

But how to avoid the problem of running it twice and creating duplicate resources?  Parameters, right?  And if statements to ensure the resource doesn't already exist.

But what if we want to make a resource change, e.g. to the database SKU, and deploy it through to multiple environments?  This is sounding complicated, and it's what products like Terraform are made for.  With Terraform we can describe the desired state using configuration as code and the tool looks at actual state and makes it happen, like magic.

Terraform sounds awesome (it is), but let's avoid 3rd party dependencies and not worry about idempotence at all for now.  I'm also sorely tempted to use Cake and the world famous (not really) Cake.Azure CLI Plugin, but we're in a hurry, let's avoid all nonessential 3rd party dependencies 😢.

# todo: Use Terraform
# todo: Convert this all to Cake for dependency management

$resourceGroupName = "LeesStoreQuickDeploy"
$location = "eastus"

# login to Azure interactively, fyi this won't work in scripts
az login

# create a resource group to hold everything
az group create -l $location -n $resourceGroupName

# create a sql server
$sqlUsername = "[username]"
$sqlPassword = "[password]"
$sqlName = "leesstorequickdeploy"
az sql server create -g $resourceGroupName -n $sqlName `
    -u $sqlUsername -p $sqlPassword -l $location

# create a sql database
$sqlDbName = "LeesStoreQuickDeploy"
az sql db create -g $resourceGroupName -s $sqlName -n $sqlDbName `
    --compute-model "Serverless" -e "GeneralPurpose" -f "Gen5" `
    -c 1 --max-size "1GB"


2. Deploy Database

Obviously we need to get the database tables created and insert some data.  That's exactly what the Entity Framework Migrations are for.  

It'd be tempting to update appsetting.json with the connection string info from step 1 and run dotnet ef database update or Update-Database just like we did locally.  However, that approach won't support the multi-tenancy multi-database scenarios granted by ASP.Net Boilerplate (see also Multi-Tenancy is Hard: ASP.Net Boilerplate Makes it Easy).  Furthermore, if we ever do a multi-stage Azure DevOps pipeline, we'd want to publish a single asset that we could download and run in each stage for each environment.

Fortunately, this is exactly why ASP.Net Boilerplate provides the "Migrator" command line application.  We just need to compile and run it.

# note: convert connection string to a parameter, or at the very least
#     don't check it in to source control (the way I did ;) )
$connectionString = "Server=leesstorequickdeploy.database.windows.net;Initial Catalog=LeesStoreQuickDeploy;UID=lee;Password=[pwd];"

# clean
Remove-Item ".\dist\Migrator\*.*"

# publish migrator (single file so we can publish it as an asset)
dotnet publish -c Release -o ".\dist\Migrator" `
    -r "win-x64" /p:PublishSingleFile=true `
    .\aspnet-core\src\LeesStore.Migrator
Copy-Item ".\aspnet-core\src\LeesStore.Migrator\log4net.config" `
    ".\dist\Migrator"

# run migrator with customized connection string
Push-Location
Set-Location ".\dist\Migrator"
$env:ConnectionStrings__Default = $connectionString
.\LeesStore.Migrator.exe -q
Pop-Location


3. Create App Service

✔ Databases & Migrations
❔ Webs of Azure

There are many options to deploy a website, even narrowing the universe to Azure.  They all have pro's and con's.  Virtual Machines (IaaS) are expensive and require maintenance.  Kubernetes is complicated.  Azure's Web App for Containers Instances is pretty awesome, and ASP.Net Boilerplate is already containerized.  But then we'd need to maintain the container image and it adds complexity.  

App Services are simple and fast, and good enough for most scenarios:

$appServicePlan = "LeesStoreServicePlan"
$webAppName = "leesstore2"
az appservice plan create -n $appServicePlan -g $resourceGroupName --sku Free -l $location
az webapp create -n $webAppName -g $resourceGroupName -p $appServicePlan


4. Compile and Deploy Host

A website without code is like a taco without fillings: an abomination.


To fix it first we have to set the connection string.  The rest should be relatively easy, just dotnet publish, compress the results, and upload them with an az command:

# set connection string
az webapp config appsettings set -n $webappname -g $resourceGroupName --settings ConnectionStrings__Default=$connectionString

# compile
dotnet publish -c Release -o ".\dist\Host" .\aspnet-core\src\LeesStore.Web.Host

# zip
Compress-Archive -PassThru -Path ".\dist\Host\*" -DestinationPath ".\dist\Host.zip"

# upload
az webapp deployment source config-zip -n $webappname -g $resourceGroupName --src ".\dist\Host.zip"

Maybe not so easy.  If we run that script the chances are good we'll get some error about an IP restriction:

SqlException: Cannot open server requested by login. Client with IP Address is now allowed to access the server. To enable access use the Windows Azure Management Portal or run sp_set_firewall_rule on the master database to create a firewall rule for this IP address or address range.

Either add the IP to the SQL Firewall, or allow all Azure resources to access it like:

# add firewall rule for access from Azure services
az sql server firewall-rule create -g $resourceGroupName -s $sqlName `
    -n Azure --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0

Problem solved.  Of course, behind every good error there's always another error:

Failed to fetch swagger.json. Possible mixed-content issue? The page was loaded over https:// but a http:// URL was specified. Check that you are not attempting to load mixed content.

This sneaky problem is most likely that ASP.Net Boilerplate depends on knowing the host URL, the Angular URL, and any other possible URL's for CORS.  Because we're going to publish Angular at the same URL we can set all three to the same URL, and per the error above they're currently set to http://localhost:[someport].  At some point we might add a custom URL and custom SSL cert, but for now we can just override them with the azurewebsites.net setting:

# set url's
$url = "https://leesstore2.azurewebsites.net/"
az webapp config appsettings set -n $webappname -g $resourceGroupName `
    --settings `
    App__ServerRootAddress=$url `
    App__ClientRootAddress=$url `
    App__CorsOrigins=$url

And with any luck Swagger! 😁

Swagger UI

5. Compile and Deploy Angular

That's great.  But however much devs might like that UI, users won't exactly be blown away.  We need the Angular site.

Step one is to compile the SPA.  With Angular that's as easy as ng build --prod.  Throwing on an --aot, however, will catch more compiler errors, that's good.  Then let's send the resulting site to a temporary location and move it to the Hosts's /wwwroot folder.

# Compiler angular
Push-Location
Set-Location ".\angular"
ng build --prod --aot --output-path "../dist/ng"
Pop-Location
Move-Item "dist/ng" "aspnet-core/src/LeesStore.Web.Host/wwwroot"

If we run the Host site now, it'll continue to throw up the Swagger site.  That's because the call to app.UseStaticFiles() in Startup.cs excludes default files, like Angular's oh so important index.html.

We could either add app.UseDefaultFiles(new DefaultFilesOptions()); or replace the app.UseStaticFiles() call with app.UseFileServer() and now if we run it locally (remembering to update the App section of appsetting.json) then Angular works at our Host port of 21021!

LeesStore Login Page

Not bad, but there's still two problems.

Problem 1 - appconfig.json

If we rerun the deploy script now things fail on the server:

GetAll net::ERR_CONNECTION_REFUSED

See the issue?  The immediate error and the underlying problem are both in the screenshot.

The immediate error (in the Console) is that the site made a request to http://localhost:21021/.../GetAll.  The underlying problem (in the Network tab's Response window) is that when it made a call to appconfig.production.json, both the remoteServiceBaseUrl and the appBaseUrl were wrong.  

We could fix those problems in a variety of ways, but I especially like adding ASP.Net Core middleware to catch that request and dynamically generate the values based on what's in the appconfig.json (or in environment variables).  Check this out:

public class Startup
{
    ...
    public void Configure(...)
    {
        ...
        app.UseCors(_defaultCorsPolicyName);
        
        app.UseDynamicAppConfig(_appConfiguration);
        
        //app.UseSpa();
        
        app.UseFileServer();
        ...
    }
}

Ha, a setup, didn't see that coming did you?  We just used an undefined UseDynamicAppConfig() extension method in Startup.cs/Configure().  Again, this time for real:

public static class DynamicAppConfigMiddlewareBuilder
{
    public static IApplicationBuilder UseDynamicAppConfig(this IApplicationBuilder builder, IConfigurationRoot appConfiguration)
    {
        var serverRootAddress = appConfiguration["App:ServerRootAddress"];
        var clientRootAddress = appConfiguration["App:ClientRootAddress"];
        return builder.UseMiddleware<DynamicAppConfigMiddleware>(serverRootAddress, clientRootAddress);
    }
}

public class DynamicAppConfigMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _serverRootAddress;
    private readonly string _clientRootAddress;

    public DynamicAppConfigMiddleware(RequestDelegate next, string serverRootAddress, string clientRootAddress)
    {
        _next = next;
        _serverRootAddress = serverRootAddress;
        _clientRootAddress = clientRootAddress;
    }

    public async Task Invoke(HttpContext context)
    {
        const string appConfigPath = "/assets/appconfig.production.json";
        var isRequestingAppConfig = appConfigPath.Equals(context.Request.Path.Value, StringComparison.CurrentCultureIgnoreCase);
        if (isRequestingAppConfig)
        {
            string response = GenerateResponse();
            await context.Response.WriteAsync(response);
        }
        else
        {
            await _next.Invoke(context);
        }
    }

    private string GenerateResponse()
    {
        var cleanServerRootAddress = _serverRootAddress.TrimEnd('/');
        var cleanClientRootAddress = _clientRootAddress.TrimEnd('/');
        return $@"{{
""remoteServiceBaseUrl"": ""{cleanServerRootAddress}"",
""appBaseUrl"": ""{cleanClientRootAddress}""
}}";
    }
}

That looks complicated, but it just intercepts any requests to /assets/appconfig.production.json and writes out a string json in GenerateResponse() that includes values from the appConfiguration.

I love the idea of dynamically generating that config file rather than duplicating values, it feels very clean.  And it even works!

Rerun the deploy script, and holy cow!

Home page for uncustomized ASP.Net Boilerplate Site

We can log in and access the database!

And you know that's exciting because I just ended three paragraphs with exclamation points! (four?  ugh, stupid off by one errors)

Problem 2: Angular Server-Side Routing

But behind any apparent success there is at least ... one minor problem.  In this case if we refresh the page on any URL other than the root we get a 404.  This is a server-side routing problem.  We need any requests to /app/[anything] to get rerouted to /index.html.  Fortunately that's easy to fix with a little more middleware magic.  Check out the app.UseSpa(); call on line 11 of Startup.cs above.  If we uncomment that and add the following middleware:

public static class SpaMiddlewareBuilder
{
    public static void UseSpa(this IApplicationBuilder app)
    {
        app.Use(async (context, next) =>
        {
            await next();
            if (context.Response.StatusCode == 404 &&
                context.Request.Path.StartsWithSegments("/app") &&
                !Path.HasExtension(context.Request.Path.Value))
            {
                context.Request.Path = "/index.html";
                await next();
            }
        });
    }
}

Now we can refresh on any page in Angular and it all just works.

🎉 Done!

We're done!!  This warrants even more exclamation points!!!

SPA's add deployment complexity, but that deployment was pretty quick and easy.  If you're interested in the source code check out Pull Request 22 on LeesStore on GitHub.  Now we just need to rewrite it all in Cake, use Terraform, and automate those scripts to run on the server.  Tomorrow perhaps.

Was this interesting?  I'd be happy to dig into more complex ABP deployment scenarios, just let me know in the comments or on twitter.

2 comments:

Azure DevOps said...
This comment has been removed by a blog administrator.
Maria rima said...
This comment has been removed by a blog administrator.