Sitecore Azure deployment using Visual Studio Cloud Service project – Part 2

In the second part of this series of blog posts I will describe what is required to deploy a Sitecore solution into the content editing environment using Visual Studio and how it can be setup.

Prerequisites

Deployment Process

The following steps will be executed when the Publish option from the Visual Studio Azure Cloud Service project is invoked:

  • Compile source code using the ContentEditing build configuration
  • Transform configuration files
    e.g. web.config, connectionStrings.config…
  • Extract archive of Sitecore 7.2 site root’s Website folder into web application temporary publishing folder
  • Publish web application to temporary publishing folder
  • Create Azure deployment package
  • Upload Azure deployment package
  • Deploy package

Since the Sitecore folder in the Website folder only contains Sitecore content editing related files that should not be changed I recommend to exclude this folder from being packaged and uploaded with each and every deployment package. The Sitecore folder alone is over 130 MB zipped and slows down the deployment process considerably. Instead the deployment setup below suggests to upload a zipped archive to the blob storage and extract that archive on role startup into the application folder on each web role.

Deployment Setup

1. Prepare and upload the Sitecore folder to the blob storage

  • Extract the archive of Sitecore 7.2 site root into a temporary folder
  • Delete all folders other than the following two under the Website folder:
    • Bin
      • Remove all libraries that are going to be part of the deployment package
        e.g. System.Web.Mvc…
    • Sitecore
  • Create zip archive with the content of the Website folder as Sitecore_7.2.zip
  • Use the Azure Storage Explorer to upload the package to the blob storage (I have created a container called Sitecore)

storage_explorer

2. Prepare Sitecore folder for packaging

  • Extract the archive of Sitecore 7.2 site root into a temporary folder
  • Create zip archive with the content of the Website folder as Sitecore.zip
  • Copy zip archive into Visual Studio root folder (or another folder of your choice)

3. Create Cloud Service project

  •  In Visual Studio add a new Azure Cloud Service project to your solution. I recommend to postfix the project name with ‘.ContentEditing’ since later we will need another Azure Cloud Service project to deploy the content delivery farm.
  • When Visual Studio prompts to select roles to be added just click ‘OK’ without adding any since we want to add an existing web application as web role in the next step
    NewAzureCloudService
  • Right click the roles folder in your Azure Cloud Service project and select ‘Add -> Web Role Project in solution…’, then select your web application from the presented list
    SelectWebRoleProject

4. Update ServiceDefinition file

Add the following inside the WebRole tag:

        <Startup>
            <Task commandLine="..\App_Start\Startup.cmd" executionContext="elevated" taskType="background">
                <Environment>
                    <Variable name="ComputeEmulatorRunning">
                        <RoleInstanceValue xpath="/RoleEnvironment/Deployment/@emulated" />
                    </Variable>
                </Environment>
            </Task>
            <Task commandLine="Microsoft.WindowsAzure.Caching\ClientPerfCountersInstaller.exe install" executionContext="elevated" taskType="simple" />
        </Startup>
        <ConfigurationSettings>
            <Setting name="Sitecore.Azure.StaticFiles.BlobConnectionString" />
            <Setting name="Sitecore.Azure.StaticFiles.BlobUri" />
            <Setting name="Microsoft.WindowsAzure.Plugins.Caching.ClientDiagnosticLevel" />
        </ConfigurationSettings>
        <LocalResources>
            <LocalStorage name="DiagnosticStore" cleanOnRoleRecycle="false" sizeInMB="20000" />
        </LocalResources>
        <Imports>
            <Import moduleName="Caching" />
        </Imports>
        <Runtime executionContext="elevated">
        </Runtime>

5. Update ServiceConfiguration files

Add the following configuration settings to the two ServiceConfiguration files (local, cloud):

            <Setting name="Sitecore.Azure.StaticFiles.BlobConnectionString" value="DefaultEndpointsProtocol=https;AccountName={your-storage-account};AccountKey={your-storage-account-key}" />
            <Setting name="Sitecore.Azure.StaticFiles.BlobUri" value="https://{your-storage-account-key}.blob.core.windows.net/sitecore/sitecore_7.2.zip" />
            <Setting name="Microsoft.WindowsAzure.Plugins.Caching.ClientDiagnosticLevel" value="1" />
            <Setting name="Microsoft.WindowsAzure.Plugins.Caching.NamedCaches" value="{&quot;caches&quot;:[{&quot;name&quot;:&quot;default&quot;,&quot;policy&quot;:{&quot;eviction&quot;:{&quot;type&quot;:0},&quot;expiration&quot;:{&quot;defaultTTL&quot;:30,&quot;isExpirable&quot;:true,&quot;type&quot;:2},&quot;serverNotification&quot;:{&quot;isEnabled&quot;:false}},&quot;secondaries&quot;:1}]}" />
            <Setting name="Microsoft.WindowsAzure.Plugins.Caching.DiagnosticLevel" value="1" />
            <Setting name="Microsoft.WindowsAzure.Plugins.Caching.CacheSizePercentage" value="20" />
            <Setting name="Microsoft.WindowsAzure.Plugins.Caching.ConfigStoreConnectionString" value="DefaultEndpointsProtocol=https;AccountName={your-storage-account};AccountKey={your-storage-account-key}" />

Make sure you update the storage account information and correct the blob storage uri if required.

6. Create startup.cmd file

Per default Web roles in Azure use the Network Service account to run the application pool in IIS. So this account will need permissions to read and write some of the Sitecore folders. Under step 4 we configured the web roles to run a startup.cmd file as a startup task which allows us to modify the folder permissions as well as some other app pool settings:

IF "%ComputeEmulatorRunning%" == "true" (
:: do not remove the echo string or else you will get an exception
ECHO  ComputeEmulatorRunning environment
) ELSE (
ECHO  Azure environment
	CACLS ../../approot /t /e /p "Network Service":c
	CACLS ../../approot/temp /t /e /p "Network Service":c
	CACLS ../../approot/layouts /t /e /p "Network Service":c
	CACLS ../../approot/App_Config/ConnectionStrings.config /t /e /p "Network Service":c
	CACLS ../../approot/App_Config/Include/publishTargets.config /t /e /p "Network Service":c
	CACLS ../../sitesroot/0/temp /t /e /p "Network Service":c
	CACLS ../../sitesroot/0/layouts /t /e /p "Network Service":c
	CACLS ../../sitesroot/0/App_Config/ConnectionStrings.config /t /e /p "Network Service":c
	CACLS ../../sitesroot/0/App_Config/Include/publishTargets.config /t /e /p "Network Service":c

	@REM Set IIS to automatically start AppPools
	%windir%\system32\inetsrv\appcmd.exe set config -section:applicationPools -applicationPoolDefaults.startMode:AlwaysRunning /commit:apphost

	@REM Set IIS to not shut down idle AppPools
	%windir%\system32\inetsrv\appcmd set config -section:applicationPools -applicationPoolDefaults.processModel.idleTimeout:00:00:00 /commit:apphost

	@REM remove IIS response headers
	%windir%\system32\inetsrv\appcmd.exe set config /section:httpProtocol /-customHeaders.[name='X-Powered-By']
)

EXIT /b 0

This file need to be added to the App_Start folder in the Visual Studio solution with the Build Action set to Content.

7. Create build configuration

In order to be able to leverage config transformation to customize settings for different deployment scenarios (i.e. CE/CD) we add a new build configuration called ContentEditing to the solution using the Release configuration as the basis.

8. Add web.config transformation

Add the transformation file for the web.config file for the CE environment (Web.ContentEditing.config) and copy the following into it:

<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">

  <configSections>
    <section name="dataCacheClients"
             type="Microsoft.ApplicationServer.Caching.DataCacheClientsSection, Microsoft.ApplicationServer.Caching.Core"
             allowLocation="true"
             allowDefinition="Everywhere"
             xdt:Transform="Insert" />
    <section name="cacheDiagnostics"
             type="Microsoft.ApplicationServer.Caching.AzureCommon.DiagnosticsConfigurationSection, Microsoft.ApplicationServer.Caching.AzureCommon"
             allowLocation="true"
             allowDefinition="Everywhere"
             xdt:Transform="Insert" />
  </configSections>

  <system.web>
    <customErrors mode="RemoteOnly" xdt:Transform="Replace" />
    <sessionState mode="Custom" customProvider="DistributedCacheSessionStateStoreProvider" xdt:Transform="Replace">
      <providers>
        <add name="DistributedCacheSessionStateStoreProvider"
             type="Microsoft.Web.DistributedCache.DistributedCacheSessionStateStoreProvider, Microsoft.Web.DistributedCache"
             cacheName="default"
             useBlobMode="true"
             dataCacheClientName="default" />
      </providers>
    </sessionState>
    <trace enabled="true" xdt:Transform="SetAttributes(enabled)"/>
  </system.web>

  <system.diagnostics xdt:Transform="Remove" />
  <system.diagnostics xdt:Transform="InsertAfter(/configuration/system.web)">
    <trace autoflush="true">
      <listeners>
        <add type="Microsoft.WindowsAzure.Diagnostics.DiagnosticMonitorTraceListener, Microsoft.WindowsAzure.Diagnostics"
             name="AzureDiagnostics" />
      </listeners>
    </trace>
  </system.diagnostics>

  <dataCacheClients xdt:Transform="Insert">
    <dataCacheClient name="default" isCompressionEnabled="false">
      <autoDiscover isEnabled="true" identifier="SCDEPL01" />
      <localCache isEnabled="false" sync="TimeoutBased" objectCount="100000" ttlValue="300" />
    </dataCacheClient>
  </dataCacheClients>
  <cacheDiagnostics xdt:Transform="Insert">
    <crashDump dumpLevel="Off" dumpStorageQuotaInMB="100" scheduledTransferPeriodInMinutes="5" />
  </cacheDiagnostics>

  <sitecore database="SqlServer">
    <sc.variable name="dataFolder" value="/App_Data" xdt:Transform="SetAttributes(value)" xdt:Locator="Match(name)" />
    <settings>
      <setting name="LicenseFile"
               value="/App_Data/license.xml"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="PageStateStore"
               value="Sitecore.Web.UI.DatabasePageStateStore, Sitecore.Kernel"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="ViewStateStore"
               value="Sitecore.Data.DataProviders.DatabaseViewStateStore, Sitecore.Kernel"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="Caching.CacheViewState"
               value="false"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="EnableEventQueues"
               value="true"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="Caching.SecurityCacheExpiration"
               value="00:20:00"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="Media.DisableFileMedia"
               value="false"
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
      <setting name="InstanceName"
               value=""
               xdt:Transform="SetAttributes(value)"
               xdt:Locator="Match(name)" />
    </settings>
    <retryer disabled="false" xdt:Transform="SetAttributes(disabled)" />
  </sitecore>

  <log4net xdt:Transform="Replace">
    <appender name="AzureAppender" type="Sitecore.Azure.ServiceRuntime.Diagnostics.TraceAppender, Sitecore.Azure.ServiceRuntime">
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%4t %d %-5p %m%n" />
      </layout>
    </appender>
    <appender name="WebDAVAzureAppender" type="Sitecore.Azure.ServiceRuntime.Diagnostics.TraceAppender, Sitecore.Azure.ServiceRuntime">
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%4t %d %-5p %m%n" />
      </layout>
    </appender>
    <appender name="LogFileAppender" type="log4net.Appender.SitecoreLogFileAppender, Sitecore.Logging">
      <file value="$(dataFolder)/logs/log.{{date}}.txt" />
      <appendToFile value="true" />
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%4t %d{{ABSOLUTE}} %-5p %m%n" />
      </layout>
    </appender>
    <appender name="WebDAVLogFileAppender" type="log4net.Appender.SitecoreLogFileAppender, Sitecore.Logging">
      <file value="$(dataFolder)/logs/WebDAV.log.{{date}}.txt" />
      <appendToFile value="true" />
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%4t %d{{ABSOLUTE}} %-5p %m%n" />
      </layout>
    </appender>
    <root>
      <priority value="INFO" />
      <appender-ref ref="AzureAppender" />
      <appender-ref ref="LogFileAppender" />
    </root>
    <logger name="Sitecore.Diagnostics.WebDAV" additivity="false">
      <level value="INFO" />
      <appender-ref ref="WebDAVAzureAppender" />
      <appender-ref ref="WebDAVLogFileAppender" />
    </logger>
  </log4net>

</configuration>

9. Add ConnectionStrings transformation file

ConnectionStringsConfig

As next step we add the connection strings transformation file for the CE environment with the following content:

<connectionStrings  xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
  <add name="core"
       connectionString="Data Source=tcp:{your-database-name}.database.windows.net;Initial Catalog=Sitecore_Core;Integrated Security=False;User ID=sitecore@{your-database-name};Password={your-password};Encrypt=True"
       xdt:Transform="Replace"
       xdt:Locator="Match(name)"/>
  <add name="master"
       connectionString="Data Source=tcp:{your-database-name}.database.windows.net;Initial Catalog=Sitecore_Master;Integrated Security=False;User ID=sitecore@{your-database-name};Password={your-password};Encrypt=True"
       xdt:Transform="Replace"
       xdt:Locator="Match(name)"/>
  <add name="web"
       connectionString="Data Source=tcp:{your-database-name}.database.windows.net;Initial Catalog=Sitecore_Web;Integrated Security=False;User ID=sitecore@{your-database-name};Password={your-password};Encrypt=True"
       xdt:Transform="Replace"
       xdt:Locator="Match(name)"/>
</connectionStrings>

Make sure you replace the place holders.

10. Install nuget packages

The following nuget packages are required for the web application:

11. Add class as the role entry point

As described in the process section we need to download the Sitecore archive from the blob storage and extract it into the application root on each web role. This can be easily done implementing a class that represents the role entry point (WebRole.cs in the web application root folder):

    public class WebRole : RoleEntryPoint
    {
        private const string ArchiveBlobConnectionSettingsKey = "Sitecore.Azure.StaticFiles.BlobConnectionString";
        private const string ArchiveBlobUriSettingsKey = "Sitecore.Azure.StaticFiles.BlobUri";
        private const string TempPath = @"C:\Temp";

        private string SitecoreArchiveName
        {
            get
            {
                return this.SitecoreArchiveBlobUri.PrimaryUri.Segments.Last();
            }
        }

        private string SitecoreArchiveBlobConnectionString
        {
            get { return CloudConfigurationManager.GetSetting(ArchiveBlobConnectionSettingsKey); }
        }

        private StorageUri SitecoreArchiveBlobUri
        {
            get
            {
                StorageUri uri =
                    new StorageUri(new Uri(CloudConfigurationManager.GetSetting(ArchiveBlobUriSettingsKey)));
                return uri;
            }
        }

        public override bool OnStart()
        {
            DirectoryInfo archiveFolder = new DirectoryInfo(TempPath);
            if (!archiveFolder.Exists)
            {
                archiveFolder.Create();
            }

            string archivePath = Path.Combine(TempPath, SitecoreArchiveName);
            FileInfo sitecoreArchive = new FileInfo(archivePath);
            if (!sitecoreArchive.Exists)
            {
                // Download sitecore folder archive from blob
                using (MemoryStream ms = this.DownloadArchive())
                using (FileStream fs = sitecoreArchive.OpenWrite())
                {
                    // Save archive on drive
                    byte[] content = ms.ToArray();
                    fs.Write(content, 0, content.Length);
                }
            }

            DirectoryInfo appDirectory = this.GetAppRoot();

            // Extract archive content
            ZipFile.ExtractToDirectory(archivePath,
                Path.Combine(appDirectory.Root.FullName, "approot"));
            ZipFile.ExtractToDirectory(archivePath,
                Path.Combine(appDirectory.Root.FullName, "sitesroot", "0"));

            return true;
        }

        private MemoryStream DownloadArchive()
        {
            CloudStorageAccount storageAccount = CloudStorageAccount.Parse(this.SitecoreArchiveBlobConnectionString);
            CloudBlobClient cloudBlobClient = storageAccount.CreateCloudBlobClient();
            ICloudBlob blob = cloudBlobClient.GetBlobReferenceFromServer(this.SitecoreArchiveBlobUri);
            MemoryStream stream = new MemoryStream();
            blob.DownloadToStream(stream);
            return stream;
        }

        private DirectoryInfo GetAppRoot()
        {
            DirectoryInfo appDirectory = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
            return appDirectory.Root;
        }
    }

12. Copy Sitecore license.xml file

Add the Sitecore license.xml file to the App_Data folder in the web application with the Build Action set to Content.

13. Modify the Azure Cloud Service project file

As a last step we need to modify the Cloud Service project file adding some MSBuild magics just before the closing Project tag so that the connection string transformation is applied as well as the Sitecore.zip archive extracted into the temporary publishing folder (make sure you update the publishing directory and the host name base path).

  <Import Project="C:\Program Files\MSBuild\ExtensionPack\4.0\MSBuild.ExtensionPack.tasks" />
  <UsingTask TaskName="TransformXml" AssemblyFile="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v10.0\Web\Microsoft.Web.Publishing.Tasks.dll" />
  <PropertyGroup>
    <HostNameBasePath>$(ProjectDir)obj\$(Configuration)\SCDEPL01</HostNameBasePath>
  </PropertyGroup>
  <PropertyGroup>
    <PublishingDirectory>C:\Temp\Sitecore Azure\Deployment\SCDEPL01</PublishingDirectory>
  </PropertyGroup>
  <Target Name="AfterBuildCustomization" AfterTargets="Build" Condition="'$(IsPublishing)' == 'True'">
    <Message Text="Host name base path: '$(HostNameBasePath)'" Importance="high" />
    <CallTarget Targets="TransformConfigurations" />
    <CallTarget Targets="CleanupTransformConfigurations" />
    <CallTarget Targets="CopySitecoreToPublishFolder" />
  </Target>
  <Target Name="CopySitecoreToPublishFolder" Condition="Exists('$(PublishingDirectory)\ExtractedBuild')">
    <Message Text="Extracting Sitecore 7.2..." Importance="high" />
    <MakeDir Directories="$(PublishingDirectory)\ExtractedBuild" />
    <MSBuild.ExtensionPack.Compression.Zip TaskAction="Extract" ExtractPath="$(PublishingDirectory)" ZipFileName="$(SolutionDir)Sitecore.zip" Condition="Exists('$(PublishingDirectory)\ExtractedBuild')" />
    <!-- This will be consumed from storage -->
    <Message Text="Removing Sitecore folder..." Importance="high" Condition="Exists('C:\Temp\Sitecore Azure\Deployment\SCDEPL01\ExtractedBuild')" />
    <MSBuild.ExtensionPack.FileSystem.Folder TaskAction="RemoveContent" Path="$(PublishingDirectory)\Sitecore" />
  </Target>
  <!-- Web config transformation -->
  <Target Name="TransformConfigurations">
    <PropertyGroup>
      <OriginalConnectionStringsConfig>$(HostNameBasePath)\App_Config\ConnectionStrings.config</OriginalConnectionStringsConfig>
      <TransformationConnectionStringsConfig>$(HostNameBasePath)\App_Config\ConnectionStrings.$(Configuration).config</TransformationConnectionStringsConfig>
    </PropertyGroup>
    <MSBuild Projects="$(MSBuildProjectFullPath)" Targets="TransformConfiguration" Properties="SourceFile=$(OriginalConnectionStringsConfig);TransformFile=$(TransformationConnectionStringsConfig)" />
  </Target>
  <Target Name="TransformConfiguration" Condition="Exists('$(SourceFile)') AND Exists('$(TransformFile)') ">
    <Message Text="Transforming configuration file '$(SourceFile)' using '$(TransformFile)' as transformation." Importance="high" />
    <PropertyGroup>
      <TempFile>$(SourceFile).transformtemp</TempFile>
    </PropertyGroup>
    <!-- Copy source file into temp file for read -->
    <Copy SourceFiles="$(SourceFile)" DestinationFiles="$(TempFile)" />
    <!-- Transform file -->
    <TransformXml Source="$(TempFile)" Transform="$(TransformFile)" Destination="$(SourceFile)" />
  </Target>
  <Target Name="CleanupTransformConfigurations">
    <Message Text="Cleanup transformation temp files" Importance="high" />
    <PropertyGroup>
      <TempFiles>$(HostNameBasePath)\**\*.transformtemp</TempFiles>
    </PropertyGroup>
    <Delete Files="$(TempFiles)" ContinueOnError="true" />
  </Target>

Also the OutputPath needs to be updated to reflect the following:

  <PropertyGroup Condition=" '$(Configuration)' == 'ContentEditing' ">
    <OutputPath>$(PublishingDirectory)</OutputPath>
  </PropertyGroup>

Deploy

Now we are ready to deploy using the Azure Cloud Service publish functionality.

In the next blog post I will talk about how some of the settings can be optimized as well as some other pitfalls and solutions to overcome them.

Leave a Reply

Your email address will not be published. Required fields are marked *