Pull to refresh

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

Reading time12 min
Views8.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 Можно расширять и добавлять новые задачи!
Tags:
Hubs:
Total votes 13: ↑13 and ↓0+13
Comments3

Articles