Get Key Value Pairs from Azure App Configuration with PowerShell - azure

There aren't any cmdlets in the AZ modules to get key-value pairs from Azure app configuration. Are there any other options besides AZ CLI? Is there a way to interact with service from PowerShell?

There is no PowerShell support to get key-values from App Configuration at this point. Calling Azure CLI in PowerShell is the way to go. The Az.AppConfiguration module only supports management operations (eg., creating an App Configuration store, etc).
The request is tracked in GitHub https://github.com/Azure/AppConfiguration/issues/267.

Hey I'm a bit late to the party but I was searching for the same thing and I ended up created my own solution because the given answer only covers management tasks and not data retrieval.
check out this repo
It can fetch key values and resolve secrets.
Also it can parse referenced keys in the values.
it uses az cli in the background, so you need that installed first.

Azure CLI has a command az appconfig kv list that can be used to list all key-values from App Configuration. More details on the usage and examples can be found here.

Here's my solution using App Configuration connection string for authentication. You're interested in .items property from the returned object. In case of paging, there'll be a next link there as well. For details, see the docs at https://learn.microsoft.com/azure/azure-app-configuration/rest-api-key-value
Keys can contain slash characters, so they need to be URL-encoded using [System.Net.WebUtility]::UrlEncode($Key) when building a $RequestUri.
function Invoke-AppConfigRequest {
param(
[Parameter(Mandatory = $true)] [string] $ConnectionString, # 'Endpoint=...;Id=...;Secret=...'
[Parameter(Mandatory = $true)] [string] $RequestUri, # '/kv?api-version=1.0&key=some-url-encoded-key&label=*'
[Parameter(Mandatory = $false)] [string] $Method = 'GET', # 'GET', 'POST'
[Parameter(Mandatory = $false)] [object] $Body = $null # Accepts [object] to avoid implicit conversion of $null to empty string
)
$ConnectionStringValues = $ConnectionString -split ';' | ForEach-Object { $Tokens = $_ -split '=',2; #{ Key = $Tokens[0]; Value = $Tokens[1] } }
$Endpoint = ($ConnectionStringValues | Where-Object { $_.Key -eq 'Endpoint' }).Value
$Credential = ($ConnectionStringValues | Where-Object { $_.Key -eq 'Id' }).Value
$Secret = ($ConnectionStringValues | Where-Object { $_.Key -eq 'Secret' }).Value
if ([string]::IsNullOrWhitespace($Endpoint) -or [string]::IsNullOrWhitespace($Credential) -or [string]::IsNullOrWhitespace($Secret)) {
throw "Invalid App Configuration connection string"
}
$UtcNow = (Get-Date).ToUniversalTime().ToString('ddd, d MMM yyyy HH:mm:ss \G\M\T')
$EndpointHost = $Endpoint -replace '^https?://(.*)$','$1'
$ContentHash = [Convert]::ToBase64String(
[System.Security.Cryptography.HashAlgorithm]::Create('sha256').ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($(if ($Body -ne $null) { "$Body" } else { '' }))
)
)
$StringToSign = "$Method`n$RequestUri`n$UtcNow;$EndpointHost;$ContentHash"
$HmacSha256 = New-Object System.Security.Cryptography.HMACSHA256
$HmacSha256.Key = [Convert]::FromBase64String($Secret)
$Signature = [Convert]::ToBase64String(
$HmacSha256.ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($StringToSign)
)
)
$Headers = #{
'Host' = $EndpointHost;
'x-ms-date' = $UtcNow;
'x-ms-content-sha256' = $ContentHash;
'Accept' = 'application/vnd.microsoft.appconfig.kv+json, application/json, application/problem+json';
'Authorization' = "HMAC-SHA256 Credential=$Credential&SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=$Signature";
}
$Uri = "$Endpoint$RequestUri"
$Response = Invoke-WebRequest -Method $Method -Uri $Uri -Headers $Headers -Body $Body
if ($Response.StatusCode -eq 200) {
[System.Text.Encoding]::UTF8.GetString($Response.Content) | ConvertFrom-Json
}
}
Example invocation:
function Get-AppConfigKeyValue {
param(
[Parameter(Mandatory = $true)] [string] $ConnectionString,
[Parameter(Mandatory = $true)] [string] $Key,
[Parameter(Mandatory = $false)] [string] $Label = ''
)
$UrlEncodedKey = [System.Net.WebUtility]::UrlEncode($Key)
$UrlEncodedLabel = [System.Net.WebUtility]::UrlEncode($Label)
# https://learn.microsoft.com/azure/azure-app-configuration/rest-api-key-value
$Method = 'GET'
$ApiVersion = '1.0'
$RequestUri = '/kv'
#if (![string]::IsNullOrWhitespace($UrlEncodedKey)) {
# $RequestUri += "/$UrlEncodedKey" # Strict key/label matching, no support for wildcards like *.
#}
$RequestUri += "?api-version=$ApiVersion"
if (![string]::IsNullOrWhitespace($UrlEncodedKey)) {
$RequestUri += "&key=$UrlEncodedKey" # Key filter, accepts "*" to match all keys.
}
if (![string]::IsNullOrWhitespace($UrlEncodedLabel)) {
$RequestUri += "&label=$UrlEncodedLabel" # Label filter, accepts "*" to match all labels.
} else {
$RequestUri += "&label=%00" # Matches KV without a label.
}
(Invoke-AppConfigRequest -ConnectionString $ConnectionString -RequestUri $RequestUri).items
}

Take a look at the Az.AppConfiguration module. I can't vouch for the quality of it or how complete it is, but it's out there. The Az module doesn't look like it is included as a dependency.
You can download is from the PowerShell Gallery with:
Install-Module -Name Az.AppConfiguration -Repository PSGallery -Scope CurrentUser

Related

How to make REST calls to Azure Pipelines from c#

From c# code, I want to call Azure Devops Pipeline Url and get the job/build ids for that url. And then get the result if the job/build succeds/fail.
How to make REST call from c# code my Azure pipelines (I have the pipeline url)?
You can use this sample through HttpClient. Additionally, you can use Microsoft.TeamFoundationServer.Client and BuildHttpClient.GetBuildsAsync: the example is here.
There are 2 ways to do authentication for Azure DevOps API.
Private PAT
Environment Variable: System.AccessToken
For PAT you can review Shamrai Aleksander's code, if you want to call Azure DevOps API in your pipeline, you can conside use System.AccessToken.
Here is Powershell Script for your reference, the field of Authorization is different.
function Http-Request {
param (
[Parameter(Mandatory=$true)]
[string] $Url,
[Parameter(Mandatory=$false)]
[string] $SystemToken
)
if([string]::IsNullOrEmpty($SystemToken)) {
$pat = 'YOUR_PAT'
$username = 'YOUR_USERNAME'
$auth = '{0}:{1}' -f $username, $pat
$bytes = [System.Text.Encoding]::UTF8.GetBytes($auth)
$encoded = [Convert]::ToBase64String($bytes)
$auth = 'Basic ' + $encoded
} else {
$auth = 'Bearer ' + $SystemToken
}
$headers = #{
'Content-Type' = 'application/json'
'Authorization' = $auth
}
Write-Host -ForegroundColor Green 'Http Request:'$Url
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12
$response = Invoke-RestMethod -Uri $Url -Method Get -Headers $headers
Write-Host ($reponse | Format-List | Out-String)
return $response
}

Assigning Intune scope tags to Azure Azure AD group

I cannot figure this on out. I need a way to assign Endpoint Manager's Scope tags to an Azure AD group using Microsoft Graph and PowerShell.
Under the portal this is done under Endpoint Manager\Tenant Administration\Roles\Scope (Tags). Then clicking on the Tag and tgo to assignments and browse to Azure AD group.
Since its under Roles, I'm assuming it falls under the roleAssignment or roleScopeTag resource types?
I have thoroughly read all documentation for the REST api and I have also attempted to do this via Microsoft.Graph.Intune modules but still cannot find a suitable cmdlet that will do this. Am I missing something?
Here is the current code I have built following this document
FIRST let's assume I have the correct tag id and azure ad group object id.
$ScopeTagId = 2
$TargetGroupIds = #()
$TargetGroupIds += '687c08f1-e78f-4506-b4a6-dfe35a05d138'
$graphApiVersion = "beta"
$Resource = "deviceManagement/roleScopeTags"
$object = New-Object -TypeName PSObject
$object | Add-Member -MemberType NoteProperty -Name 'assignments' -Value #($TargetGroupIds)
$JSON = $object | ConvertTo-Json
$uri = "https://graph.microsoft.com/$graphApiVersion/$($Resource)/$ScopeTagId/assign"
Invoke-RestMethod -Method Post -Uri $uri -Headers $global:authToken -Body $JSON
No success. I then thought that since the create roleScopeTag API doesn't have an assignment property in the request body, this must be done using the update method, but that doesn't have it in there either. The only one I read was to use the assign action and in the documentation example it shows the roleScopeTagAutoAssignment URI, so I went down that rabbit hole:
$ScopeTagId = 2
$TargetGroupIds = #()
$TargetGroupIds += '687c08f1-e78f-4506-b4a6-dfe35a05d138'
$graphApiVersion = "beta"
$Resource = "deviceManagement/roleScopeTags"
$AutoTagObject = #()
foreach ($TargetGroupId in $TargetGroupIds) {
#Build custom object for assignment
$AssignmentProperties = "" | Select '#odata.type',id,target
$AssignmentProperties.'#odata.type' = '#microsoft.graph.roleScopeTagAutoAssignment'
$AssignmentProperties.id = $TargetGroupId
#Build custom object for target
$targetProperties = "" | Select "#odata.type",deviceAndAppManagementAssignmentFilterId,deviceAndAppManagementAssignmentFilterType
$targetProperties."#odata.type" = "microsoft.graph.deviceAndAppManagementAssignmentTarget"
$targetProperties.deviceAndAppManagementAssignmentFilterId = $TargetGroupId
$targetProperties.deviceAndAppManagementAssignmentFilterType = 'include'
#add target object to assignment
$AssignmentProperties.target = $targetProperties
$AutoTagObject += $AssignmentProperties
}
#build body object
$object = New-Object -TypeName PSObject
$object | Add-Member -MemberType NoteProperty -Name 'assignments' -Value #($AutoTagObject)
$JSON = $object | ConvertTo-Json
$uri = "https://graph.microsoft.com/$graphApiVersion/$($Resource)/$ScopeTagId/assign"
Invoke-RestMethod -Method Post -Uri $uri -Headers $global:authToken -Body $JSON
The error I get back is something like: "Property target in payload has a value that does not match schema.","innerError":{"date":"2022-01-27T16:28:34","request-id":"75626c4d-f09b-438e-8b0f-0b2928ac23ce","client-request-id":"75626c4d-f09b-438e-8b0f-0b2928ac23ce" which I assume is the odata.type object i'm calling "microsoft.graph.deviceAndAppManagementAssignmentTarget"
There is a second post method in the documentations via roledefinition URI which seems like an unnecessary step, but I tried that too with no success.
I do not know if any of this is correct. I understand the API calls for others pretty well and I have been able to successfully add Tags for Custom Roles and their assignments using graph; I just can't seem to find the right combination of URI and JSON body for scope tags themselves...if it even exists. :(
Any ideas, please share some code snippets if you can. THANKS!
I found the correct URI and request body
If can be generated like this:
$ScopeTagId = 2
$TargetGroupIds = #()
$TargetGroupIds += '687c08f1-e78f-4506-b4a6-dfe35a05d138'
$graphApiVersion = "beta"
$Resource = "deviceManagement/roleScopeTags"
$AutoTagObject = #()
#TEST $TargetGroupId = $TargetGroupIds[0]
foreach ($TargetGroupId in $TargetGroupIds) {
#Build custom object for assignment
$AssignmentProperties = "" | Select id,target
$AssignmentProperties.id = ($TargetGroupId + '_' + $ScopeTagId)
#Build custom object for target
$targetProperties = "" | Select "#odata.type",deviceAndAppManagementAssignmentFilterId,deviceAndAppManagementAssignmentFilterType,groupId
$targetProperties."#odata.type" = "microsoft.graph.groupAssignmentTarget"
$targetProperties.deviceAndAppManagementAssignmentFilterId = $null
$targetProperties.deviceAndAppManagementAssignmentFilterType = 'none'
$targetProperties.groupId = $TargetGroupId
#add target object to assignment
$AssignmentProperties.target = $targetProperties
$AutoTagObject += $AssignmentProperties
}
#build body object
$object = New-Object -TypeName PSObject
$object | Add-Member -MemberType NoteProperty -Name 'assignments' -Value #($AutoTagObject)
$JSON = $object | ConvertTo-Json -Depth 10
$uri = "https://graph.microsoft.com/$graphApiVersion/$($Resource)/$ScopeTagId/assign"
Invoke-RestMethod -Method Post -Uri $uri -Headers $global:authToken -Body $JSON -ErrorAction Stop
The Json request body would look like this:
{
"assignments": [
{
"id": "b25c80e3-78cc-4b7c-888e-fc50dcc6b582_2",
"target": {
"#odata.type": "microsoft.graph.groupAssignmentTarget",
"deviceAndAppManagementAssignmentFilterId": null,
"deviceAndAppManagementAssignmentFilterType": "none",
"groupId": "b25c80e3-78cc-4b7c-888e-fc50dcc6b582"
}
}
]
}
Nowhere is this documented...

Cannot update AAD App Registration in B2C Tenant after first user logs in

I am posting this question in the style of question and answer.
I recently ran into an issue where I could not add Reply Urls to an Azure AD App Registration, which was registered in a B2C Tenant.
I had been happily updating the Reply Urls using automation using the az ad app update --id $appIdentifier --add replyUrls "https://<a-valid-dns-zone-record> command.
However, once you use your App Registration from a B2C tenant to perform the first login, the command above stops working. You CAN carry on using the portal to add new Reply Urls, but that isnt any good for automation!
I had googled and searched stackoverflow for answers but found none. I raised a Premier Support ticket with Microsoft, and a Github issue on the Azure CLI tool.
I got a workaournd for the problem from my Github Issue
Which I will detail in my answer
The solution is to use the az rest command and use it to call Graph API directly. Here is a PowerShell script I use in an Azure DevOps yaml pipeline.
[CmdletBinding()]
Param(
[Parameter(Mandatory)]
[String]$servicePrincipalId,
[Parameter(Mandatory)]
[String]$servicePrincipalPassword,
[Parameter(Mandatory)]
[String]$servicePrincipalTenantId,
[Parameter(Mandatory)]
[String]$newReplyUrl,
[Parameter(Mandatory)]
[String]$appRegIdentifier
)
Write-Host "Logging in as B2C Tenant Service Principal"
$null = az login --service-principal -u $servicePrincipalId -p $servicePrincipalPassword -t $servicePrincipalTenantId --allow-no-subscriptions
# Load some JSON/Text file helpers...
Import-Module $PSScriptRoot\Json-Helpers.psm1
Write-Host "Adding $newReplyUrl if required..."
$appRegObjectId = az ad app show --id $appRegIdentifier --query 'objectId'
$graphAppRegUri = "https://graph.microsoft.com/v1.0/applications/$appRegObjectId"
$appJson = az rest --method GET --uri $graphAppRegUri
$app = $appJson | ConvertFrom-Json
if ($app.web.redirectUris.Contains($newReplyUrl)) {
# ReplyUrl exists, no-op
Write-Host "Reply Url already exists, no action required..."
Exit 0
}
$patchApp = #{ web = #{ redirectUris = #() } }
ForEach ($url in $app.web.redirectUris) {
$patchApp["web"].redirectUris += $url
}
$patchApp["web"].redirectUris += $newReplyUrl
$tempFile = ".\patchAppTemp-$([System.Guid]::NewGuid()).json"
try {
$patchApp | ConvertTo-Json -Depth 5 | Format-Json -Minify | Set-Content -Path $tempFile -Encoding UTF8 -NoNewline
# Update `redirectUris` for `web` property
Write-Host "Calling Graph API to update Reply Urls"
az rest --method patch --uri $graphAppRegUri --headers "Content-Type=application/json" --body=#$tempFile
Write-Host "Successfully added $newReplyUrl to App Registration $appRegIdentifier"
}
finally {
if (Test-Path $tempFile) {
Remove-Item -Path $tempFile
}
}
And for completeness, here is the Json-Helpers.psm1 PowerShell module:
function Get-Encoding
{
param
(
[Parameter(Mandatory,ValueFromPipeline,ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string]
$Path
)
process
{
$bom = New-Object -TypeName System.Byte[](4)
$file = New-Object System.IO.FileStream($Path, 'Open', 'Read')
$null = $file.Read($bom,0,4)
$file.Close()
$file.Dispose()
$enc = 'Ascii'
if ($bom[0] -eq 0x2b -and $bom[1] -eq 0x2f -and $bom[2] -eq 0x76)
{ $enc = 'Utf7' }
if ($bom[0] -eq 0xff -and $bom[1] -eq 0xfe)
{ $enc = 'Unicode' }
if ($bom[0] -eq 0xfe -and $bom[1] -eq 0xff)
{ $enc = 'Bigendianunicode' }
if ($bom[0] -eq 0x00 -and $bom[1] -eq 0x00 -and $bom[2] -eq 0xfe -and $bom[3] -eq 0xff)
{ $enc = 'Utf32'}
if ($bom[0] -eq 0xef -and $bom[1] -eq 0xbb -and $bom[2] -eq 0xbf)
{ $enc = 'Utf8'}
[PSCustomObject]#{
Encoding = $enc
Path = $Path
}
}
}
function Format-Json {
<#
.SYNOPSIS
Prettifies JSON output.
.DESCRIPTION
Reformats a JSON string so the output looks better than what ConvertTo-Json outputs.
.PARAMETER Json
Required: [string] The JSON text to prettify.
.PARAMETER Minify
Optional: Returns the json string compressed.
.PARAMETER Indentation
Optional: The number of spaces (1..1024) to use for indentation. Defaults to 4.
.PARAMETER AsArray
Optional: If set, the output will be in the form of a string array, otherwise a single string is output.
.EXAMPLE
$json | ConvertTo-Json | Format-Json -Indentation 2
#>
[CmdletBinding(DefaultParameterSetName = 'Prettify')]
Param(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
[string]$Json,
[Parameter(ParameterSetName = 'Minify')]
[switch]$Minify,
[Parameter(ParameterSetName = 'Prettify')]
[ValidateRange(1, 1024)]
[int]$Indentation = 4,
[Parameter(ParameterSetName = 'Prettify')]
[switch]$AsArray
)
if ($PSCmdlet.ParameterSetName -eq 'Minify') {
return ($Json | ConvertFrom-Json) | ConvertTo-Json -Depth 100 -Compress
}
# If the input JSON text has been created with ConvertTo-Json -Compress
# then we first need to reconvert it without compression
if ($Json -notmatch '\r?\n') {
$Json = ($Json | ConvertFrom-Json) | ConvertTo-Json -Depth 100
}
$indent = 0
$regexUnlessQuoted = '(?=([^"]*"[^"]*")*[^"]*$)'
$result = $Json -split '\r?\n' |
ForEach-Object {
# If the line contains a ] or } character,
# we need to decrement the indentation level unless it is inside quotes.
if ($_ -match "[}\]]$regexUnlessQuoted") {
$indent = [Math]::Max($indent - $Indentation, 0)
}
# Replace all colon-space combinations by ": " unless it is inside quotes.
$line = (' ' * $indent) + ($_.TrimStart() -replace ":\s+$regexUnlessQuoted", ': ')
# If the line contains a [ or { character,
# we need to increment the indentation level unless it is inside quotes.
if ($_ -match "[\{\[]$regexUnlessQuoted") {
$indent += $Indentation
}
$line
}
if ($AsArray) { return $result }
return $result -Join [Environment]::NewLine
}
The key line is
az rest --method patch --uri $graphAppRegUri --headers "Content-Type=application/json" --body=#$tempFile
Which calls the Graph API, passing the Json content of your dynamically created temporary file. One small downside to doing it this way is that you take the current URLs, add your new one, and patch the whole list, rather than just adding the one you wanted. As the list grows, the content of the patch could get large too.
But as things stand today, this is the best way to get around the problem that I found.
While Ian had a very good answer and would not have been able to get started without it, it also seems bit too complex. With patch command you do not need to write all the json gotten from the GET call, but only the part you want to update.
$myTestServiceUri = "https://my-test-url.com"
$myObjectId = "<OBJECT_ID_GUID_HERE>"
$graphAppRegUri = "https://graph.microsoft.com/v1.0/applications/$myObjectId"
az rest --method GET --uri $graphAppRegUri
$newUriArr = ((az rest --method GET --uri $graphAppRegUri | ConvertFrom-Json).spa.redirectUris + $myTestServiceUri)
(#{spa=#{redirectUris=$newUriArr}} | ConvertTo-Json) | Set-Content -Encoding UTF8 -NoNewlin -Path tmp-redirectUris.json
az rest --method PATCH --uri $graphAppRegUri --headers "Content-Type=application/json" --body=#tmp-redirectUris.json
Also good to do login before running this to test locally (had to use no subscription as Azure AD B2C tenant has got no subscription at least in my case):
az login --tenant <TENANT_NAME_HERE>.onmicrosoft.com --allow-no-subscriptions
When logging in with automated setup, one needs to use Azure B2C application configured with correct access rights and client secret (see instructions from Azure B2C docs):
az login --service-principal --username APP_ID --password SP1_PASSWORD --tenant TENANT_ID
From the Microsoft Graph REST API docs one can also see the required permissions for the command and can configure those to B2C application. So you will need Application.ReadWrite.All if you are going to manage all your Applications with the same "service principal". If only this one application, use Application.ReadWrite.OwnedBy.

How to automate the pipelining in azure devops using rest api

I would look to automate process of creating build, releases in azure devops. I do understand that rest api's exist. But will they help to automate the process and can this be done in node.js?
yes. Azure Devops do have API. I do not have experiences in node.js.
Docu for Azure DevOps REST API here:
https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-5.0
You may find node.js libraries here:
https://github.com/microsoft/azure-devops-node-api
Can you give a few more details about your desired automation process?
If you mean also the creation of definitions, then I would also have a look into YAML. In the future, there will be few more update especially for the Release Definitions:
https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema
I implemented a Powershell (but not with Node.js) wrapper for the Azure DevOps Api . Here are some code snippets to create a new release:
SET UP CONNECTION
<#
.SYNOPSIS
Sets the environment parameter for the current session so that the commandlets can access Azure DevOps.
#>
function Set-AzureDevOpsEnvironment {
Param(
<# The account name of the Azure DevOps tenant to access. If not set, this is set to "vanstelematics" #>
[Parameter(Mandatory=$true)]
$AzureDevOpsAccountName,
<# The project name of Azure DevOps to work with. If not set, this is set to "ScaledPilotPlatform" #>
[Parameter(Mandatory=$true)]
$AzureDevOpsProjectName,
<# The PAT to access the Azure DevOps REST API. If not set, the function tries to read the PAT from a
textfile called "AzureDevOpsPat.user" either in the current working directory or in the profile
directory of the current user. The textfile must contain only that PAT as plain string. #>
[Parameter(Mandatory=$false)]
$AzureDevOpsPat
)
if ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent) {
$Script:Verbose = $true
} else {
$Script:Verbose = $false
}
if (!$AzureDevOpsPat) {
$paths = #("AzureDevOpsPat.user", (JOin-Path $env:USERPROFILE "AzureDevOpsPat.user"))
foreach ($path in $paths) {
if (Test-Path $path -ErrorAction SilentlyContinue) {
$AzureDevOpsPat = Get-Content $path
break
}
}
if (!$AzureDevOpsPat) {
Write-Host "AzureDevOpsPat is empty and file AzureDevOpsPat.user not found." -ForegroundColor Red
return
}
}
Write-Host "The Azure DevOps project '$($AzureDevOpsProjectName)' inside the Azure DevOps account '$($AzureDevOpsAccountName)' will be used."
$Script:AzureDevOpsAccountName = $AzureDevOpsAccountName
$Script:AzureDevOpsProjectName = $AzureDevOpsProjectName
$Script:AzureDevOpsPat = $AzureDevOpsPat
}
REST CALLER
function Call-AzureDevOpsApi($Url, $JsonBody, [ValidateSet("GET", "DELETE", "POST", "PUT", "PATCH")]$Method, $ContentType) {
if ($Script:Verbose) {
Write-Host "Calling $($Method) $($Url)"
}
if (!$ContentType) {
$ContentType = "application/json"
}
$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(("{0}:{1}" -f "", $Script:AzureDevOpsPat)))
$parameters = #{
Headers = #{Authorization = ("Basic {0}" -f $base64AuthInfo)};
Method = $Method;
Uri = $Url;
}
if ($Method -in #("POST", "PUT", "PATCH")) {
if (!$JsonBody) {
Write-Error "A JsonBody is required for method $($Method)."
return
}
$JsonBodyUtf8 = [Text.Encoding]::UTF8.GetBytes($JsonBody)
$parameters["Body"] = $JsonBodyUtf8
$parameters["ContentType"] = $ContentType
}
$result = Invoke-RestMethod #parameters
return $result
}
function Call-AzureDevOpsApiPost($Url, $JsonBody, [Parameter(Mandatory=$False)][ValidateSet("application/json", "application/json-patch+json")]$ContentType) {
return Call-AzureDevOpsApi -Url $Url -JsonBody $JsonBody -ContentType $ContentType -Method POST
}
function Call-AzureDevOpsApiPut($Url, $JsonBody, [Parameter(Mandatory=$False)][ValidateSet("application/json", "application/json-patch+json")]$ContentType) {
return Call-AzureDevOpsApi -Url $Url -JsonBody $JsonBody -Method PUT
}
function Call-AzureDevOpsApiPatch($Url, $JsonBody, [Parameter(Mandatory=$False)][ValidateSet("application/json", "application/json-patch+json")]$ContentType) {
return Call-AzureDevOpsApi -Url $Url -JsonBody $JsonBody -Method PATCH
}
function Call-AzureDevOpsApiGet($Url, [Parameter(Mandatory=$False)][ValidateSet("application/json", "application/json-patch+json")]$ContentType) {
return Call-AzureDevOpsApi -Url $Url -Method GET
}
function Call-AzureDevOpsApiDelete($Url, [ValidateSet("application/json", "application/json-patch+json")]$ContentType) {
return Call-AzureDevOpsApi -Url $Url -Method DELETE
}
NEW RELEASE
<#
.SYNOPSIS
Creates a new release for a given Release Definition and artifact (p.e. build)
#>
function New-Release {
Param(
<# The id of the release definition to create the release for. #>
[Parameter(Mandatory=$true)]
$ReleaseDefinition,
<# The alias of the artifact of the release definition to create the release for #>
[Parameter(Mandatory=$true)]
$ArtifactAlias,
<# The version of the artifact (p.e. the id of the build)#>
[Parameter(Mandatory=$true)]
$ArtifactVersion,
<# The description/name of the release #>
[Parameter(Mandatory=$true)]
$Description
)
$url = "https://vsrm.dev.azure.com/$($Script:AzureDevOpsAccountName)/$($Script:AzureDevOpsProjectName)/_apis/release/releases?api-version=4.1-preview.6"
$releaseData = #{
"definitionId" = $ReleaseDefinition.id;
"description" = $Description;
"artifacts" = #(
#{
"alias" = $ArtifactAlias;
"instanceReference" = $ArtifactVersion
}
);
"isDraft" = $false;
"reason" = "none";
"manualEnvironments" = $ReleaseDefinition.environments | select -ExpandProperty name
}
$result = Call-AzureDevOpsApiPost -Url $url -JsonBody ($releaseData | ConvertTo-Json -Depth 100)
return $result
}
Hope this gives an idea how to use it.

Azure WebApp - version controlled configuration/user data deployed separately from app

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).
# 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"
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

Resources