1

I've developed an WebApp (API) which is hosted in Azure & uses VSTS for CI/CD/version control.

I'd like to give the customer (owner) of this API the ability to update various configuration/data files under wwwroot, however I'd like those files to be under version control (source of truth - a separate repository to the API source code). Creating/Updating/Deleting one of those files in the repository should cause the file to be uploaded/removed in the WebApp (in a folder under wwwroot).

Modifying (creating/deleting) one of these files should not trigger a full redeployment (of the WebApp)

How can I achieve this? So far I've thought about a VSTS release pipeline for a GIT artefact however I couldn't see a low-friction way to make the changes in the Azure webapp (KUDU API seems a bit complex and heavy-handed)

**EDIT: ** Sample PowerShell script to sync Configuration files in WebApp w/ a build-artefact (PUT/DELETE only invoked when necessary).

VSTS Configuration

# The idea behind this script is to synchronize the configuration files on the server with what's in the repo, only updating files where necessary

param (
    [string]$resourceGroupName = "XXX",
    [Parameter(Mandatory=$true)][string]$webAppName,
    [Parameter(Mandatory=$true)][string]$latestConfigFilesPath
)

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 2015-08-01 -Force

    return $publishingCredentials
}

function Get-KuduApiAuthorisationHeaderValue($resourceGroupName, $webAppName, $slotName = $null) {
    $publishingCredentials = Get-AzureRmWebAppPublishingCredentials $resourceGroupName $webAppName $slotName
    return ("Basic {0}" -f [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $publishingCredentials.Properties.PublishingUserName, $publishingCredentials.Properties.PublishingPassword))))
}

function Get-KuduInode($kuduHref) {
    return Invoke-RestMethod -Uri $kuduHref `
                             -Headers @{"Authorization"=$kuduApiAuthorisationToken;"If-Match"="*"} `
                             -Method GET `
                             -ContentType "application/json"
}

function Get-AllFilesUnderKuduHref($kuduHref) {
    $result = @()
    $inodes = (Get-KuduInode $kuduHref)
    Foreach ($inode in $inodes) {
        if ($inode.mime -eq "inode/directory") {
            $result += (Get-AllFilesUnderKuduHref $inode.href)
        } else {
            $result += $inode.href
        }
    }

    return $result
}

function Get-LocalPathForUri([System.Uri]$uri) {
    $latestConfigFilesUri = [System.Uri]$latestConfigFilesPath
    $localFileUri = [System.Uri]::new($latestConfigFilesUri, $uri)
    return $localFileUri.LocalPath
}

function Get-RemoteUri([System.Uri]$uri) {
    return  [System.Uri]::new($configurationHref, $uri)
}

function Files-Identical($uri) {
    $localFilePath = Get-LocalPathForUri $uri
    $localFileHash = Get-FileHash $localFilePath -Algorithm MD5

    # Download the remote file so that we can calculate the hash. It doesn't matter that it doesn't get cleaned up, this is running on a temporary build server anyway.
    $temporaryFilePath = "downloded_kudu_file"
    $remoteFileUri = [System.Uri]::new($configurationHref, $uri)
    Invoke-RestMethod -Uri $remoteFileUri `
                      -Headers @{"Authorization"=$kuduApiAuthorisationToken;"If-Match"="*"} `
                      -Method GET `
                      -OutFile $temporaryFilePath `
                      -ContentType "multipart/form-data"

    $remoteFileHash = Get-FileHash $temporaryFilePath -Algorithm MD5

    return $remoteFileHash.Hash -eq $localFileHash.Hash
}

function CalculateRelativePath([System.Uri]$needle, [System.Uri]$haystack) {
    return $haystack.MakeRelativeUri($needle).ToString();
}

function Put-File([System.Uri]$uri) {
    Write-Host "Uploading file $uri"
    $localFilePath = Get-LocalPathForUri $uri
    $remoteFileUri = Get-RemoteUri $uri
    Invoke-RestMethod -Uri $remoteFileUri `
                      -Headers @{"Authorization"=$kuduApiAuthorisationToken;"If-Match"="*"} `
                      -Method PUT `
                      -InFile $localFilePath `
                      -ContentType "multipart/form-data"
}

function Delete-File([System.Uri]$uri) {
    Write-Host "Deleting file $uri"
    $remoteFileUri = Get-RemoteUri $uri
    Invoke-RestMethod -Uri $remoteFileUri `
                      -Headers @{"Authorization"=$kuduApiAuthorisationToken;"If-Match"="*"} `
                      -Method DELETE `
}

# Script begins here

$configurationHref = [System.Uri]"https://$webAppName.scm.azurewebsites.net/api/vfs/site/wwwroot/Configuration/"
$kuduApiAuthorisationToken = Get-KuduApiAuthorisationHeaderValue -resourceGroupName $resourceGroupName -webAppName $webAppName
$filenamesOnServer = Get-AllFilesUnderKuduHref $configurationHref $kuduApiAuthorisationToken | % { $configurationHref.MakeRelativeUri($_).OriginalString }

Write-Host "Files currently on server" $filenamesOnServer

$filesCurrentlyInRepo = Get-ChildItem -Path $latestConfigFilesPath -Recurse -File
$filenamesInRepo = $filesCurrentlyInRepo | Select-Object -ExpandProperty FullName | % { CalculateRelativePath $_ $latestConfigFilesPath}

Write-Host "Files currently in repo" $filenamesInRepo

$intersection = $filenamesOnServer | ?{$filenamesInRepo -contains $_}
Write-Host "Intersection: " $intersection

$onlyOnServer = $filenamesOnServer | ?{-Not($filenamesInRepo -contains $_)}
$onlyInRepo = $filenamesInRepo | ?{-Not($filenamesOnServer -contains $_)}
Write-Host "Only on server" $onlyOnServer
Write-Host "Only in repo" $onlyInRepo
Write-Host

Foreach ($uri in $onlyInRepo) {
    Put-File $uri
}

Foreach ($uri in $onlyOnServer) {
    Delete-File $uri
}

Foreach ($uri in $intersection) {
    if (-Not (Files-Identical $uri)) {
        Write-Host "Configuration file $uri needs updating"
        Put-File $uri
    } else {
        Write-Host "Configuration file $uri is identical, skipping"
    }
}

Write-Host "Sync complete"
eddiewould
  • 1,555
  • 16
  • 36

1 Answers1

1

With Azure App Service deploy task, you can upload files to app service, but can’t delete files (Uncheck Publish using Web Deploy optioin and specify the folder path in Package or folder input box).

So, the better way is using Kudu API to delete/upload files during build/release.

There is the thread and a blog about using Kudu API:

How to access Kudu in Azure using power shell script

Interacting with Azure Web Apps Virtual File System using PowerShell and the Kudu API

starian chen-MSFT
  • 33,174
  • 2
  • 29
  • 53
  • Thanks for the response. What about FTP or AzurePSDrive? Are they options? – eddiewould Mar 12 '18 at 05:57
  • @eddiewould FTP is ok too, not familiar with AzurePSDrive, you can try and check the result. – starian chen-MSFT Mar 12 '18 at 06:00
  • It turns out that accessing the Kudu REST API from VSTS is quite straight-forward - if you use an Azure PowerShell task, it automatically authenticates you - no manual fiddling with credentials is required. – eddiewould Mar 12 '18 at 13:37
  • @eddiewould Can you share the code of how to authenticate through Azure PowerShell task? – starian chen-MSFT Mar 13 '18 at 01:14
  • I used the PowerShell scripts from the blog page you linked to (Interacting with Azure Web Apps Virtual File System using PowerShell and the Kudu API). Because the Azure PowerShell task is linked to the subscription/web app it was able to get the provisioning credentials automatically. – eddiewould Mar 13 '18 at 01:24
  • @eddiewould It doesn't work for me, can you share your code? – starian chen-MSFT Mar 13 '18 at 02:07