Пишем Custom MSBuild Task для деплоя (WMI included)

    Добрый день! Одним прекрасным днем мы обнаружили, что наш MSBuild деплой проект не хочет работать в новой среде: для создания и управления сайтами и пулами он использовал MSBuild.ExtensionPack. Падали ошибки, связанные с недоступностью DCOM. Среду менять было нельзя, поэтому кстати пришлась возможность написания собственных задач для MSBuild: msdn.microsoft.com/en-us/library/t9883dzc.aspx, было принято решения написать свои, которые работали бы через WMI (доступный на среде) Кому интересно, что получилось, прошу под кат.

    Почему MSBuild и WMI


    Есть такие среды, в которых мы не властны открывать порты и конфигурировать их как хотим. Однако в данной среде уже все было настроено для работы WMI внутри всей сети, так что решение использовать WMI было наиболее безболезненным.
    MSBuild Использовался для деплоя несложного сайта с самого начала, поэтому было выбрано не переписывать весь деплоймент на Nant, а использовать уже имеющийся скрипт и заменить только не работающие таски.

    Как писать собственные задачи для MSBuild


    Подключаем в свой проект сборки Microsoft.Build.Framework, Microsoft.Build.Tasks.v4.0 и Microsoft.Build.Utilities.v4.0. Теперь есть 2 альтернативы:
    1 — наследовать от интерфейса ITask и потом саму переопределять кучу методов и свойств.
    2 — наследовать от абстрактного класса Task и переопределять только метод Execute.
    Как несложно догадаться, был выбран второй метод.
    HelloWorld для собственной задачи:
    using System;
    using Microsoft.Build.Framework;
    using Microsoft.Build.Utilities;
    
    namespace MyTasks
    {
        public class SimpleTask : Task
        {
            public override bool Execute()
            {
                Log.LogMessage("Hello Habrahabr");
                return true;
            }
        }
    }
    

    Метод Execute возвращает true, если задача выполнилась успешно, и false — в противном случае. Из полезных свойств, доступных в классе Task стоит отметить свойство Log, позволяющее поддерживать взаимодействие с пользователем.
    Параметры передаются тоже несложно, достаточно определить открытое свойство в этом классе (с открытыми геттером и сеттером):
    using System;
    using Microsoft.Build.Framework;
    using Microsoft.Build.Utilities;
    
    namespace MyTasks
    {
        public class SimpleTask : Task
        {
            public string AppPoolName { get; set; }
    
            [Output]
            public bool Exists { get; set; }
    
            public override bool Execute()
            {
                Log.LogMessage("Hello Habrahabr");
                return true;
            }
        }
    }
    

    Чтобы наша задача что-то возвращала, свойству надо добавить атрибут [Output].
    Так что можно сказать, что простота написания также явилась плюсом данного решения. На том, как с помощью WMI управлять IIS я останавливаться не буду, только отмечу, что используем namespace WebAdministration, который ставится вместе с компонентом Windows «IIS Management Scripts and Tools».
    Под спойлерами листинг базовой задачи, в которой инкапсулирована логика подключения к WMI и базовые параметры задачи, такие как:
    1. Machine — имя удаленной машины или localhost
    2. UserName — имя пользователя, под которым будем коннектиться к WMI
    3. Password — пароль пользователя, под которым будем коннектиться к WMI
    4. TaskAction — название самого действия (Create, Stop, Start, CheckExists)

    BaseWMITask
    using Microsoft.Build.Utilities;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Management;
    using System.Text;
    using System.Threading;
    
    namespace MSBuild.WMI
    {
        /// <summary>
        /// This class will be used as a base class for all WMI MSBuild tasks.
        /// Contains logic for basic WMI operations as well as some basic properties (connection information, actual task action).
        /// </summary>
        public abstract class BaseWMITask : Task
        {
            #region Private Fields
    
            private ManagementScope _scope;
    
            #endregion
    
            #region Public Properties (Task Parameters)
    
            /// <summary>
            /// IP or host name of remote machine or "localhost"
            /// If not set - treated as "localhost"
            /// </summary>
            public string Machine { get; set; }
    
            /// <summary>
            /// Username for connecting to remote machine
            /// </summary>
            public string UserName { get; set; }
    
            /// <summary>
            /// Password for connecting to remote machine
            /// </summary>
            public string Password { get; set; }
    
            /// <summary>
            /// Specific action to be executed (Start, Stop, etc.)
            /// </summary>
            public string TaskAction { get; set; }
    
            #endregion
    
            #region Protected Members
    
            /// <summary>
            /// Gets WMI ManagementScope object
            /// </summary>
            protected ManagementScope WMIScope
            {
                get
                {
                    if (_scope != null)
                        return _scope;
    
                    var wmiScopePath = string.Format(@"\\{0}\root\WebAdministration", Machine);
    
                    //we should pass user as HOST\\USER
                    var wmiUserName = UserName;
                    if (wmiUserName != null && !wmiUserName.Contains("\\"))
                        wmiUserName = string.Concat(Machine, "\\", UserName);
    
                    var wmiConnectionOptions = new ConnectionOptions()
                    {
                        Username = wmiUserName,
                        Password = Password,
                        Impersonation = ImpersonationLevel.Impersonate,
                        Authentication = AuthenticationLevel.PacketPrivacy,
                        EnablePrivileges = true
                    };
    
                    //use current user if this is a local machine
                    if (Helpers.IsLocalHost(Machine))
                    {
                        wmiConnectionOptions.Username = null;
                        wmiConnectionOptions.Password = null;
                    }
    
                    _scope = new ManagementScope(wmiScopePath, wmiConnectionOptions);
                    _scope.Connect();
    
                    return _scope;
                }
            }
    
            /// <summary>
            /// Gets task action
            /// </summary>
            protected TaskAction Action
            {
                get
                {
                    return (WMI.TaskAction)Enum.Parse(typeof(WMI.TaskAction), TaskAction, true);
                }
            }
    
            /// <summary>
            /// Gets ManagementObject by query
            /// </summary>
            /// <param name="queryString">String WQL query</param>
            /// <returns>ManagementObject or null if it was not found</returns>
            protected ManagementObject GetObjectByQuery(string queryString)
            {
                var query = new ObjectQuery(queryString);
                using (var mos = new ManagementObjectSearcher(WMIScope, query))
                {
                    return mos.Get().Cast<ManagementObject>().FirstOrDefault();
                }
            }
    
            /// <summary>
            /// Wait till the condition returns True
            /// </summary>
            /// <param name="condition">Condition to be checked</param>
            protected void WaitTill(Func<bool> condition)
            {
                while (!condition())
                {
                    Thread.Sleep(250);
                }
            }
    
            #endregion
        }
    }
    


    AppPool
    using Microsoft.Build.Framework;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Management;
    using System.Text;
    using System.Threading;
    
    namespace MSBuild.WMI
    {
        /// <summary>
        /// This class is used for operations with IIS ApplicationPool.
        /// Possible actions:
        ///   "CheckExists" - check if the pool with the name specified in "AppPoolName" exists, result is accessible through field "Exists"
        ///   "Create" - create an application pool with the name specified in "AppPoolName"
        ///   "Start" = starts Application Pool
        ///   "Stop" - stops Application Pool
        /// </summary>
        public class AppPool : BaseWMITask
        {
            #region Public Properties
    
            /// <summary>
            /// Application pool name
            /// </summary>
            public string AppPoolName { get; set; }
    
            /// <summary>
            /// Used as outpur for CheckExists command - True, if application pool with the specified name exists
            /// </summary>
            [Output]
            public bool Exists { get; set; }
    
            #endregion
    
            #region Public Methods
    
            /// <summary>
            /// Executes the task
            /// </summary>
            /// <returns>True, is task has been executed successfully; False - otherwise</returns>
            public override bool Execute()
            {
                try
                {
                    Log.LogMessage("AppPool task, action = {0}", Action);
                    switch (Action)
                    {
                        case WMI.TaskAction.CheckExists:
                            Exists = GetAppPool() != null;
                            break;
    
                        case WMI.TaskAction.Create:
                            CreateAppPool();
                            break;
    
                        case WMI.TaskAction.Start:
                            StartAppPool();
                            break;
    
                        case WMI.TaskAction.Stop:
                            StopAppPool();
                            break;
                    }
                }
                catch (Exception ex)
                {
                    Log.LogErrorFromException(ex);
                    return false;
                }
    
                //WMI tasks are execute asynchronously, wait to completing
                Thread.Sleep(1000);
    
                return true;
            }
    
            #endregion
    
            #region Private Methods
    
            /// <summary>
            /// Gets ApplicationPool with name AppPoolName
            /// </summary>
            /// <returns>ManagementObject representing ApplicationPool or null</returns>
            private ManagementObject GetAppPool()
            {
                return GetObjectByQuery(string.Format("select * from ApplicationPool where Name = '{0}'", AppPoolName));
            }
    
            /// <summary>
            /// Creates ApplicationPool with name AppPoolName, Integrated pipeline mode and ApplicationPoolIdentity (default)
            /// Calling code (MSBuild script) must first call CheckExists, in this method there's no checks
            /// </summary>
            private void CreateAppPool()
            {
                var path = new ManagementPath(@"ApplicationPool");
                var mgmtClass = new ManagementClass(WMIScope, path, null);
    
                //obtain in-parameters for the method
                var inParams = mgmtClass.GetMethodParameters("Create");
    
                //add the input parameters.
                inParams["AutoStart"] = true;
                inParams["Name"] = AppPoolName;
    
                //execute the method and obtain the return values.
                mgmtClass.InvokeMethod("Create", inParams, null);
    
                //wait till pool is created
                WaitTill(() => GetAppPool() != null);
                var appPool = GetAppPool();
    
                //set pipeline mode (default is Classic)
                appPool["ManagedPipelineMode"] = (int)ManagedPipelineMode.Integrated;
                appPool.Put();
            }
    
            /// <summary>
            /// Starts Application Pool
            /// </summary>
            private void StartAppPool()
            {
                GetAppPool().InvokeMethod("Start", null);
            }
    
            /// <summary>
            /// Stops Application Pool
            /// </summary>
            private void StopAppPool()
            {
                GetAppPool().InvokeMethod("Stop", null);
            }
    
            #endregion
        }
    }
    
    


    WebSite
    using Microsoft.Build.Framework;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Management;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;
    
    namespace MSBuild.WMI
    {
        /// <summary>
        /// 
        /// </summary>
        public class WebSite : BaseWMITask
        {
            #region Public Properties
    
            /// <summary>
            /// Web Site name
            /// </summary>
            public string SiteName { get; set; }
    
            /// <summary>
            /// Web Site physical path (not a UNC path)
            /// </summary>
            public string PhysicalPath { get; set; }
    
            /// <summary>
            /// Port (it's better if it's custom)
            /// </summary>
            public string Port { get; set; }
    
            /// <summary>
            /// Name of the Application Pool that will be used for this Web Site
            /// </summary>
            public string AppPoolName { get; set; }
    
            [Output]
            public bool Exists { get; set; }
    
            #endregion
    
            #region Public Methods
    
            /// <summary>
            /// Executes the task
            /// </summary>
            /// <returns>True, is task has been executed successfully; False - otherwise</returns>
            public override bool Execute()
            {
                try
                {
                    Log.LogMessage("WebSite task, action = {0}", Action);
                    switch (Action)
                    {
                        case WMI.TaskAction.CheckExists:
                            Exists = GetWebSite() != null;
                            break;
    
                        case WMI.TaskAction.Create:
                            CreateWebSite();
                            break;
    
                        case WMI.TaskAction.Start:
                            StartWebSite();
                            break;
    
                        case WMI.TaskAction.Stop:
                            StopWebSite();
                            break;
                    }
                }
                catch (Exception ex)
                {
                    Log.LogErrorFromException(ex);
                    return false;
                }
    
                //WMI tasks are execute asynchronously, wait to completing
                Thread.Sleep(1000);
    
                return true;
            }
    
            #endregion
    
            #region Private Methods
    
            /// <summary>
            /// Creates web site with the specified name and port. Bindings must be confgiured after manually.
            /// </summary>
            private void CreateWebSite()
            {
                var path = new ManagementPath(@"BindingElement");
                var mgmtClass = new ManagementClass(WMIScope, path, null);
    
                var binding = mgmtClass.CreateInstance();
    
                binding["BindingInformation"] = ":" + Port + ":";
                binding["Protocol"] = "http";
    
                path = new ManagementPath(@"Site");
                mgmtClass = new ManagementClass(WMIScope, path, null);
    
                // Obtain in-parameters for the method
                var inParams = mgmtClass.GetMethodParameters("Create");
    
                // Add the input parameters.
                inParams["Bindings"] = new ManagementBaseObject[] { binding };
                inParams["Name"] = SiteName;
                inParams["PhysicalPath"] = PhysicalPath;
                inParams["ServerAutoStart"] = true;
    
                // Execute the method and obtain the return values.
                mgmtClass.InvokeMethod("Create", inParams, null);
    
                WaitTill(() => GetApp("/") != null);
                var rootApp = GetApp("/");
    
                rootApp["ApplicationPool"] = AppPoolName;
                rootApp.Put();
            }
    
            /// <summary>
            /// Gets Web Site by name
            /// </summary>
            /// <returns>ManagementObject representing Web Site or null</returns>
            private ManagementObject GetWebSite()
            {
                return GetObjectByQuery(string.Format("select * from Site where Name = '{0}'", SiteName));
            }
    
            /// <summary>
            /// Get Virtual Application by path 
            /// </summary>
            /// <param name="path">Path of virtual application (if path == "/" - gets root application)</param>
            /// <returns>ManagementObject representing Virtual Application or null</returns>
            private ManagementObject GetApp(string path)
            {
                return GetObjectByQuery(string.Format("select * from Application where SiteName = '{0}' and Path='{1}'", SiteName, path));
            }
    
            /// <summary>
            /// Stop Web Site
            /// </summary>
            private void StopWebSite()
            {
                GetWebSite().InvokeMethod("Stop", null);
            }
    
            /// <summary>
            /// Start Web Site
            /// </summary>
            private void StartWebSite()
            {
                GetWebSite().InvokeMethod("Start", null);
            }
    
            #endregion
        }
    }
    


    Вызываем собственные задачи из билд скрипта


    Теперь осталось только научиться вызывать эти задачи из билд скрипта. Для этого надо, во-первых, сказать MSBuild где лежит наша сборка и какие задачи оттуда мы будем использовать:
    <UsingTask TaskName="MSBuild.WMI.AppPool" AssemblyFile="MSBuild.WMI\bin\Debug\MSBuild.WMI.dll"/>
    

    Теперь можно использовать задачу MSBuild.WMI.AppPool точно так же, как и самые обычные MSBuild команды.
    <MSBuild.WMI.AppPool TaskAction="CheckExists" AppPoolName="$(AppPoolName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)">
          <Output TaskParameter="Exists" PropertyName="AppPoolExists"/>
    </MSBuild.WMI.AppPool>
    

    Под спойлером — пример deploy.proj файла, который умеет создавать пул и сайт (если их нет), останавливать их перед деплоем, а потом запускать заново.
    deploy.proj
    <?xml version="1.0" encoding="utf-8"?>
    <Project ToolsVersion="4.0" DefaultTargets="Deploy" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
      
      <!-- common variables -->
      <PropertyGroup>       
        <Machine Condition="'$(AppPoolName)' == ''">localhost</Machine>
        <User Condition="'$(User)' == ''"></User>
        <Password Condition="'$(User)' == ''"></Password>	
        <AppPoolName Condition="'$(AppPoolName)' == ''">TestAppPool</AppPoolName>
        <WebSiteName Condition="'$(WebSiteName)' == ''">TestSite</WebSiteName>
        <WebSitePort Condition="'$(WebSitePort)' == ''">8088</WebSitePort>
    	<WebSitePhysicalPath Condition="'$(WebSitePhysicalPath)' == ''">D:\Inetpub\TestSite</WebSitePhysicalPath>
    	<AppPoolExists>False</AppPoolExists>
      </PropertyGroup>
    
      <UsingTask TaskName="MSBuild.WMI.AppPool" AssemblyFile="MSBuild.WMI\bin\Debug\MSBuild.WMI.dll"/>
      <UsingTask TaskName="MSBuild.WMI.WebSite" AssemblyFile="MSBuild.WMI\bin\Debug\MSBuild.WMI.dll"/>
      
      <!-- set up variables -->
      <Target Name="_Setup">
        <MSBuild.WMI.AppPool TaskAction="CheckExists" AppPoolName="$(AppPoolName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)">
          <Output TaskParameter="Exists" PropertyName="AppPoolExists"/>
        </MSBuild.WMI.AppPool>
    	<MSBuild.WMI.WebSite TaskAction="CheckExists" SiteName="$(WebSiteName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)">
          <Output TaskParameter="Exists" PropertyName="WebSiteExists"/>
        </MSBuild.WMI.WebSite>
      </Target>
      
      <!-- stop web site -->
      <Target Name="_StopSite">
    	<MSBuild.WMI.WebSite TaskAction="Stop" SiteName="$(WebSiteName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)" Condition="'$(WebSiteExists)'=='True'" />
        <MSBuild.WMI.AppPool TaskAction="Stop" AppPoolName="$(AppPoolName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)" Condition="'$(AppPoolExists)'=='True'" />
      </Target>
    
      <!-- stop and deploy web site -->
      <Target Name="_StopAndDeployWebSite">
    
        <!-- stop (if it exists) -->
        <CallTarget Targets="_StopSite" />
        
        <!-- create AppPool (if does not exist) -->
        <MSBuild.WMI.AppPool TaskAction="Create" AppPoolName="$(AppPoolName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)" Condition="'$(AppPoolExists)'=='False'" />
    	
    	<!-- create web site (if does not exist)-->
    	<MSBuild.WMI.WebSite TaskAction="Create" SiteName="$(WebSiteName)" Port="$(WebSitePort)"
    	    AppPoolName="$(AppPoolName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)" PhysicalPath="$(WebSitePhysicalPath)"
    		Condition="'$(WebSiteExists)'=='False'" />
      </Target>
    
      <!-- start all application parts -->
      <Target Name="_StartAll">
        <MSBuild.WMI.AppPool TaskAction="Start" AppPoolName="$(AppPoolName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)" />
    	<MSBuild.WMI.WebSite TaskAction="Start" SiteName="$(WebSiteName)" Machine="$(Machine)" UserName="$(User)" Password="$(Password)" />
      </Target>
    
      <!-- deployment implementation -->
      <Target Name="_DeployAll">
        <CallTarget Targets="_StopAndDeployWebSite" />
        <CallTarget Targets="_StartAll" />
      </Target>
    
      <!-- deploy application -->
      <Target Name="Deploy" DependsOnTargets="_Setup">
        <CallTarget Targets="_DeployAll" />
      </Target>
    
      <!-- stop application -->
      <Target Name="StopApplication" DependsOnTargets="_Setup">
        <CallTarget Targets="_StopWebSite" />
      </Target>  
      
      <!-- start application -->
      <Target Name="StartApplication" DependsOnTargets="_Setup">
        <CallTarget Targets="_StartAll" />
      </Target>  
      
    </Project>
    


    Для вызова деплоя достаточно передать этот файл msbuild.exe:
    "C:\Program Files (x86)\MSBuild\12.0\Bin\msbuild.exe" deploy.proj
    


    Выводы и ссылки


    Можно сказать, что написать свои задачи и подсунуть их MSBuild совсем не сложно. Спектр действий, которые могут выполнять такие задачи, тоже весьма широк и позволяет использовать MSBuild даже для не самых тривиальных операций по деплою, не требуя ничего, кроме msbuild.exe. На гитхабе выложен этот проект с примером билд файла: github.com/StanislavUshakov/MSBuild.WMI Можно расширять и добавлять новые задачи!
    • +13
    • 6.7k
    • 3
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 3

      0
      А с версии 4.0 — можно писать вообще inline — msdn.microsoft.com/en-us/library/dd722601.aspx, мсбилд сам его скомпилирует и подключит в контекст скрипта.
      За примером можно сходить сюда.

        0
        Спасибо за ссылку! Но мне и для Nanta, и для MSBuild'а больше нравится писать кастомные таски отдельно, в своих сборках. Так разделение логики лучше.
        0
        То что вы написали работать будет, это бесспорно.
        Но блин, неужели вам самому не кажется, что решить задачу «задеплоить сайт на IIS» можно лишь только написав на C# кастомный таск для MSBuild, который через WMI дёргает IIS?

        Линуксоиды прочитают вашу статью и подумают «на дворе 2015 год, а в винде до сих пор по-человечески сайты даже деплоить нельзя, вот идиоты».

        Начать нужно с того, что использовать MSBuild для автоматизации deploy — не нужно.
        Right tool for the right job, понимаете.

        Для всякой DevOps темы в виндах изобрели PowerShell.
        Есть PowerShell, в нём есть модуль WebAdministration, там эта задача решается так просто, что даже недостойна статьи на хабре.

        Извините если сильно грубо пишу, просто я считаю что хабр не должен учить людей костылям и велосипедам, а в вашем решении явно просматривается велосипед.

        Only users with full accounts can post comments. Log in, please.