Packaging a .Net Windows Service Application

This post will demonstrate how to package a .Net Windows service application using Habitat. A Windows service application provides some interesting challenges to Habitat packaging because the application process is ultimately controlled by the Windows Service Control Manager (SCM). It runs outside of the Habitat Supervisor process tree. It also requires some initial setup when the Supervisor starts the application for the first time since the SCM service entry will not be present. These are similar to the challenges of packaging an IIS web application as described in this previous post.

Our Fancy Windows Service

To illustrate how one can package and run Windows services with Habitat, we will use a VERY simple Windows service. You can find the full source code and an accompanying Habitat plan here.

I created the application in Visual Studio 2017 Community Edition starting with the code generated by File/New/Project and selecting a “Windows Servce (.NET Framework)” application. I changed the *.cs files with the service implementation described below and added a habitat folder to include my plan.ps1, hooks, and configuration. However, I made no changes to the .csproj or other project files. This is very much a “vanilla” C# Visual Studio Windows service.

This service simply logs the state of the service to a file. It logs startup and shutdown messages as well as a message once a minute stating that the application is up. Not a very useful service but fine for illustrating how a Windows service looks inside of Habitat.

The Plan

windows-service-sample/habitat/plan.ps1 link

$pkg_name="windows-service-sample"
$pkg_origin="mwrock"
$pkg_version="0.1.0"
$pkg_maintainer="Matt Wrock"
$pkg_license=@('MIT')
$pkg_description="A sample .NET Windows Service"
$pkg_bin_dirs=@("bin")
function Invoke-Build {
  Copy-Item $PLAN_CONTEXT/../* $HAB_CACHE_SRC_PATH/$pkg_dirname -recurse -force -Exclude ".vs"
  ."$env:SystemRoot\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe" $HAB_CACHE_SRC_PATH/$pkg_dirname/${pkg_name}.csproj /t:Build /p:Configuration=Release
  if($LASTEXITCODE -ne 0) {
      Write-Error "dotnet build failed!"
  }
}
function Invoke-Install {
  Copy-Item $HAB_CACHE_SRC_PATH/$pkg_dirname/bin/release/* $pkg_prefix/bin
}

Because our service is so simple and has no dependencies, there is not much going on here. Of course our application depends on the standard .Net framework but because that is a core operating system framework, we do not bother isolating it with Habitat. Thats fine because any modern Windows OS from 2008 R2 and up should have the .Net 4 CLR installed.

In short, this plan copies the source code to the Habitat cache, performs an MSBUILD release build and then copies the binaries to our Habitat package staging folder.

Creating the Windows Service from the Supervisor

When a Habitat Supervisor first installs our application, it likely will not have an actual service installed in the machine’s SCM. Our init hook will need to check if one is installed and install it if one is not present:

windows-service-sample/habitat/hooks/init link

Set-Location {{pkg.svc_path}}
if(Test-Path bin) { Remove-Item bin -Recurse -Force }
New-Item -Name bin -ItemType Junction -target "{{pkg.path}}/bin" | Out-Null
# Add the Windows Service
if((Get-Service HabSampleService -ErrorAction SilentlyContinue) -eq $null) {
    $binPath = (Resolve-Path "{{pkg.svc_path}}/bin/{{pkg.name}}.exe").Path
    &$env:systemroot\system32\sc.exe create HabSampleService binpath= $binPath
}

This simply uses the sc.exe utility to install the service. Note that we want to keep the default manual startup setting and do not want the service to start automatically when Windows boots. This is because we want the Habitat Supervisor to control when the service starts and stops.

Starting the service in a Run Hook

Our run hook will start the service and loop continuously until the service is stopped.

windows-service-sample/habitat/hooks/run link

Copy-Item "{{pkg.svc_config_path}}\windows-service-sample.exe.config" "{{pkg.svc_path}}\bin" -Force
Start-Service HabSampleService
Write-Host "{{pkg.name}} is running"
while($(Get-Service HabSampleService).Status -eq "Running") {
    Start-Sleep -Seconds 1
}

First we copy our templatized configuration file that ensures that status messages will be logged to the pkg.svc_data_path. Next we simply call Start-Service to start our service. We will keep the process running as long as the service is in the running state. So if one were to manually stop the service from the SCM, the Habitat service would also terminate and attempt to restart it.

Cleaning Up the Service

Now we will take advantage of a new Habitat feature: the post-stop hook. This hook runs after a Habitat service is stopped. Here we make sure that the actual Windows service stops when the Habitat service stops:

windows-service-sample/habitat/hooks/post-stop link

if($(Get-Service HabSampleService).Status -ne "Stopped") {
    Write-Host "{{pkg.name}} stopping..."
    Stop-Service HabSampleService
    Write-Host "{{pkg.name}} has stopped"
}

We especially need this for upgrade scenarios when a Supervisor updates a running service with an updated version of our service application. An update would fail if the service was running.

Building and Testing our .Net Service Application

Now lets see if this all works. Lets clone the repository and run hab studio enter from its root to start a Windows Habitat Studio. I’m on Windows 10 running Docker Community Edition for Windows so this command will spin up a Windows Server 2016 Core container:

C:\windows-service-sample

C:\windows-service-sample> hab studio enter
   hab-studio: Creating Studio at c:/
   hab-studio: Entering Studio at c:/
** 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.
[HAB-STUDIO] Habitat:\src>

This is especially nice for testing Windows services because I don’t have to worry about random services being installed into my personal SCM. You could alternatively run hab studio enter -w to start a local Windows Studio (not containerized).

Now we will run build . to build our Windows service package. We can see MSBUILD starting:

Building the package

[HAB-STUDIO] Habitat:\src> build .
   : Loading C:\src\habitat\plan.ps1
   windows-service-sample: Plan loaded
   windows-service-sample: Validating plan metadata
   windows-service-sample: hab-plan-build.ps1 setup
   windows-service-sample: Using HAB_BIN=C:\hab\pkgs\core\hab-studio\0.51.0\20171218132802\bin\hab\hab.exe for installs, signing, and hashing
   windows-service-sample: Resolving scaffolding dependencies
   windows-service-sample: Setting PATH=C:\hab\pkgs\mwrock\windows-service-sample\0.1.0\20171220175451/bin;;C:\hab\pkgs\core\hab-studio\0.51.0\20171218132802\bin\hab;C:\hab\pkgs\core\hab-studio\0.51.0\20171218132802\bin\7zip;C:\hab\pkgs\core\hab-studio\0.51.0\20171218132802\bin;C:\Windows\system32;C:\Windows
   windows-service-sample: Setting LIB=
   windows-service-sample: Setting INCLUDE=
   windows-service-sample: Clean the cache
   windows-service-sample: Preparing to build
   windows-service-sample: Building
Microsoft (R) Build Engine version 4.6.1586.0
[Microsoft .NET Framework, version 4.0.30319.42000]
Copyright (C) Microsoft Corporation. All rights reserved.
Build started 12/20/2017 5:54:53 PM.
Project "C:\hab\cache\src\windows-service-sample-0.1.0\windows-service-sample.csproj" on node 1 (Build target(s)).
...

Ok, lets start the service with the Studio’s Supervisor:

Starting the studio’s supervisor

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

We should see an actual Windows service running in the scm:

Check for the service

[HAB-STUDIO] Habitat:\src> Get-Service HabSampleService
Status   Name               DisplayName
------   ----               -----------
Running  HabSampleService   HabSampleService

We should also see the fabulous status messages in the service’s data file:

View the status message

[HAB-STUDIO] Habitat:\src> cat C:/hab/svc/windows-service-sample/data/status.txt
HabSampleService is starting
HabSampleService is started
HabSampleService is up
HabSampleService is up

Now we will stop the service:

Stopping the service

[HAB-STUDIO] Habitat:\src> hab svc stop mwrock/windows-service-sample
[HAB-STUDIO] Habitat:\src> Get-Service HabSampleService
Status   Name               DisplayName
------   ----               -----------
Stopped  HabSampleService   HabSampleService
[HAB-STUDIO] Habitat:\src> cat C:/hab/svc/windows-service-sample/data/status.txt
HabSampleService is starting
HabSampleService is started
HabSampleService is up
HabSampleService is up
HabSampleService is stopped

As you can see, the service is stopped and it logs that status to its data file.

Avatar
Matt Wrock

I am a software developer for Chef and much of my focus has been making Chef better on Windows. When not developing Chef code, I'm usually contributing to other projects in the Chef ecosystem. I regularly contribute to the WinRM gem and Vagrant, I am a member of the core Chocolatey team, author of Boxstarter and was an early contributor to Pester creating its Powershell Mocking functionality. I am a former Microsoft engineer and write regularly on Windows automation topics at hurryupandwait.io