- Has two websites,
- Hosts a Click Once project,
- Hosts a downloadable zip file containing some PowerShell extensions.
The websites are developed within the single Visual Studio 2013 solution MyApp.Server.sln and the Click Once and command line tools within another solution MyApp.Client.sln. These are located in the same folder in the file system / TFS as shown below:
The MyApp.Common project contains common functionality that is shared by all the client and server projects.
Extending the Packaging Process
All the processes that I know of that deploy Azure Cloud Services packages use MsBuild.exe to build the package itself. It would therefore be best if whatever we do to ensure that all the components of our Web Role end up in the package is something that will be triggered by MsBuild. We could try and do this the hard way by creating MsBuild tasks and customising the build process, but I went for the easy way: setting Pre-Build commands on the Cloud Services project.Deploying Two Websites to a Single Web Role
After I've created a Web Role for the first Web Project, MyApp.WebSite1, the ServiceDefinition.csdef file for my Cloud Service looks like this:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?xml version="1.0" encoding="utf-8"?> <ServiceDefinition name="MyApp.CloudService" xmlns="http://schemas.microsoft.com/ServiceHosting/2008/10/ServiceDefinition" schemaVersion="2014-06.2.4"> <WebRole name="MyApp.WebSite1" vmsize="Small"> <Sites> <Site name="Web"> <Bindings> <Binding name="Main" endpointName="WebSite1" /> </Bindings> </Site> </Sites> <Endpoints> <InputEndpoint name="WebSite1" protocol="http" port="80" /> </Endpoints> </WebRole> </ServiceDefinition> |
To add my second Web Project to this role I need to manually edit this file, adding the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <?xml version="1.0" encoding="utf-8"?> <ServiceDefinition name="MyApp.CloudService" xmlns="http://schemas.microsoft.com/ServiceHosting/2008/10/ServiceDefinition" schemaVersion="2014-06.2.4"> <WebRole name="MyApp.WebSite1" vmsize="Small"> <Sites> <Site name="Web"> <Bindings> <Binding name="Main" endpointName="WebSite1" /> </Bindings> </Site> <Site name="Web2" physicalDirectory=".\..\..\..\MyApp.WebSite2"> <Bindings> <Binding name="Main" endpointName="WebSite2" /> </Bindings> </Site> </Sites> <Endpoints> <InputEndpoint name="WebSite1" protocol="http" port="80" /> <InputEndpoint name="WebSite2" protocol="http" port="82" /> </Endpoints> </WebRole> </ServiceDefinition> |
(The \..\..\..\ in the "physicalDirectory" attribute is needed because the current directory at this point is "MyApp.CloudService\csx\Debug")
If I press F5 my two websites now fire up correctly in the Azure emulator. But let's build a package to deploy to Azure and see what actually gets included. If I right click on the MyApp.CloudService project and select the "Package" option then the following is built:
Rename the .cspkg to a .zip, look inside it and we find:
Extract the .cssx file, rename it to a .zip, look inside it and we find (if we drill down):
You can see the problem here: although the site has been included in the package, all the source code files and other development artifacts are included instead of just the binaries, config and content. Worse still:
- The project is not built when we package our MyApp.CloudService project as it is not a dependency, so the build that we deploy is the previous build; this might be Debug rather than Release, and might have out of date binaries that do not include the latest changes.
- The web.config transforms are not applied, so the web.config that we deploy to production contains all our Debug settings.
So how can we fix the problem? What I need is to deploy a copy of MyApp.WebSite2 that has been built using the correct configuration and then had the development artifacts removed and the web.config transforms applied. As this is exactly what the "Publish" process does I will use a Pre-Build command on the Cloud Services project to publish MyApp.WebSite2 to a temporary directory and then point the .csdef at that temporary directory instead of the source directory. Before we can do this we need to set up a Publish Profile for MyApp.WebSite2 as shown below:
Note that we choose "Custom" not "Microsoft Azure Websites" because by "publish" here what we really mean is "build using my chosen configuration and apply the web.config transformations of my chosen configuration, copying over only the binaries, content and configuration".
Step 2 of the wizard:
At this step I enter the path to a temporary folder that is a sibling of the MyApp.WebSite2 project folder. The remaining steps of the wizard can be clicked through accepting the default settings. Next I edit the .csdef file to point to this temporary folder instead of the source folder:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <?xml version="1.0" encoding="utf-8"?> <ServiceDefinition name="MyApp.CloudService" xmlns="http://schemas.microsoft.com/ServiceHosting/2008/10/ServiceDefinition" schemaVersion="2014-06.2.4"> <WebRole name="MyApp.WebSite1" vmsize="Small"> <Sites> <Site name="Web"> <Bindings> <Binding name="Main" endpointName="WebSite1" /> </Bindings> </Site> <Site name="Web2" physicalDirectory=".\..\..\..\MyApp.WebSite2.TempPublish"> <Bindings> <Binding name="Main" endpointName="WebSite2" /> </Bindings> </Site> </Sites> <Endpoints> <InputEndpoint name="WebSite1" protocol="http" port="80" /> <InputEndpoint name="WebSite2" protocol="http" port="82" /> </Endpoints> </WebRole> </ServiceDefinition> |
And add the following to the Pre-Build commands of the MyApp.CloudService project:
"%ProgramFiles(x86)%\MSBuild\12.0\Bin\msbuild.exe" "$(ProjectDir)..\MyApp.WebSite2\MyApp.WebSite2.csproj" /p:DeployOnBuild=true /p:PublishProfile="Pre Azure Publish" /p:VisualStudioVersion=12.0 /p:Configuration=$(ConfigurationName)
Most of the parameters are as you would expect, the only surprise is the "/p:VisualStudioVersion=12.0"; see this blog post on msbuild for the reason why this is needed.
Hit F5 and it starts up in the Azure emulator without any problems. Build a package, drill down into the .cspkg file and this time we find:
All the development artifacts have been removed, and if we look at the contents of the web.config file the transformations have been applied.
Building the Click Once Project
Before thinking about how we can ensure that the Click Once project is correctly deployed to Azure I need to give you some more details of how it is set up. The MyApp.ClickOnce project is configured to Publish to a folder within the MyApp.WebSite1 project using the Publish wizard as shown below:In the next step we need to enter the Url that users will download the app from on our Cloud Services website:
In the next step I select whether the app can be launched independently of the website:
After the wizard has run and published the application I open the Properties of the MyApp.ClickOnce project and suppress the creation of the "publish.htm" file:
Finally I clear the option to auto-increment the version on each publish operation and ensure that the version number is set to 1.0.0.1:
I click "Publish Now" to carry out another publish operation with the new settings. To ensure that the app is deployed along with MyApp.WebSite1 I need to include it in the project and ensure that all the files are marked as "Content":
I also at this point ensure that these files are not source controlled by undoing the "add" operations in Team Explorer, Pending Changes. Note that Visual Studio is quite happy for files to be part of the project without being in TFS.
With this publishing set up the most recent build of MyApp.ClickOnce will be included in the Cloud Services package when it is built. To ensure that this build is up to date we need... another Pre Build command! I add the following to the Pre Build commands of MyApp.CloudService:
"%ProgramFiles(x86)%\MSBuild\12.0\Bin\msbuild.exe" "$(ProjectDir)..\MyApp.ClickOnce\MyApp.ClickOnce.csproj" /p:DeployOnBuild=true /p:VisualStudioVersion=12.0 /p:Configuration=$(ConfigurationName) /p:Platform=AnyCPU /target:publish /p:InstallUrl=http://www.mydomain.com/downloads/ClientApp/ "/p:PublishDir=$(ProjectDir)..\MyApp.WebSite1\downloads\ClientApp\\"
A few important things to note about this command:
- The "InstallUrl" and "PublishDir" properties need to be set via the command line even though we have already specified them when setting up publishing for MyApp.ClickOnce.
- The double trailing slash on the PublishDir is needed!
- If $(ProjectDir) contains any spaces (which will normally be the case as the default location for projects is "%USERPROFILE%\Documents\Visual Studio 2013\Projects") then this will not work; you will get an error message from msbuild that says
MSBUILD : error MSB1008: Only one project can be specified.
The only solution I found to this was to replace "$(ProjectDir).." in the PublishDir with the literal path using short folder names, like this:
"%ProgramFiles(x86)%\MSBuild\12.0\Bin\msbuild.exe" "$(ProjectDir)..\MyApp.ClickOnce\MyApp.ClickOnce.csproj" /p:DeployOnBuild=true /p:VisualStudioVersion=12.0 /p:Configuration=$(ConfigurationName) /p:Platform=AnyCPU /target:publish /p:InstallUrl=http://www.mydomain.com/downloads/ClientApp/ "/p:PublishDir=C:\Users\Alex\Documents\Visual~1\Projects\MyApp\MyApp.WebSite1\downloads\ClientApp\\"
It's a shame I have to do this, but at least it works! Every time I want to increment the version number of the Click Once app I have to ensure that I include the build outputs from the new version in the MyApp.WebSite1 project (and I'll probably want to exclude the outputs from the old version).
Zip File containing PowerShell Extensions
Outputs of my MyApp.PowerShell project are:I want to end up with these outputs (less the .pdb files) in a zip file in the MyApp.WebSite1 project like this:
What's the best way to do this? You can't throw a brick in Linux without hitting a command line zip tool, but in Windows they're a bit less abundant, so I decided to use PowerShell, and added the following to the MyApp.CloudService Pre Build command line to build and zip the MyApp.PowerShell project:
1 2 | "%ProgramFiles(x86)%\MSBuild\12.0\Bin\msbuild.exe" "$(ProjectDir)..\MyApp.PowerShell\MyApp.PowerShell.csproj" /p:VisualStudioVersion=12.0 /p:Configuration=$(ConfigurationName) /target:build powershell -Command "[void] [System.Reflection.Assembly]::LoadFrom('$(ProjectDir)..\packages\SharpZipLib.0.86.0\lib\20\ICSharpCode.SharpZipLib.dll');$zip = [ICSharpCode.SharpZipLib.Zip.ZipFile]::Create('$(ProjectDir)..\MyApp.WebSite1\downloads\MyApp.PowerShell.zip');$zip.BeginUpdate();ls '$(ProjectDir)..\MyApp.PowerShell\bin\$(ConfigurationName)' -exclude *.pdb,*.xml | Foreach-Object {$zip.Add($_.FullName, $_.Name)};$zip.CommitUpdate();" |
The second line isn't very readable in that format so here it is split on to multiple lines:
1 2 3 4 5 | [void][System.Reflection.Assembly]::LoadFrom('$(ProjectDir)..\packages\SharpZipLib.0.86.0\lib\20\ICSharpCode.SharpZipLib.dll'); $zip = [ICSharpCode.SharpZipLib.Zip.ZipFile]::Create('$(ProjectDir)..\MyApp.WebSite1\downloads\MyApp.PowerShell.zip'); $zip.BeginUpdate(); ls '$(ProjectDir)..\MyApp.PowerShell\bin\$(ConfigurationName)' -exclude *.pdb,*.xml | Foreach-Object {$zip.Add($_.FullName, $_.Name)}; $zip.CommitUpdate(); |
For this to work you need to add the SharpZipLib package to one of the projects in the solution. You can use Microsoft's System.IO.Compression.FileSystem.dll assembly but this only allows you to zip whole folders so we would not be able to exclude the .pdb files.
Conclusion
Pre Build events are very useful for extending the Azure packaging process, or for extending any other Visual Studio publishing process.The only final note to add is that if you wanted your project to start up slightly faster when debugging you could excluded some of the Pre Build commands from a Debug build by prefixing them with:
if /i "$(ConfigurationName)" == "Release"
Enjoy!