Let me start off by stating that I am in no way an expert on MSBuild, writing build scripts, MSDeploy, Web Deploy, etc. I learned a lot in this process about those things which helped me find a workable solution. I'm sure there are better ways to accomplish what I'm trying to do, but this is what I came up with. My goal in writing this is that someone will hopefully avoid wasting their time as I did trying to get app_offline.htm to work.
For those of you who don't know what app_offline.htm is, it's a file that you can place in the root of your .net web application which will route requests to that page when IIS sees it. The purpose of it is so that you have an easy way to take your site down for maintenance. See Scott Guthrie's post about it here
In talking with my boss the other day, we were discussing build and deploy automation. We've got automated deploys to dev, test and staging (using TFS 2010), but we do the last deploy to production manually--really for no great reason other than avoiding the interruption of service while deploying. To be clear, by manually I mean we were creating a deployment package for production from our build server and importing that package manually from IIS Manager on the production server. We wanted to automate this last step to production, but in order to do that, we needed to put up some kind of maintenance page; enter app_offline.htm.
First of all, I did manage to get it working (though it didn't meet our needs) -- I created a file called _app_offline.htm in my web application. I also created a file called BuildCustomizations.targets in my web application (more on this later) and I imported those targets into my project file. For those of you who don't know what I'm talking about, your web application project file (.csproj, .vbproj, etc) is just an MSBuild script. In order to extend the basic functionality of the build, you can import an external file with additional targets in it (or just drop the additional targets in your project file) that you want to execute when your project is built. Right click on your project file, select unload project, right click on the unloaded project and click edit. This will open up an XML view of your project where you can edit the file manually. Be very careful editing this file--if you put something in this file incorrectly, it won't load in visual studio. Either make a backup or use source control. Scroll down to the very end and you should see something like this:
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
<Import Project="$(MSBuildExtensionsPath32\Microsoft\VisualStudio\v10.0\WebApplications\Microsoft.WebApplication.targets" />
<!-- this is the line I added: -->
<Import Project="BuildCustomizations.targets" />
Those of you who know MSBuild will shake your head in disgust. There's a much simpler way to tie in your build customizations, so do it this way: Create your targets file and name it [ProjectName].wpp.targets. MSBuild looks for a file with that naming convention and will automatically pull in those build tasks. This is great for several reasons, but the biggest reason is that you don't have to manually edit your project file. Moving on...
So now, I've got my build customizations in a file called MyProject.wpp.targets, the contents of which look like this:
<Target Name="DeployMaintenancePage" AfterTargets="CopyAllFilesToSingleFolderForPackage" Condition="'$(Configuration)' == 'QA' Or '$(Configuration)' == 'Staging'">
<Message Text="Deploying maintenance page: $(MSBuildProjectDirectory)\$(_PackageTempDir)\_app_offline.htm" />
<Exec Command=""$(MSDeployPath)msdeploy.exe" -verb:sync -source:contentPath='$(MSBuildProjectDirectory)\$(_PackageTempDir)\_app_offline.htm' -dest:contentPath=$(DeployIisAppPath)/app_offline.htm,wmsvc=$(MSDeployServiceUrl),username=$(username),password=$(password) -allowUntrusted" />
<Delete Files="$(MSBuildProjectDirectory)\$(_PackageTempDir)\_app_offline.htm" />
<Target Name="RemoveMaintenancePage" AfterTargets="MSDeployPublish" Condition="'$(Configuration)' == 'QA' Or '$(Configuration)' == 'Staging'">
<Message Text="Removing maintenance page: $(DeployIisAppPath)/app_offline.htm" />
<Exec Command=""$(MSDeployPath)msdeploy.exe" -verb:delete -dest:contentPath=$(DeployIisAppPath)/app_offline.htm,wmsvc=$(MSDeployServiceUrl),username=$(username),password=$(password) -allowUntrusted" />
First of all, there are ways to using MSDeploy specific tasks, but there were a couple reasons I didn't use them. First, the delete command wasn't recognized--seems like it only works with sync. Sure I could try to extend it, but I'm too green on this subject / lazy to go that route. It also seemed like a more complicated solution and I didn't have time to figure it out. Anyways, back to it...
Let me walk through what is going on here. First, I've got two targets (a collection of tasks). They only run when the configuration parameter is QA or Staging. The first target runs by including the attribute AfterTargets="CopyAllFilesToSingleFolderForPackage" after the project has been compiled and all of the files have been moved to the package temp folder.
As a side comment, you can get an understanding of which targets are firing and when by comparing the output of your build to the targets file (Microsoft.CSharp.targets and Microsoft.Web.Publishing.targets). The log file will contain the target names followed by the output of the tasks and you can map that to the .targets file... that's how I found which targets I could associate with in my AfterTargets attributes.
The first task in the "DeployMaintenancePage" target just writes a message out to the logger that we're deploying the maintenance page. The next task (Exec Command="...") actually accomplishes the deployment of the file. MSDeploy can be a complicated beast, so let me explain what's happening with that command.
"$(MSDeployPath)msdeploy.exe" (this is the executable we're using)
-verb:sync (this means that we're taking a file from our local directory and putting in our destination)
-source:contentPath='$(MSBuildProjectDirectory)\$(_PackageTempDir)\_app_offline.htm' (this is the source file we want to push to our destination, _app_offline.htm).
-dest:contentPath=$(DeployIisAppPath)/app_offline.htm, (I'm using what's called in MSDeploy terms the "contentPath" provider. It can be used to publish from / to a file location, a web application, etc. The DeployIisAppPath is a parameter from my build with the name of the site in IIS.)
wmsvc=$(MSDeployServiceUrl), (using the web management service, I pull in one of the parameters from my build that specifies the deployment url for my production web server)
username=$(username), password=$(password) (specify the username / password I use for deployments)
-allowUntrusted (and finally, just tell it to ignore any SSL warnings)
After this task executes, I delete the _app_offline.htm file from my package temp directory so it doesn't get included in the deployment using this task:
<Delete Files="$(MSBuildProjectDirectory)\$(_PackageTempDir)\_app_offline.htm" />
In my second target, I'm just deleting the app_offline.htm file from the server after the deployment is complete by triggering it with AfterTargets="MSDeployPublish". It's very similar to the first target, so I won't go into the details of it.
Now my app_offline.htm file gets published and deleted before and after a deployment respectively. Great! I go to deploy my code to production and I get a combination of the maintenance page being displayed correctly and intermittent YSODs that indicate an assembly can't be loaded, presumably as it is being transferred. Super. At this point I'm contemplating switching to using NAnt or some other alternative, but I press on.
Instead of using the app_offline.htm file, I decided to create a second site on IIS that has my maintenance page and associated images and that I'll stop the production site and start the "offline" site when I deploy. Of course, I want to automate this as a part of the deployment. As I had been investigating MSDeploy before, I found that there is a preSync and postSync action that you can take. Unfortunately, there doesn't appear to be a way (that I found) where I could trigger those commands automatically during my TFS build / deploy. What I ended up doing was removing the deploy parameters from my build arguments and created a custom target in my MyProject.wpp.targets file that handles the deployment and uses the preSync and postSync commands. It looks like this:
<Target Name="DeployToEnvironment" AfterTargets="PackageUsingManifest" Condition="('$(Configuration)' == 'QA' Or '$(Configuration)' == 'Staging') And $(DeployToEnvironment) == true">
<Message Text="Deploying application to $(Configuration)." />
<Exec Command=""$(MSDeployPath)msdeploy.exe" -verb:sync -preSync:runCommand="c:\inetpub\$(Configuration)_start_deploy.bat",dontUseCommandExe=true -postSync:runCommand="c:\inetpub\$(Configuration)_stop_deploy.bat",dontUseCommandExe=true -source:package='$(PackageDestinationRoot)' -dest:auto,ComputerName='$(MSDeployServiceUrl)?site=$(DeployIisAppPath)',username=$(username),password=$(password),IncludeAcls='False',AuthType='Basic' -disableLink:AppPoolExtension -disableLink:ContentExtension -disableLink:CertificateExtension -allowUntrusted -setParam:'IIS Web Application Name'='$(DeployIisAppPath)'" />
Again, let me walk through what I'm doing here. This time, I've got one target called DeployToEnvironment and it runs after PackageUsingManifest (basically after the deployment package has been created). It runs when the configuration is either QA or Staging and I include a build argument to indicate that I want to actually deploy after the build is complete ($(DeployToEnvironment) == true).
In my MSDeploy task, I added the preSync and postSync commands and I'm using the runCommand provider. I've still got a bit of work to do with getting the runCommand stuff configured correctly from a permissions standpoint. It's a bit of a hassle, and you can read about it on Microsoft's reference page
. One of the things they fail to indicate in the reference is that if you're using the WMSVC for deployments, you will need to add an entry to delegate the rights to the runCommand provider within IIS. It's similar to the createApp or setAcls rights that you generally have to configure when setting up Web Deploy. In my commands, I'm running batch files on the remote server that start and stop the appropriate sites. The batch files basically contain this (preSync):
c:\windows\system32\inetsrv\appcmd stop site "online site"
c:\windows\system32\inetsrv\appcmd start site "offline site"
c:\windows\system32\inetsrv\appcmd stop site "offline site"
c:\windows\system32\inetsrv\appcmd start site "online site"
For my source, I'm specifying my deployment package zip file using the package provider and my destination uses the auto provider. The destination parameters (deployment url, site name, username, password, etc) are all specified either as arguments for the destination switch or as additional arguments (like "IIS Web Application Name"). Now when I go to do a deployment, the preSync command runs, stops / starts the appropriate sites, performs the sync to deploy the package to the destination and then does the stop / start on the appropriate sites again. Works like a charm. I will be updating the preSync batch files to perform some additional actions in staging like taking a backup of the production database and restoring it over the staging database before the package deployment.
In summary, I did manage the find a workable solution, but it's probably not the best. I found that there were surprisingly few details on how to do what I wanted to accomplish. You can find some really good examples of how to do some sweet stuff with MSBuild at Sayed Ibrahim Hashimi's site
, one of which is a more correct way of publishing an app_offline.htm file should you decide to go that route. Another good reference for MSBuild information is Vishal Joshi's site
. Check them both out--seem like some really smart folks! One last thing I should mention is that I had thought about using the MSBuild Extension Pack
to assist with doing some of the site starting and stopping in IIS, but there were enough people having issues with doing that on a remote server that I went this route instead. If you've done something similar and have another way of doing it, I'd love to hear about it!