Wednesday 22 November 2017

Copy Azure web app files to slot

The majority of the out-of-the-box Sitecore ARM template is great for anything from a development to testing environment, but in production you're very likely to be using slots to test in staging and have zero-downtime releases (if you're not using slots, I'd highly recommend it).  I'll be doing a later post on some updates we can make to the Sitecore ARM templates to actually add a staging slot (amongst other enhancements), but once you have your slot you still need the base Sitecore files in there when you kick off your deployment (using CI/CD of course).

Sitecore by default is only installed to the production slot (ie. the web app itself), and installing it again in the slot will mean either pointing the installation at a second DB (which you could do), restoring the dacpacs to the live DB a second time (which you don't want to do), or creating a custom Sitecore package without the dacpac files (painful when Sitecore upgrades or changes need to be made).

I was tempted to create some DB-less Sitecore packages, but I knew there would have to be a better way.  There looks to be some options if you upgrade to Premium, but for those of us in Standard I figured there should be a way to copy the files from slot to slot without downloading them locally and uploading them again, via FTP.  After a lot of hunting and a promising upcoming solution from Microsoft, I stumbled across this azure-clone-webapps repo in Github.  This was almost exactly what I was after (massive thanks to the author), I just needed to convert it to Powershell so that I could run it as part of my ARM template deployment script.

I've included my final Powershell script here and uploaded it as a Gist, feel free to use it as-is or tweak it to suit your needs.  Since our client's Sitecore host is Rackspace they've got NewRelic installed, and I've included a skip statement to ignore the newrelic folder inside the website.  Other than that it will copy all the site files from your given web app to the given slot.  Enjoy!

Gist of SyncFilesToSlot.ps1
<#
 .SYNOPSIS
    Copies all of a web app's files to a given slot

 .DESCRIPTION
    Copies all of a web app's files to a given slot. Skips "newrelic" folder as the files are in use.
    
 .PARAMETER SubscriptionId
    The subscription id where the resources reside.

 .PARAMETER ResourceGroupName
    The resource group where the resources reside.

 .PARAMETER WebAppName
    Name of the web app containing files for the slot.
    
 .PARAMETER SlotName
    Name of the slot to fill with files from web app.
#>

param(
    [string]
    $SubscriptionId,

    [Parameter(Mandatory = $True)]
    [string]
    $ResourceGroupName,

    [Parameter(Mandatory = $false)]
    [string]
    $WebAppName,

    [Parameter(Mandatory = $True)]
    [string]
    $SlotName
)

[System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Web.Deployment")

function Get-AzureRmWebAppPublishingCredentials($ResourceGroupName, $WebAppName, $SlotName = $null){
  if ([string]::IsNullOrWhiteSpace($SlotName)) {
    $resourceType = "Microsoft.Web/sites/config";
    $resourceName = "$WebAppName/publishingcredentials";
  }
  else {
    $resourceType = "Microsoft.Web/sites/slots/config";
    $resourceName = "$WebAppName/$SlotName/publishingcredentials";
  }
  $publishingCredentials = Invoke-AzureRmResourceAction -ResourceGroupName $ResourceGroupName -ResourceType $resourceType -ResourceName $resourceName -Action list -ApiVersion 2016-08-01 -Force;
  return $publishingCredentials;
}

function GetScmUrl($ResourceGroupName, $WebAppName, $SlotName) {
    # revert to this when MS fixes https://social.msdn.microsoft.com/Forums/expression/en-US/938e59f6-6a83-4640-a423-26fe91d66cf3/scm-uri-for-web-app-deployment-slots
    #$scmUrl = $publishingCredentials.properties.scmUri
    #$scmUrlNoCreds = $scmUrl.Replace($scmUrl.Substring($scmUrl.IndexOf('$'), ($scmUrl.IndexOf('@')-$scmUrl.IndexOf('$')+1)), '') # ugh this version of substring sucks sooo much :'(
    #$apiUrl = "$scmUrl/api/command"
    # revert below
    if($SlotName) {
        $slot = Get-AzureRmWebAppSlot -ResourceGroupname $ResourceGroupName -Name $WebAppName -Slot $SlotName;
        $scmUrl = $slot.EnabledHostNames | where { $_.Contains('.scm.') };
    } else {
        $scmUrl = "$WebAppName.scm.azurewebsites.net";
    }
    # revert above
    return "https://$scmUrl";
}

function SyncWebApps($srcUrl, $srcCredentials, $destUrl, $destCredentials) {
    $syncOptions = New-Object Microsoft.Web.Deployment.DeploymentSyncOptions;
    #$syncOptions.DoNotDelete = $true;
    $appOfflineRule = $null;
    $availableRules = [Microsoft.Web.Deployment.DeploymentSyncOptions]::GetAvailableRules();
    if (!$availableRules.TryGetValue('AppOffline', [ref]$appOfflineRule)) {
        Write-Host "Failed to find AppOffline Rule";
    } else {
        $syncOptions.Rules.Add($appOfflineRule);
        Write-Host "Enabled AppOffline Rule";
    }
    
    $skipNewRelic = New-Object Microsoft.Web.Deployment.DeploymentSkipDirective -ArgumentList @("skipNewRelic", 'objectName=dirPath,absolutePath=.*\\newrelic', $true);

    $sourceBaseOptions = New-Object Microsoft.Web.Deployment.DeploymentBaseOptions;
    $sourceBaseOptions.ComputerName = $srcUrl + "/msdeploy.axd";
    $sourceBaseOptions.UserName = $srcCredentials.properties.PublishingUserName;
    $sourceBaseOptions.Password = $srcCredentials.properties.PublishingPassword;
    $sourceBaseOptions.AuthenticationType = "basic";
    $sourceBaseOptions.SkipDirectives.Add($skipNewRelic);

    $destBaseOptions = New-Object Microsoft.Web.Deployment.DeploymentBaseOptions;
    $destBaseOptions.ComputerName = $destUrl + "/msdeploy.axd";
    $destBaseOptions.UserName = $destCredentials.properties.PublishingUserName;
    $destBaseOptions.Password = $destCredentials.properties.PublishingPassword;
    $destBaseOptions.AuthenticationType = "basic";
    $destBaseOptions.SkipDirectives.Add($skipNewRelic);

    $destProviderOptions = New-Object Microsoft.Web.Deployment.DeploymentProviderOptions -ArgumentList @("contentPath");
    $destProviderOptions.Path = "/site";
    $sourceObj = [Microsoft.Web.Deployment.DeploymentManager]::CreateObject("contentPath", "/site", $sourceBaseOptions);
    $sourceObj.SyncTo($destProviderOptions, $destBaseOptions, $syncOptions); 
}

if($SubscriptionId) {
    try {
        Set-AzureRmContext -SubscriptionID $SubscriptionId;
    } catch {
     Login-AzureRmAccount;
     Set-AzureRmContext -SubscriptionID $SubscriptionId;
    }
}

$srcCreds = Get-AzureRmWebAppPublishingCredentials $ResourceGroupName $WebAppName;
$srcUrl = GetScmUrl $ResourceGroupName $WebAppName;
$destCreds = Get-AzureRmWebAppPublishingCredentials $ResourceGroupName $WebAppName $SlotName;
$destUrl = GetScmUrl $ResourceGroupName $WebAppName $SlotName;
SyncWebApps $srcUrl $srcCreds $destUrl $destCreds;

1 comment: