Как стать автором
Обновить

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

Время на прочтение12 мин
Количество просмотров8.3K
Добрый день! Одним прекрасным днем мы обнаружили, что наш 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: ↑13 и ↓0+13
Комментарии3

Публикации

Истории

Работа

Ближайшие события