At work I’ve been working with a very old classic ASP website, running on a very old hardware. After a few weeks of hacking vbscript, I’m
sorely missing C#, and especially unit tests. I feel so exposed when there’s no tests proving my code does what I think it does. For reasons I’ll not go into, deploying aspx pages is not an option, so I spent some time exploring creating COM objects using .NET, and consuming them from vbscript using Server.CreateObject. This seems like it will work really well for my purpose. This morning I sorted out deployment, which didn’t have any easy google answers.
My goals is to deploy via msbuild with a few commands:
MSBuild.exe /t:DeployCOM /p:env=test
to deploy to my test siteMSBuild.exe /t:DeployCOM /p:env=prod
to deploy to production
TL;DR: see deploy-com-dll.targets
Requirements
Before getting into XML, to deploy a COM DLL, there are a few things to do:
- unregister the old version of the DLL. I’m trying to avoid DLL Hell by only having one version of my DLL installed on the server, and explicitly not maintaining backward compatibility
- restart IIS; if my COM object has been loaded in IIS, then the DLL file on disk is locked
- copy new DLL to the server
- register the new DLL
MSBuild
design
MSBuild conveniently includes the RegisterAssembly and UnregisterAssembly tasks to handle the COM registration, but unfortunately these don’t work with remote machines, so we have to do things by hand.
- conditional PropertyGroups for each deployment target (
test
andprod
) - unconditional PropertyGroup for common settings
- use the Exec task with PsExec.exe to make run remote commands
- use RegAsm.exe to register / unregister our assembly
- use iisreset.exe to restart IIS
- use Copy to copy our DLL over file share using UNC paths
- wrap it up in a
DeployCOM
Target
MSBuild
implementation
The XML is available as deploy-com-dll.targets.
First up, our conditional PropertyGroups:
<Propertygroup Condition="'$(Env)' == 'test'">
<!-- UNC path where we publish code-->
<PublishFolder>\\dev-server\Sites\my-classic-asp-site</PublishFolder>
<!-- the remote path to where our code is deployed -->
<LocalPublishFolder>D:\WebRoot\my-classic-asp-site</LocalPublishFolder>
<!-- the computer name for psexec -->
<Computer>\\dev-server</Computer>
</PropertyGroup>
<PropertyGroup Condition="'$(Env)' == 'prod'">
<PublishFolder>\\prod-server\WebRoot\my-classic-asp-site</PublishFolder>
<LocalPublishFolder>D:\sites\my-classic-asp-site</LocalPublishFolder>
<Computer>\\prod-server</Computer>
</PropertyGroup>
Pretty straightfoward, tells us where to copy our files to, and gives the remote path so when we call regasm
we can tell it where to look.
Next, our unconditional PropertyGroup:
<PropertyGroup>
<!-- where to copy our COM assembly -->
<COMPublishFolder>$(PublishFolder)\COM</COMPublishFolder>
<!-- remote path to our where we published our COM assembly -->
<COMFolder>$(LocalPublishFolder)\COM</COMFolder>
<!-- remote path to our COM assembly -->
<COMAssembly>$(COMFolder)\My.Interop.dll</COMAssembly>
<!-- remote path to our typelib -->
<COMTypeLib>$(COMFolder)\My.Interop.tlb</COMTypeLib>
<!-- local path to psexec-->
<PsExec>$(MSBuildProjectDirectory)\tools\PsExec.exe</PsExec>
<!-- remote path to regasm.exe -->
<RegAsm>C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\RegAsm.exe</RegAsm>
<!-- remote path to iisreset.exe -->
<iisreset>c:\WINDOWS\system32\iisreset.exe</iisreset>
</PropertyGroup>
Again, pretty straightforward. A lot of derived properties, specifying where to publish COM code, more remote paths so psexec
can find the regasm
and iisreset
executables and our COM assembly.
Lastly, our Target:
<Target Name="DeployCOM">
<!-- local path our compiled, strongly-named COM assembly and it's dependencies -->
<ItemGroup>
<FilesToCopy Include="$(MSBuildProjectDirectory)\src\My.Interop\bin\Release\*.*"/>
</ItemGroup>
<MakeDir Directories="$(COMPublishFolder)"/>
<!-- unregister the old version of the DDL -->
<Exec Command="$(PsExecBinary) $(Computer) $(RegAsm) /u /codebase /tlb:$(COMTypeLib) $(COMAssembly)"
IgnoreExitCode="true"/>
<!-- restart IIS -->
<Exec Command="$(PsExecBinary) $(Computer) $(iisreset) /restart" IgnoreExitCode="true"/>
<!-- copy new DLL to the server -->
<Copy SourceFiles="@(FilesToCopy)"
DestinationFiles="@(FilesToCopy->'$(COMPublishFolder)\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true"
/>
<!-- register the new DDL -->
<Exec Command="$(PsExecBinary) $(Computer) $(RegAsm) /codebase /tlb:$(COMTypeLib) $(COMAssembly)"/>
</Target>
There are some comments inline there breaking out the steps. A few important notes:
- we’re using the
/codebase
flag toregasm
; this means we are NOT going to be installing our .NET in the global assembly cache, so the COM system needs to know to look in ourCOMFolder
to resolve .NET references. Without this, we would need to also callgacutil
to install our .NET in the system, and any third-party libraries we’ve referenced - the COM assembly needs to be strongly named, and therefore all it’s dependencies need to be strongly named. Compiling and signing the assembly is out of scope here, but there are plenty of good tutorials out there.
- in a few places we ignore the exit code of the exec call; on first deployment those tasks might fail
- this is a really heavy handed approach with IIS. Using more of the versioning features in COM and .NET assembly will let you install multiple versions of everything so you can publish a new version, then update your vbscript code to use that new version. In my case, I have huge maintenance windows every night to do iis restarts, but that option is not available to everyone.
Summary
It’s ugly, but it works and is a hell of a lot prettier than writing vbscript, and I can write tests and get some confidence that my code works before it’s in production.