Running a Full Framework ASP.NET IIS application in Habitat

  • person iconMatt Wrock
  • calendar iconWednesday, August 9 2017

ASP.NET-on-Habitat

For the past several months we have been working hard to provide full feature parity accross all Habitat components on Windows. We often use an ASP.NET Core plan to test Windows functionality because building and running a .NET Core application is very similar to developing a Node application not to mention many other runtimes that others use Habitat for building and running their applications. The .NET Core runtime is extremely portable and easy to isolate within a Habitat environment. It does not require machine scoped configuration like its "full framework" cousin. I'm personally excited about .NET Core and really hope others come to adopt it.

However, today, the world's .NET applications run on the .NET full framework and ASP.NET applications run inside IIS and not the lightweight Kestrel web server. How do we build and run these inside of Habitat? How would we create packages for the .NET full framework or IIS like we do for Node or nginx? These are truly challenging scenarios because the .NET full framework needs to reside in a specific location on disk and IIS is a feature of the Windows operating system.

This post will walk you through how to work around these challenges and create a plan that builds and runs an ASP.NET full framework application inside of an IIS application pool.

You can find a complete Github repo of this application along with its Habitat plan, hooks and config here.

.NET Full Framework and IIS as Infrastructure

First and foremost, we will not be creating Habitat packages for the .NET framework or IIS. We are going to let our configuration management or provisioning system of choice ensure that the .NET framework is installed and that the appropriate IIS features are enabled. When we author our Habitat plan, we will assume that the .NET framework and IIS pieces are already in place. Our plan will focus on our individual .NET application. It will build the application on any machine where the .NET 4.0 CLR is installed, it will setup the IIS application pool and web site, and deploy our app binaries and web artifacts to our nodes where the Habitat Supervisor will ensure it is running and update it as updates are available.

So lets get started!

An ASP.NET application

The first thing we need is an actual ASP.NET application. You may already have one and if so, that's great. Because I don't right now (but I did spend 10 years of my career building them), I'm gonna cheat. I'll open Visual Studio 2017 and select file/new/project and then create the stock ASP.NET MVC application template. Earlier Visual Studio like 2015 should work too.

file-new-project

Finally, and mainly to illustrate how to adjust our Habitat plan to target different .NET framework versions, we will target version 4.7.

target-framework

Building with a plan.ps1

Right in Visual Studio I'll add a habitat folder to my project and then right click on that folder and select Add/New Item. Because I have the PowerShell Tools Visual Studio Extension, I have the option to add a PowerShell script and I will add a plan.ps1 file.

plan-PS1

Now I'll add the codes for building this project:

C:\dev\hab-sln\habitat\plan.ps1
$pkg_name="hab-sln"
$pkg_origin="mwrock"
$pkg_version="0.1.0"
$pkg_maintainer="Matt Wrock"
$pkg_license=@('MIT')
$pkg_description="A sample ASP.NET Full FX IIS app"
$pkg_deps=@("core/dsc-core")
$pkg_build_deps=@(
"core/nuget",
"core/dotnet-47-dev-pack",
"core/visual-build-tools-2017"
)
function invoke-download { }
function invoke-verify { }
function Invoke-Build {
Copy-Item $PLAN_CONTEXT/../../* $HAB_CACHE_SRC_PATH/$pkg_dirname -recurse -force
nuget restore $HAB_CACHE_SRC_PATH/$pkg_dirname/$pkg_name/packages.config -PackagesDirectory $HAB_CACHE_SRC_PATH/$pkg_dirname/packages -Source "https://www.nuget.org/api/v2"
nuget install MSBuild.Microsoft.VisualStudio.Web.targets -Version 14.0.0.3 -OutputDirectory $HAB_CACHE_SRC_PATH/$pkg_dirname/
$env:TargetFrameworkRootPath="$(Get-HabPackagePath dotnet-47-dev-pack)\Program Files\Reference Assemblies\Microsoft\Framework"
$env:VSToolsPath = "$HAB_CACHE_SRC_PATH/$pkg_dirname/MSBuild.Microsoft.VisualStudio.Web.targets.14.0.0.3/tools/VSToolsPath"
MSBuild $HAB_CACHE_SRC_PATH/$pkg_dirname/$pkg_name/${pkg_name}.csproj /t:Build
if($LASTEXITCODE -ne 0) {
Write-Error "dotnet build failed!"
}
}
function Invoke-Install {
MSBuild $HAB_CACHE_SRC_PATH/$pkg_dirname/$pkg_name/${pkg_name}.csproj /t:WebPublish /p:WebPublishMethod=FileSystem /p:publishUrl=$pkg_prefix/www
}

This bit of PowerShell script essentially leverages nuget and msbuild to grab dependencies, compile our code and publish it to our Habitat pkgs directory which will be archived to a Habitat hart package. It should also be noted that in order for Habitat to build this plan, one does NOT need to have Visual Studio installed. Rather you just need a recent framework version of .NET installed which ships by default on Windows Server 2012R2 and forward.

First, enter a Windows Habitat Studio:

C:\dev\hab-sln
$ hab studio enter -w
hab-studio: Creating Studio at /hab/studios/dev--hab-sln
» Importing origin key from standard input
★ Imported secret origin key core-20170318210306.
hab-studio: Entering Studio at /hab/studios/dev--hab-sln
** The Habitat Supervisor has been started in the background.
** Use 'hab svc start' and 'hab svc stop' to start and stop services.
** Use the 'Get-SupervisorLog' command to stream the Supervisor log.
** Use the 'Stop-Supervisor' to terminate the Supervisor.

Within the studio, lets build and package our hart for this application:

[HAB-STUDIO] Habitat:\src> build .\hab-sln</span>
: Loading C:\hab\studios\dev--hab-sln\src\hab-sln\habitat\plan.ps1
hab-sln: Plan loaded
hab-sln: Validating plan metadata
...

This should compile and package our hab-sln project. Lets drill in a bit closer on a few of the moving parts of the plan.

The Nuget Command Line Utility

Note that our plan takes a build dependency on core/nuget. That is a core Habitat plan that packages the nuget.exe utility. Our build will use that to do a "package restore" of all packages declared in our project's packages.config file. Visual Studio would essentially do the same if we were building inside the IDE.

Using the Right .NET Reference Assemblies

When you build a .NET project, msbuild searches for reference assemblies matching the framework version you are targeting. You can build a .NET assembly against any framework version compatible with the CLR version you are using regardless of the version of the .NET framework you have installed. You just need the right reference assemblies. We use the core/dotnet-47-dev-pack because we are targeting 4.7 and set the TargetFrameworkRootPath to its contents. This may be optional since if the reference assemblies are not present, the build will use the assemblies in your Global Assembly Cache. However, the build could fail if there are APIs used that are not in your locally installed assemblies so it is good practice to reference the appropriate targeting pack.

Getting the VisualStudio.Web.targets

Most of the MSBUILD targets, .prop files, and the msbuild.exe needed to build .NET assemblies can be found in your local c:\windows\Microsoft.NET folder. However, the web targets are not. The easiest way to get those without installing Visual Studio is to use nuget to install the MSBuild.Microsoft.VisualStudio.Web.targets package and then point VSToolsPath to its location.

Using Compatible MSBuild Tooling

Because we are targeting the v4.7 framework, our project will take a dependency on the v4.7 Microsoft.Net.Compilers Nuget package. This is going to require us to use the Visual Studio 2017 MSBuild tooling in our Invoke-Build. So we declare a build time dependency on core/visual-build-tools-2017 which places its version of msbuild on the path.

Configuring IIS Pools, Sites and Applications

As mentioned earlier, we won't rely on Habitat for the installation of .NET or the right IIS features. However, it makes sense that Habitat should be able to configure IIS so that the application can run and behave as expected. We will leverage our run hook to do this.

There are two approaches we can take in our hook. One is a bit dated and clunky, but works everywhere. The other is more elegant, but limited to machines where WMF 5 (or higher) is installed.

appcmd.exe

This is the old-fashioned appcmd.exe of yore. For our purposes in our little sample app, it can get the job done:

."$env:SystemRoot\System32\inetsrv\appcmd.exe" list apppool "{{cfg.app_pool}}" | Out-Null
if($LASTEXITCODE -ne 0) {
Write-Host "Creating App Pool {{pkg.app_pool}}" -ForegroundColor Yellow
."$env:SystemRoot\System32\inetsrv\appcmd.exe" add apppool /name:"{{cfg.app_pool}}"
."$env:SystemRoot\System32\inetsrv\appcmd.exe" set apppool /apppool.name:"{{cfg.app_pool}}" /managedRuntimeVersion:v4.0
}
."$env:SystemRoot\System32\inetsrv\appcmd.exe" list site "{{cfg.site_name}}" | Out-Null
if($LASTEXITCODE -ne 0) {
Write-Host "Creating Web Site {{cfg.site_name}}" -ForegroundColor Yellow
."$env:SystemRoot\System32\inetsrv\appcmd.exe" add SITE /name:"{{cfg.site_name}}" /PhysicalPath:"$((Resolve-Path '{{pkg.svc_path}}').Path)" /bindings:http://*:{{cfg.port}}
Write-Host "Binding Web Site {{cfg.site_name}} to pool {{cfg.app_pool}}" -ForegroundColor Yellow
."$env:SystemRoot\System32\inetsrv\appcmd.exe" set site /site.name:"{{cfg.site_name}}" "/[path='/'].applicationPool:{{cfg.app_pool}}"
}
."$env:SystemRoot\System32\inetsrv\appcmd.exe" list app /site.name:"{{cfg.site_name}}" | Findstr "{{cfg.app_name}}" | Out-Null
if($LASTEXITCODE -ne 0) {
Write-Host "Creating Application {{cfg.app_name}} at path /{{pkg.name}} with physical path '$((Resolve-Path "<span class="k">{{</span><span class="nv">pkg</span><span class="p">.</span><span class="nv">svc_var_path</span><span class="k">}}</span>").Path)'"
."$env:SystemRoot\System32\inetsrv\appcmd.exe" add app /site.name:"{{cfg.site_name}}" /path:"/{{cfg.app_name}}" /PhysicalPath:"$((Resolve-Path '{{pkg.svc_var_path}}').Path)" /applicationPool:"{{cfg.app_pool}}"
}
."$env:SystemRoot\System32\inetsrv\appcmd.exe" start apppool "{{cfg.app_pool}}"
."$env:SystemRoot\System32\inetsrv\appcmd.exe" start site "{{cfg.site_name}}"

This uses values in our configuration to create the app pools, site and application folders needed to run our site.

DSC

If you are familiar with DSC (particularly the resources in xWebAdministration), then using Habitat to configure IIS should come naturally. We will create a config folder in our habitat directory and a website.ps1 inside of that. Here you might have the following configuration:

C:\dev\hab-sln\habitat\config\website.ps1
Configuration NewWebsite
{
Import-DscResource -Module xWebAdministration
Node 'localhost' {
xWebAppPool {{cfg.app_pool}}
{
Name = "{{cfg.app_pool}}"
Ensure = "Present"
State = "Started"
}
xWebsite {{cfg.site_name}}
{
Ensure = "Present"
Name = "{{cfg.site_name}}"
State = "Started"
PhysicalPath = Resolve-Path "{{pkg.svc_path}}"
ApplicationPool = "{{cfg.app_pool}}"
BindingInfo = @(
MSFT_xWebBindingInformation
{
Protocol = "http"
Port = {{cfg.port}}
}
)
}
xWebApplication {{cfg.app_name}}
{
Name = "{{cfg.app_name}}"
Website = "{{cfg.site_name}}"
WebAppPool = "{{cfg.app_pool}}"
PhysicalPath = Resolve-Path "{{pkg.svc_var_path}}"
Ensure = "Present"
}
}
}

This uses Habitat's templating capabilities to ensure that IIS points to where our application is stored in the Habitat svc folder and uses the configured port.

Our run hook will apply this configuration whenever we start our ASP.NET application and make sure everything is configured according to our DSC or appcmd.exe calls.

Running our Application in the Habitat Supervisor

In full framewoerk apps, IIS is often in charge of the process lifecycle of our app, unlike Node or .NET Core where we would simply invoke node.exe or dotnet.exe to run our app in a lightweight web server. So instead of handing off our run hook to call into a runtime, we just need to make sure IIS is configured and the site and app pool are both in the "running" state. We already covered IIS configuration above. However if the run hook terminates, the Supervisor assumes our service has terminated. As a result, we will have PowerShell loop and check that our application endpoint is responsive:

C:\dev\hab-sln\habitat\hooks\run
try {
Write-Host "{{pkg.name}} is running"
$running = $true
while($running) {
Start-Sleep -Seconds 1
$resp = Invoke-WebRequest "http://localhost:{{cfg.port}}/{{cfg.app_name}}" -Method Head
if($resp.StatusCode -ne 200) { $running = $false }
}
}
catch {
Write-Host "{{pkg.name}} HEAD check failed"
}
finally {
# Add any cleanup here which will run after Supervisor stops the service
Write-Host "{{pkg.name}} is stoping..."
."$env:SystemRoot\System32\inetsrv\appcmd.exe" stop apppool "{{cfg.app_pool}}"
."$env:SystemRoot\System32\inetsrv\appcmd.exe" stop site "{{cfg.site_name}}"
Write-Host "{{pkg.name}} has stopped"
}

As you can see, we simply call Invoke-Webrequest to send a HEAD request to our app. If the response is not a 200, then we assume things have gone horribly wrong and exit the loop. Furthermore, if the Supervisor stops our service (hab svc stop mwrock/hab-sln), execution will move to the finally block, where we tell IIS to shut down our application.

So let's run our application. If you are not already in a Windows Studio, enter hab studio enter -w from the root of our solution.

C:\dev\habitat-aspnet-full
$ hab studio enter -w

Assuming that the plan has been built as shown above, We can stream the Supervisor log and start our service:

[HAB-STUDIO] Habitat:\src> Get-SupervisorLog
[HAB-STUDIO] Habitat:\src> hab svc load mwrock/hab-sln
hab-sup(MN): Supervisor starting mwrock/hab-sln. See the Supervisor output for more details.

Our Supervisor log should look something like this:

hab-sup(MR): Supervisor Member-ID 883fe61fe11b4e64b20245adc9830bed
hab-sup(MR): Starting gossip-listener on 0.0.0.0:9638
hab-sup(MR): Starting http-gateway on 0.0.0.0:9631
hab-sup(MR): Starting mwrock/hab-sln
hab-sln.default(SR): Hooks recompiled
default(CF): Updated website.ps1 945a1ab52bb3ad41211cd77625dd870244be1a81b822e7db171795c4f22ade86
hab-sln.default(SR): Configuration recompiled
hab-sln.default(SR): Initializing
hab-sln.default(SV): Starting service as user=matt, group=
hab-sln.default(O): Ensuring xWebAdministration DSC resources are installed...
hab-sln.default(O): Compiling DSC mof...
hab-sln.default(O): WARNING: The names of some imported commands from the module
hab-sln.default(O): 'PSDesiredStateConfiguration' include unapproved verbs that might make them less
hab-sln.default(O): discoverable. To find the commands with unapproved verbs, run the Import-Module
hab-sln.default(O): command again with the Verbose parameter. For a list of approved verbs, type
hab-sln.default(O): Get-Verb.
hab-sln.default(O): Applying DSC configuration...
hab-sln.default(O):
hab-sln.default(O): hab-sln is running

and browsing to http://localhost:8099/hab_app should bring up our ASP.NET web site.

Now let's go crazy and change the web site port:

[HAB-STUDIO] Habitat:\src> 'port = 8098' | hab config apply hab-sln.default 1
» Applying configuration for hab-sln.default incarnation 1
Ω Creating service configuration
✓ Verified this configuration is valid TOML
↑ Applying to peer 127.0.0.1:9638
★ Applied configuration

and our Supervisor log reads:

hab-sln.default(SR): Hooks recompiled
default(CF): Updated website.ps1 9d47f71105cd9971a516c3be4567dec3779f156cfaa72910304f3a79aca056f4
hab-sln.default(SR): Configuration recompiled
hab-sln.default(O): hab-sln is stoping...
hab-sln.default(O): "hab_pool" successfully stopped
hab-sln.default(O): "hab_site" successfully stopped
hab-sln.default(O): hab-sln has stopped
hab-sln.default(O): Ensuring xWebAdministration DSC resources are installed...
hab-sln.default(O): Compiling DSC mof...
hab-sln.default(O): WARNING: The names of some imported commands from the module
hab-sln.default(O): 'PSDesiredStateConfiguration' include unapproved verbs that might make them less
hab-sln.default(O): discoverable. To find the commands with unapproved verbs, run the Import-Module
hab-sln.default(O): command again with the Verbose parameter. For a list of approved verbs, type
hab-sln.default(O): Get-Verb.
hab-sln.default(O): Applying DSC configuration...
hab-sln.default(O):
hab-sln.default(O): hab-sln is running

Our port change forces an application restart and now we can reach our application on http://localhost:8098/hab_app.

C:\dev\habitat-aspnet-full
C:\dev\habitat-aspnet-full [master]> Invoke-WebRequest http://localhost:8098/hab_app -Method Head
StatusCode : 200
StatusDescription : OK

Of course changing a port is just one small example of a multitude of possible configuration change scenarios where we can inject a configuration change into our Habitat ring and every service can respond.

What's Next?

We'd love to hear what you think! Do you have ASP.NET applications that you'd like to build and run with Habitat? Do you think this pattern would work for your applications and tour team's work flows? Do you think it would need some tweaking. We'd love to hear! You can talk with us in the #windows Habitat slack channel.

Posted In

About the author


Matt Wrock

Matt Wrock is a senior engineer on the core Habitat team. Among other things, Matt has been focusing on bringing feature parity to the Habitat build components and the Supervisor on the Windows platform. Matt has 18 years experience in software development mostly in building high traffic distributed web applications in online advertising as well as various Microsoft.com web properties.

Recommended Posts

All articles