Team Build 2010 – Copying Build Outputs to Remote Server in Untrusted Domain

While helping a client update their Team Build 2008 builds to TFS 2010 format, we came across a use case that is not well documented (to be fair, it may be out there but my half day searching did not uncover a complete working solution). This blog post will detail a possible solution to the problem of copying build outputs to a remote server in an untrusted domain

The Use Case

The client’s previous setup looked like the following:

TFS2008Architecture

· Domain 1 with TFS and a Build Server

· Domain 2 with DEV Server. A 1 way trust exists between domain 2 and domain 1. (That is: Domain 2 will accept Domain 1 accounts, but domain 1 will not authenticate Domain 2 accounts)

· Domain 3 with a build server and a UAT server. No trust exists between Domain 1 and Domain 3

· Domain 4 with a build server and a UAT server. No trust exists between Domain 1 and Domain 4

The reason that the client initially went for this configuration was so that the DEV/UAT/PROD servers could be automatically deployed by the build server

When we migrated the client to TFS 2010, the new structure was now as follows:

TFS2010Architecture

This new configuration left us with an issue with the build scripts: The built-in CopyDirectory Activity would not work, as there were no domain trusts between Domain 1 and Domain 3 or 4.

The Search

So off to the Internet we went, trusting the great God’s Bing and Google. Alas, no satisfactory answer was found (If someone is reading this and has a more elegant solution, then please let me and everyone else who reads this know).

We came across a very good set of blog posts that detailed how to create a custom Build Activity. (Ewald Hofman – Customize Team Build 2010 – Part 9: Impersonate activities (run under other credentials)  - I highly recommend reading the entire series as it gives an excellent tutorial on creating custom build activities)

This blog post was the most interesting, and showed much promise. However, for our use case (untrusted domain), it proved to be not what we needed. the above solution relied on a PInvoke API call to LogonUser. The issue with this is that the LogonUser API call does not log-on remotely. That is, if there exists a trust between the machine invoking the LogonUser API and the target machine, then the call will work. However, in out situation, there is no domain trust, and the LogonUser will not authenticate.

The Solution

After a lot of searching and chasing of links, we settled on using the API equivalent of net use Z:  password \ServerNameShareName /user:domainusername: WNetAddConnection2 API call from the mpr.dll

   1: [DllImport("mpr.dll")]
   2:  private static extern int WNetAddConnection2(NETRESOURCE lpNetResource, string lpPassword, string lpUsername, int dwFlags);
   3: 
   4: [DllImport("mpr.dll")]
   5: private static extern int WNetCancelConnection2(string lpName, int dwFlags, bool bForce);


So what we did was take the example given by Ewald and used the above instead of the impersonation.

The Code

  1. Create a new Workflow/Activity Library C# Project. Name it something like ActivityPack

CreateWorkflowActivityLibrary

  1. Delete the XAML file that is created automatically.

  2. Add the following references (paths included are default install paths)

C:Program Files (x86)Microsoft Visual Studio 10.0Common7IDEReferenceAssembliesv2.0Microsoft.TeamFoundation.Build.Client.dll
C:WindowsMicrosoft.NetassemblyGAC_MSILMicrosoft.TeamFoundation.Build.Workflowv4.0_10.0.0.0__b03f5f7f11d50a3aMicrosoft.TeamFoundation.Build.Workflow.dll
C:Program Files (x86)Microsoft Visual Studio 10.0Common7IDEReferenceAssembliesv2.0Microsoft.TeamFoundation.Client.dll
C:Program Files (x86)Microsoft Visual Studio 10.0Common7IDEReferenceAssembliesv2.0Microsoft.TeamFoundation.Common.dll
C:Program Files (x86)Microsoft Visual Studio 10.0Common7IDEPrivateAssembliesMicrosoft.TeamFoundation.TestImpact.BuildIntegration.dll
C:WindowsassemblyGAC_MSILMicrosoft.TeamFoundation.TestImpact.Client10.0.0.0__b03f5f7f11d50a3aMicrosoft.TeamFoundation.TestImpact.Client.dll
C:Program Files (x86)Microsoft Visual Studio 10.0Common7IDEReferenceAssembliesv2.0Microsoft.TeamFoundation.VersionControl.Client.dll
C:Program Files (x86)Microsoft Visual Studio 10.0Common7IDEReferenceAssembliesv2.0Microsoft.TeamFoundation.VersionControl.Common.dll
C:Program Files (x86)Reference AssembliesMicrosoftFramework.NETFrameworkv4.0PresentationCore.dll
C:Program Files (x86)Reference AssembliesMicrosoftFramework.NETFrameworkv4.0PresentationFramework.dll
C:Program Files (x86)Reference AssembliesMicrosoftFramework.NETFrameworkv4.0System.Activities.dll
C:Program Files (x86)Reference AssembliesMicrosoftFramework.NETFrameworkv4.0System.Activities.Presentation.dll


4. Create a new Directory to hold the activities. Right-click the project and select Add –> Directory. Name the directory Activities

  1. Create a new directory for the Custom Types. Right-click the project and select Add –> Directory. Name the directory CustomTypes

  2. Create a new directory  for the the Network Connection Code. Right-click the project and select Add –> Directory. Name the directory Library

  3. Create the Credential custom type

  4. Create the CredentialDialog.cs file. This will be the dialog that is shown when the user adds credentials to the build (See attached files for this one)

  5. Create the CredentialEditor.cs file. This will be the editor class that is used to display the CredentialDialog when the user adds credentials to the build.

using System.Drawing.Design;
using System.Windows.Forms.Design;
using System.ComponentModel;
using System;
using System.Windows.Forms;
 
namespace EALM.TfsBuild.Activities.ActivityPack.CustomType
{
 
    public class CredentialEditor : UITypeEditor
    {
 
        public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
        {
            if (provider != null)
            {
                IWindowsFormsEditorService editorService = (IWindowsFormsEditorService)provider.GetService(typeof(IWindowsFormsEditorService));
 
                if (editorService != null)
                {
                    Credential credential = value as Credential;
 
                    using (CredentialDialog dialog = new CredentialDialog { Domain = credential.Domain, UserName = credential.UserName, Password = credential.Password })
                    {
 
                        if (editorService.ShowDialog(dialog) == DialogResult.OK)
                        {
                            credential.Domain = dialog.Domain;
                            credential.UserName = dialog.UserName;
                            credential.Password = dialog.Password;
                        }
                    }
                }
 
            }
 
            return value;
  
  
        }
 
        public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context)
        {
            return UITypeEditorEditStyle.Modal;
        }
    }
}


10. Create the Credential.cs file. This class provides the data for the credentials

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
 
namespace EALM.TfsBuild.Activities.ActivityPack.CustomType
{
    public class Credential
    {
        public string Domain { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
  
  
        public override string ToString()
        {
            if (string.IsNullOrEmpty(UserName))
            {
                return null;
            }
            else
            {
                return String.Format(@"{0}{1}", Domain, UserName);
            }
        }
    }
}


11. Create the NetworkDrive.cs file in the Library folder. This class is used to create the drive mapping, and cancelling the drive mapping using the supplied credentials. This class will also determine the next available drive letter on the build server, so that multiple builds occurring at the same time will not clash with drive names.

NOTE: It is still possible to run out of drive letters

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.InteropServices;
using System.Collections.Specialized;
using System.IO;
 
namespace EALM.TfsBuild.Activities.ActivityPack.Library
{
    public class NetworkDrive
    {
        public enum ResourceScope
        {
            RESOURCE_CONNECTED = 1,
            RESOURCE_GLOBALNET,
            RESOURCE_REMEMBERED,
            RESOURCE_RECENT,
            RESOURCE_CONTEXT
        }
 
        public enum ResourceType
        {
            RESOURCETYPE_ANY,
            RESOURCETYPE_DISK,
            RESOURCETYPE_PRINT,
            RESOURCETYPE_RESERVED
        }
 
        public enum ResourceUsage
        {
            RESOURCEUSAGE_CONNECTABLE = 0x00000001,
            RESOURCEUSAGE_CONTAINER = 0x00000002,
            RESOURCEUSAGE_NOLOCALDEVICE = 0x00000004,
            RESOURCEUSAGE_SIBLING = 0x00000008,
            RESOURCEUSAGE_ATTACHED = 0x00000010,
            RESOURCEUSAGE_ALL = (RESOURCEUSAGE_CONNECTABLE | RESOURCEUSAGE_CONTAINER | RESOURCEUSAGE_ATTACHED),
        }
 
        public enum ResourceDisplayType
        {
            RESOURCEDISPLAYTYPE_GENERIC,
            RESOURCEDISPLAYTYPE_DOMAIN,
            RESOURCEDISPLAYTYPE_SERVER,
            RESOURCEDISPLAYTYPE_SHARE,
            RESOURCEDISPLAYTYPE_FILE,
            RESOURCEDISPLAYTYPE_GROUP,
            RESOURCEDISPLAYTYPE_NETWORK,
            RESOURCEDISPLAYTYPE_ROOT,
            RESOURCEDISPLAYTYPE_SHAREADMIN,
            RESOURCEDISPLAYTYPE_DIRECTORY,
            RESOURCEDISPLAYTYPE_TREE,
            RESOURCEDISPLAYTYPE_NDSCONTAINER
        }
 
        [StructLayout(LayoutKind.Sequential)]
        private class NETRESOURCE
        {
            public ResourceScope dwScope = 0;
            public ResourceType dwType = 0;
            public ResourceDisplayType dwDisplayType = 0;
            public ResourceUsage dwUsage = 0;
            public string lpLocalName = null;
            public string lpRemoteName = null;
            public string lpComment = null;
            public string lpProvider = null;
        }
 
        [DllImport("mpr.dll")]
        private static extern int WNetAddConnection2(NETRESOURCE lpNetResource, string lpPassword, string lpUsername, int dwFlags);
 
        [DllImport("mpr.dll")]
        private static extern int WNetCancelConnection2(string lpName, int dwFlags, bool bForce);
 
        public int MapNetworkDrive(string unc, string drive, string user, string password)
        {
            NETRESOURCE myNetResource = new NETRESOURCE();
            myNetResource.lpLocalName = drive;
            myNetResource.lpRemoteName = unc;
            myNetResource.lpProvider = null;
            int result = WNetAddConnection2(myNetResource, password, user, 0);
            return result;
        }
 
        public int CancelConnection2(string lpName, bool force)
        {
            return WNetCancelConnection2(lpName, 0, force);
        }
 
        public string FindNextAvailableDriveLetter()
        {
            // build a string collection representing the alphabet
            StringCollection alphabet = new StringCollection();
 
            int lowerBound = Convert.ToInt16('a');
            int upperBound = Convert.ToInt16('z');
            for (int i = lowerBound; i < upperBound; i++)
            {
                char driveLetter = (char)i;
                alphabet.Add(driveLetter.ToString());
            }
 
            // get all current drives
            DriveInfo[] drives = DriveInfo.GetDrives();
            foreach (DriveInfo drive in drives)
            {
                alphabet.Remove(drive.Name.Substring(0, 1).ToLower());
            }
 
            if (alphabet.Count > 0)
            {
                return alphabet[0] + ":";
            }
            else
            {
                throw (new ApplicationException("No drives available."));
            }
        }
    }
}


12. Create the CopyFiles.cs Activity in the Activities folder.

NOTE:

This activity will require the following parameters be passed to it/set in the build workflow:

  • Credentials –> The credentials to use.
  • SourcePath –> The source path. e.g. BinariesDirectory
  • DestinationPath –> The path to copy the source to
  • Recursive –> Whether to only delete files in the SourcePath (false), or the entire contained structure (true)
using System.Activities;
using EALM.TfsBuild.Activities.ActivityPack.CustomType;
using EALM.TfsBuild.Activities.ActivityPack.Library;
using Microsoft.TeamFoundation.Build.Client;
using System.IO;
using Microsoft.TeamFoundation.Build.Workflow.Tracking;
using Microsoft.TeamFoundation.Build.Workflow.Activities;
using System;
 
namespace EALM.TfsBuild.Activities.ActivityPack
{
    [BuildActivity(HostEnvironmentOption.All)]
    public sealed class CopyFiles : CodeActivity<bool>
    {
 
        public InArgument<Credential> Credentials { get; set; }
        public InArgument<string> SourcePath { get; set; }
        public InArgument<string> DestinationPath { get; set; }
        NetworkDrive nd = new NetworkDrive();
 
        protected override bool Execute(CodeActivityContext context)
        {
            bool result = false;
            string sourcePath = context.GetValue(SourcePath);
            string destinationPath = context.GetValue(DestinationPath);
            Credential cred = context.GetValue(Credentials);
 
            string driveToMap = nd.FindNextAvailableDriveLetter();
            try
            {
                context.Track(new BuildInformationFieldRecord<BuildMessage>()
                {
                    FieldValue = new BuildMessage()
                    {
                        Importance = BuildMessageImportance.Normal,
                        Message = string.Format("CopyFiles: Credentials Being used: User Name: {0}; Domain: {1}", cred.UserName, cred.Domain),
                    },
                });
                context.Track(new BuildInformationFieldRecord<BuildMessage>()
                {
                    FieldValue = new BuildMessage()
                    {
                        Importance = BuildMessageImportance.Normal,
                        Message = string.Format("CopyFiles: Source Path: {0}; Destination Path: {1}", sourcePath, destinationPath),
                    },
                });
 
                int returnValue = nd.MapNetworkDrive(destinationPath, driveToMap, cred.Domain + @"" + cred.UserName, cred.Password);Error connecting to {0}. Error returned = {1}", driveToMap,  new System.ComponentModel.Win32Exception(returnValue).Message);");
                if (returnValue != 0){
                    string errorM = string.Format("
                    throw new ApplicationException(errorM);
                }
 
                CopyFolder(sourcePath, driveToMap + @"
                result = true;
            }
            catch
            {
                throw;
            }
            finally
            {
                nd.CancelConnection2(driveToMap, true);
            }
            return result;
        }
 
        private void CopyFolder(string sourceFolder, string destFolder)
        {
            if (!Directory.Exists(destFolder))
            {
                Directory.CreateDirectory(destFolder);
            }
            string[] files = Directory.GetFiles( sourceFolder );
            foreach (string file in files)
            {
                string name = Path.GetFileName( file );
                string dest = Path.Combine( destFolder, name );
                File.Copy( file, dest );
            }
            string[] folders = Directory.GetDirectories( sourceFolder );
            foreach (string folder in folders)
            {
                string name = Path.GetFileName( folder );
                string dest = Path.Combine( destFolder, name );
                CopyFolder( folder, dest );
            }
        }
    }
}


13. Create the DeleteFiles.cs Activity in the Activities folder

NOTE: This activity will fail if there is a file open in the directory to be deleted, or a command prompt is open at a path that is contained in the path to delete.

NOTE 2: This activity will require the following parameters be passed to it/set in the build workflow:

  • Credentials –> The credentials to use.
  • SourcePath –> The path to clear
  • Recursive –> Whether to only delete files in the SourcePath (false), or the entire contained structure (true)
using System.Activities;
using EALM.TfsBuild.Activities.ActivityPack.CustomType;
using EALM.TfsBuild.Activities.ActivityPack.Library;
using Microsoft.TeamFoundation.Build.Client;
using System.IO;
using Microsoft.TeamFoundation.Build.Workflow.Tracking;
using Microsoft.TeamFoundation.Build.Workflow.Activities;
using System.Runtime.InteropServices;
using System;
 
namespace EALM.TfsBuild.Activities.ActivityPack
{
    [BuildActivity(HostEnvironmentOption.All)]
    public sealed class DeleteFiles : CodeActivity<bool>
    {
 
        public InArgument<Credential> Credentials { get; set; }
        public InArgument<string> SourcePath { get; set; }
        public InArgument<bool> Recursive { get; set; }
 
        protected override bool Execute(CodeActivityContext context)
        {
            bool result = false;
            string sourcePath = context.GetValue(SourcePath);
            bool recursive = context.GetValue(Recursive);
            Credential cred = context.GetValue(Credentials);
            NetworkDrive nd = new NetworkDrive();
 
            string driveToMap = nd.FindNextAvailableDriveLetter();
            try
            {
                int returnValue = nd.MapNetworkDrive(sourcePath, driveToMap, cred.Domain + @"" + cred.UserName, cred.Password);Error connecting to {0}. Error returned = {1}", driveToMap, new System.ComponentModel.Win32Exception(returnValue).Message);
                if (returnValue != 0)
                {
                    string error = string.Format("
                    throw new ApplicationException(error);
                }
                Delete(driveToMap, recursive);
            }
            catch
            {
                throw;
            }
            finally
            {
                nd.CancelConnection2(driveToMap, true);
            }
            return result;
        }
 
        private void Delete(string sourceFolder, bool recursive)
        {
            if (recursive)
            {
                //get the child folders and delete them
                string[] directories = Directory.GetDirectories(sourceFolder);
                foreach (string dir in directories)
                {
                    Directory.Delete(dir, recursive);
                }
                //Delete all files in this folder
                string[] files = Directory.GetFiles(sourceFolder);
                foreach (string file in files)
                {
                    File.Delete(file);
                }
            }
            else
            {
                //Just delete the files and ignore the subdirectories
                string[] files = Directory.GetFiles(sourceFolder);
                foreach (string file in files)
                {
                    File.Delete(file);
                }
            }
        }
    }
}


14.  That should be it. Follow Ewald’s steps to register the resulting DLL with the build server and test.

Hopefully this will help someone who is struggling with trying to deploy a build to a server that is in a different domain

Download the code here

References

Find Next Available Drive Letter

Ewald Hofman - Customize Team Build 2010 - Part 9 Impersonate activities (run under other credentials)

C# Copy Folder Recursively

PInvoke.NET - wnetaddconnection2 (mpr)

Richard

Richard is a Director and the principal Consultant at Dev iQ Pty Ltd. He specialises in SharePoint, Team Foundation Server/Visual Studio and .NET Development.

Subscribe to richard angus

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!