Adding Internal and External users to SharePoint Sites and Teams using PowerShell

As part of the resolution process for a Guest Account issue, I recently had a need to be able to send out Teams invitations without using the Admin GUI. I thought others may also find this useful. As Teams is built on SharePoint I having included SharePoint in this too

There are two different types of user to consider in this scenario. Internal users and external users. The code is different for these user types. Users internal to your organisation (and tenant) are straightforward as they already have an ID within your Office 365 tenant. External users, however, do not. Therefore the command to invite external users also creates a guest account for the external user if one doesn’t already exist.

PowerShell Modules

You will need to install and connect to the ExchangeOnline, MicrosoftTeams and SharePointOnline modules.

Install-Module -Name ExchangeOnlineManagement
Install-Module -Name MicrosoftTeams
Install-Module -Name Microsoft.Online.SharePoint.PowerShell

$Cred = Get-Credential
Connect-MicrosoftTeams -Credential $Cred
Connect-ExchangeOnline -Credential $Cred
Connect-SPOService -Credential $Cred -URL https://<Office 365 org Name>-admin.sharepoint.com

Internal Users

Teams

To start with we need to get the Team information, we then need to add the user to the team.

$Team = Get-Team -displayname "<Name of Team>"
Add-TeamUser -GroupId $Team.GroupID -User <User Principal Name>

The first line of code returns the Team information for the Team we have specified. The second line then adds the users User Principal Name as a member of the underlying unified group.

SharePoint

Things are similar for Sharepoint, although there can be many different groups for the SharePoint site. So the first thing to do is list out the groups for the SharePoint site.

Get-SPOSiteGroup -Site https://contoso.sharepoint.com/sites/sc1

LoginName      : sc1 Members
Title          : sc1 Members
OwnerLoginName : sc1 Owners
OwnerTitle     : sc1 Owners
Users          : {spo-grid-all-users/********-203d-4107-9544-************}
Roles          : {Edit}

LoginName      : sc1 Owners
Title          : sc1 Owners
OwnerLoginName : sc1 Owners
OwnerTitle     : sc1 Owners
Users          : {SHAREPOINT\system}
Roles          : {Full Control}

LoginName      : sc1 Visitors
Title          : sc1 Visitors
OwnerLoginName : sc1 Owners
OwnerTitle     : sc1 Owners
Users          : {}
Roles          : {Read}

The first line of code will return an array containing all of the groups associated with the SharePoint site, as shown above. The next line of code, below, adds the user to the selected group

Add-SPOUser -Site https://contoso.sharepoint.com/sites/sc1 -LoginName joe.healy@contoso.com -Group "SC1 Owners"

External Users

Teams

External users are slightly different as they do not have an account in the Office 365 organisation. To get round this a guest account needs to be created in Azure AD, and an invitation needs to be sent to the external user. This means we also need to find the Team URL. Team groups have 3 SharePoint URLs associated with them. The one we are after is called the SharePointSiteURL and is the root URL for the team.

The code is shown below.

$Team = Get-Team -displayname "<Name of Team>"
$URL = Get-UnifiedGroup -Identity $Team.GroupId | Select -ExpandProperty SharePointSiteURL
New-AzureADMSInvitation -InvitedUserEmailAddress <users email address> -SendInvitationMessage $True -InviteRedirectUrl $URL

The first line of code returns the team details, exacly the same as for internal users.

The second line then returns the information about the unified group (using the GroupId property) which underpins the Team. This will provide us with the SharePointSiteURL property.

Now that we have the SharepointSiteURL for the Team, the third line then sends out the invitation (including a link to the SharePoint site for that Team) and automatically creates a guest account for the user, if one does not already exist.

SharePoint

This final line of code can also be used to send out invitations to external users for SharePoint sites. Simply set $URL to the SharePoint site URL.

To list all SharePoint sites use the following command.

Get-SPOSite -Limit All

PowerShell: Automatically Deploy an Azure Test or Development Environment as Code (IaC)

We often need to build testing, packaging or development environments in Azure. Often these are built in an ad-hoc way, manually. This is fine, if a little time consuming, but Azure makes it easy to create scripts that will automate the provisioning of these environments. In fact, automation should really be the preferred method of deployment as it ensures that environments can be deployed or re-deployed rapidly whilst maintaining build quality.

The script that I am going to describe here uses PowerShell Azure cmdlets to provide a very flexible method of deploying the Azure environment, and deploying as many fully configured VMs (desktops or servers) as you may need.

This script relies on the AZ PowerShell module, and this should be installed on any workstation prior to running the script by running the following command. The script uses functions, for code minimisation and to simplify code reuse.

Install-Module -Name Az

Azure Environment and Networking

All the variables required for the script are set at the beginning, and I have added comments in the script describing all of these. All output is sent to the console.

The ConfigureNetwork function creates a Virtual Network, a subnet and a Network Security Group (NSG) name. The NSG also has a rule enabled for RDP. You can add other rules if you need them. If you don’t need the networking configured simply comment out the ConfigureNetwork call later in the script.

function ConfigureNetwork {
    $virtualNetwork = New-AzVirtualNetwork -ResourceGroupName $RGName -Location $Location -Name $VNet -AddressPrefix 10.0.0.0/16
    $subnetConfig = Add-AzVirtualNetworkSubnetConfig -Name default -AddressPrefix 10.0.0.0/24 -VirtualNetwork $virtualNetwork
        
    $rule1 = New-AzNetworkSecurityRuleConfig -Name rdp-rule -Description "Allow RDP" -Access Allow -Protocol Tcp -Direction Inbound -Priority 100 -SourceAddressPrefix * -SourcePortRange * -DestinationAddressPrefix * -DestinationPortRange 3389
    $nsg = New-AzNetworkSecurityGroup -ResourceGroupName $RGName -Location $location -Name $NsgName -SecurityRules $rule1
        # $rule1, $rule2 etc
        If ($nsg.ProvisioningState -eq "Succeeded") {Write-Host "Network Security Group created successfully"}Else{Write-Host "*** Unable to create or configure Network Security Group! ***"}
    $Vnsc = Set-AzVirtualNetworkSubnetConfig -Name default -VirtualNetwork $virtualNetwork -AddressPrefix "10.0.1.0/24" -NetworkSecurityGroup $nsg
    $virtualNetwork | Set-AzVirtualNetwork >> null
        If ($virtualNetwork.ProvisioningState -eq "Succeeded") {Write-Host "Virtual Network created and associated with the Network Security Group successfully"}Else{Write-Host "*** Unable to create the Virtual Network, or associate it to the Network Security Group! ***"}
}

I wanted the script to support fully configured VMs, which can be configured using scripts and Azure’s Custom Script Extension functionality. This enables scripts to be run during, or after VM deployment. These scripts need storing somewhere, so I have included a CreateStorageAccount function.

function CreateStorageAccount {
    If ($StorAccRequired -eq $True)
        {
        $storageAccount = New-AzStorageAccount -ResourceGroupName $RGName -AccountName $StorAcc -Location uksouth -SkuName Standard_LRS
        $ctx = $storageAccount.Context
        $Container = New-AzStorageContainer -Name $ContainerName -Context $ctx -Permission Container
        If ($storageAccount.StorageAccountName -eq $StorAcc -and $Container.Name -eq $ContainerName) {Write-Host "Storage Account and container created successfully"}Else{Write-Host "*** Unable to create the Storage Account or container! ***"}    
        #$BlobUpload = Set-AzStorageBlobContent -File $BlobFilePath -Container $ContainerName -Blob $Blob -Context $ctx 
        Get-ChildItem -Path $ContainerScripts -File -Recurse | Set-AzStorageBlobContent -Container "data" -Context $ctx
        }
        Else
        {
            Write-Host "Creation of Storage Account and Storage Container not required"
        }   
}

This function creates a Storage Account and a Container (for holding the scripts). It then copies all files found in the $ContainerScripts variable up to the Container. Again, if this functionality is not required (e.g. you are storing your scripts in GitHub) you can comment out the CreateStorageAccount call later in the script.

Creating the VM/s

To provide the correct details for the image, the available SKUs need to be listed. This is done with the following command (don’t forget to enter the location that you want to use when creating the resource, as availability varies between regions).

Get-AzVMImageSku -Location "uksouth" -PublisherName "MicrosoftWindowsServer" -Offer "WindowsServer"

To identify available Windows 10 SKUs, use this command.

Get-AzVMImageSku -Location "uksouth" -PublisherName "MicrosoftWindowsDesktop" -Offer "Windows-10"

From this data we can put together the image name to be used for the VM. For example, We want to deploy the latest 20h2 Windows 10 image, so we would use the following string for the ImageName parameter.

MicrosoftWindowsDesktop:Windows-10:20h2-ent:latest

The CreateVMp function creates the VM, sets the local Admin User and Password, and creates a Public IP address.

function CreateVMp($VMName) {
    $PublicIpAddressName = $VMName + "-ip"

    $Params = @{
    ResourceGroupName = $RGName
    Name = $VMName
    Size = $VmSize
    Location = $Location
    VirtualNetworkName = $VNet
    SubnetName = "default"
    SecurityGroupName = $NsgName
    PublicIpAddressName = $PublicIpAddressName
    ImageName = $VmImage
    Credential = $VMCred
    }

    $VMCreate = New-AzVm @Params
    If ($VMCreate.ProvisioningState -eq "Succeeded") {Write-Host "Virtual Machine $VMName created successfully"}Else{Write-Host "*** Unable to create Virtual Machine $VMName! ***"}
}

Configuring the VM/s

Configuring VMs is done by means of a script, run on the newly provisioned VM. Obviously, a script allows you to do anything that you want. In my example the script is run from the Storage Account but copies media from a GitHub location and then installs Orca. You can use the same technique to do pretty much anything that you need to.

The script is run using the Custom Script Extension functionality in Azure. The function, RunVMConfig, can be run against any Azure Windows VM, it does not have to have been created using this script.

function RunVMConfig($VMName, $BlobFilePath, $Blob) {

    $Params = @{
    ResourceGroupName = $RGName
    VMName = $VMName
    Location = $Location
    FileUri = $BlobFilePath
    Run = $Blob
    Name = "ConfigureVM"
    }

    $VMConfigure = Set-AzVMCustomScriptExtension @Params
    If ($VMConfigure.IsSuccessStatusCode -eq $True) {Write-Host "Virtual Machine $VMName configured successfully"}Else{Write-Host "*** Unable to configure Virtual Machine $VMName! ***"}
}

The script that will be run is taken from the Storage Account Container, but it will also accept the RAW path to a GitHub repository. You would then not need to create the Storage Account.

Script Main Body

The main body of the script is actually quite short, due to the use of functions. The Resource Group is created, and then the ConfigureNetwork and CreateStorageContainer functions are called (if required, they can be commented out).

# Main Script

# Create Resource Group
$RG = New-AzResourceGroup -Name $RGName -Location $Location
If ($RG.ResourceGroupName -eq $RGName) {Write-Host "Resource Group created successfully"}Else{Write-Host "*** Unable to create Resource Group! ***"}

# Create VNet, NSG and rules (Comment out if not required)
ConfigureNetwork

# Create Storage Account and copy media (Comment out if not required)
CreateStorageAccount

# Build VM/s
$Count = 1
While ($Count -le $NumberOfVMs)
    {
    Write-Host "Creating and configuring $Count of $NumberofVMs VMs"
    $VM = $VmNamePrefix + $VmNumberStart
    CreateVMp "$VM"
    RunVMConfig "$VM" "https://packagingstoracc.blob.core.windows.net/data/VMConfig.ps1" "VMConfig.ps1"
    # Shutdown VM if $VmShutdown is true
    If ($VmShutdown)
        {
        $Stopvm = Stop-AzVM -ResourceGroupName $RGName -Name $VM -Force
        If ($RG.ResourceGroupName -eq $RGName) {Write-Host "VM $VM shutdown successfully"}Else{Write-Host "*** Unable to shutdown VM $VM! ***"}
        }
    $Count++
    $VmNumberStart++
    }

In order to provision multiple VMs, a While loop is used, to rerun code until the required number of VMs are provisioned. The VM name is created by concatenating variables, and the name is passed, first to the CreateVMp function, and then to the RunVMConfig function. A variable at the beginning of the script is used to check if the newly provisioned VM should be shutdown or not. This is very useful as an aid to manage costs.

The full script is located here.

https://raw.githubusercontent.com/HigginsonConsultancy/Scripts/master/CreateConfigureVMs.ps1

The VM config script I used is written to output into the Windows Event Log for ease of fault finding. It is located here.

https://raw.githubusercontent.com/HigginsonConsultancy/Scripts/master/VMConfig.ps1

The script outputs to the console and successful completion shouls look something like this.

Resource Group created successfully
Network Security Group created successfully
Virtual Network created and associated with the Network Security Group successfully
Storage Account and container created successfully
Account              SubscriptionName          TenantId                             Environment
-------              ----------------          --------                             -----------
graham@*********.*** Microsoft Partner Network ********-****-****-****-********73ab AzureCloud 

ICloudBlob                         : Microsoft.Azure.Storage.Blob.CloudBlockBlob
BlobType                           : BlockBlob
Length                             : 2034
IsDeleted                          : False
BlobClient                         : Azure.Storage.Blobs.BlobClient
BlobProperties                     : Azure.Storage.Blobs.Models.BlobProperties
RemainingDaysBeforePermanentDelete : 
ContentType                        : application/octet-stream
LastModified                       : 11/30/2020 10:03:49 AM +00:00
SnapshotTime                       : 
ContinuationToken                  : 
Context                            : Microsoft.WindowsAzure.Commands.Storage.AzureStorageContext
Name                               : VMConfig.ps1

Creating and configuring 1 of 1 VMs
Virtual Machine PVMMSI500 created successfully
Virtual Machine PVMMSI500 configured successfully
VM PVMMSI500 shutdown successfully

Phased Deployment of Azure Conditional Access Multi-factor Authentication (MFA) using PowerShell

I recently had a client that needed to deploy Azure MFA, controlled by a conditional access policy, to around 30,000 users. Now, obviously, from a technical point of view this is quite straightforward, however because of the large number of users, and the fact that user communications and support needed to be managed, I wanted this to be done in a controlled way. I didn’t want to overload the helpdesk, or cause business problems for the users, due to unexpected issues.

I therefore wrote 3 scripts that would allow us to easily manage the deployment and ensure that things progressed in a tightly controlled way.

The process that was to be followed was this.

  1. Create the Conditional Access policy and assign to an MFA Deployment group.
  2. Identify all the affected users and gather their User Principal Names into multiple batches. Create files with, perhaps 500 users in each file. This ensures that users are enabled for MFA in small, manageable batches.
  3. Communications are sent to the user to tell them to register their additional authentication methods, together with the URL to the correct web page (aka.ms/MFASetup *). The users will automatically be prompted to register additional authentication methods when they first login, once they are enabled for MFA, however many users tend to ignore this if they can, so its better to try and get them to register before they need to. This saves issues building up with the helpdesk if they encounter problems after waiting until the last minute to register additional authentication methods.
  4. Run the CheckVerificationStatus.ps1 script. This script reads the UPNs from the Input File and lists each UPN together with a list of their registered Strong Authentication Methods. If nothing is listed for a user then they have not registered additional authentication methods for MFA. Communications could then be sent to those users to request that they register.
  5. Run the EnableMFA.ps1 script that takes the UPN names from a file, ensures that sufficient EMS licenses exist, provisions the user with an EMS license, then adds the user to the MFA deployment group.
  6. If irresolvable problems occur, run the DisableMFA.ps1 script which will back-out MFA for all users in the batch, or create a list of users to be backed out and run DisableMFA using that file.

* The aka.ms/MFASetup URL will send each user to the Additional Security Verification page for their own ID. If they haven’t registered additional security verification previously they will be prompted to configure it. If they have configured additional security verification previously they will be shown the page below.

The input files that provide the UPN list are simply TXT files that list each UPN on it’s own line. Try to ensure that there are no blank lines at the end of the file, otherwise you will see the following error in the PowerShell console for each blank line. It doesn’t affect how the scripts work though. The output file is simply a log of what the script has done.

Get-MsolUser : Cannot bind argument to parameter 'UserPrincipalName' because it is an empty string.
At line:10 char:52
+     $CurrentUser = Get-MsolUser -UserPrincipalName $UPN
+                                                    ~~~~
 
    + CategoryInfo          : InvalidData: (:) [Get-MsolUser], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationErrorEmptyStringNotAllowed,Microsoft.Online.Administration.Automation.GetUser

CheckVerificationStatus.ps1

Import-Module -Name Msonline
Connect-MsolService

$InputFile = "C:\Data\UpnList.txt" # List of UPNs
$OutputFile = "C:\Data\Output.txt"
 # Output file

# Read in list of users who will be enabled for MFA
$UPNs = Get-Content -Path $InputFile

ForEach ($UPN in $UPNs)
    {
    $CurrentUser = Get-MsolUser -UserPrincipalName $UPN
      
    If ($CurrentUser.StrongAuthenticationmethods -eq "")
        {
        $NewLine = $UPN + "`t" + "nul" >> $OutputFile
        }
    Else
        {
        $NewLine = $UPN + "`t" + $CurrentUser.StrongAuthenticationmethods.MethodType >> $OutputFile
        }
    }

After prompting for Azure credentials this script takes input from the $InputFile and outputs tab separated data to the $OutputFile. This file can be easily opened in Excel for data manipulation. An example of the output is shown below, showing the Strong Authentication Methods registered for each user. Users with no methods listed have not registered any additional security methods

graham.higginson@xxxxxx.com	OneWaySMS TwoWayVoiceMobile
joe.bloggs@xxxxxx.com	PhoneAppOTP PhoneAppNotification OneWaySMS TwoWayVoiceMobile
jim.booth@xxxxxx.com	
scott.devlin@xxxxxx.com	
andy.knowles@xxxxxx.com	
simon.jay@xxxxxx.com	
john.bonham@xxxxxx.com	

EnableMFA.ps1

Import-Module -Name MsolService
Connect-MsolService

$InputFile = "C:\Data\UpnList.txt"
 # List of UPNs
$OutputFile = "C:\Data\Output.txt"
 # Output File
$License = "xxxxx:EMS"
 # Name of license
$GroupID = "4adcacb1-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
 # GUID of the MFA Deployment group


# Read in list of users who will be enabled for MFA
$UPNs = Get-Content -Path $InputFile


# Identify how many available EMS licenses exist
$Licenses = Get-AzureADSubscribedSku | where {$_.SkuPartNumber -eq 'EMS'}
$PrepaidLicenses = $Licenses.PrePaidUnits.Enabled
$ConsumedLicenses = $Licenses.ConsumedUnits
$AvailableLicenses = $PrepaidLicenses - $ConsumedLicenses

If ($UPNs.count -lt $AvailableLicenses)
    {
     Write-Warning "Sufficient licenses are available"
     Write-Warning "Script continuing..."
    }
Else
    {
     Write-Warning "Insufficient licenses are available"
     Write-Warning "Script stopping!!"
     Break
    }

# Add EMS license to user and add to MFA group
ForEach ($UPN in $UPNs)
    {
    $CurrentUser = Get-MsolUser -UserPrincipalName $UPN
    If ($CurrentUser.Licenses.AccountSkuId -Contains $License)
        {
        Write-Host $UPN "already has an EMS license assigned" >>$OutputFile
        }
    Else
        {
        Set-MsolUserLicense -UserPrincipalName $UPN -AddLicenses $License
        If ($Error[0])
            {
            Write-Host "Unable to add license to" $UPN >>$OutputFile
            $Error.Clear
            }
        Else
            {
            Write-Host "Successfully added license to" $UPN >>$OutputFile
            }
        }

    Add-MsolGroupMember -GroupObjectId $GroupID -GroupMemberType User -GroupMemberObjectId $CurrentUser.ObjectId
    If ($Error[0])
        {
        Write-Host "Unable to add" $UPN "to the MFA group" >>$OutputFile
        $Error.Clear
        }
        Else
            {
            Write-Host "Successfully added" $UPN "to MFA group" >>$OutputFile
            }
    }

After prompting for Azure credentials this script reads in the list of user UPNs and checks that enough licenses are available for the number of users to be enabled for MFA. If there aren’t enough licenses the script will exit. Otherwise the script will assign and EMS license to each user, unless they already have a license assigned. The script then adds each user to the specified MFA Deployment group.

DisableMFA.ps1

Import-Module -Name MsolService
Connect-MsolService


$InputFile = "C:\Data\UpnList.txt"
 # List of UPNs
$OutputFile = "C:\Data\Output.txt"
 # Output File
 
$License = "xxxxx:EMS"
 # Name of license
$GroupID = "4adcacb1-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
 # GUID of the MFA Deployment group

# Read in list of users who will be enabled for MFA
$UPNs = Get-Content -Path $InputFile


ForEach ($UPN in $UPNs)
    {
    $CurrentUser = Get-MsolUser -UserPrincipalName $UPN
    Remove-MsolGroupMember -GroupObjectId $GroupID -GroupMemberType User -GroupMemberObjectId $CurrentUser.ObjectId
    If ($Error[0])
        {
        Write-Host "Unable to remove " $UPN " from MFA group" >>$OutputFile
        $Error.Clear
        }
    Else
        {
        Write-Host "Successfully removed " $UPN " from MFA group" >>$OutputFile
        }
    
    Set-MsolUserLicense -UserPrincipalName $UPN -RemoveLicenses $License
    
    If ($Error[0])
        {
        Write-Host "Unable to remove license from " $UPN >>$OutputFile
        $Error.Clear
        }
    Else
        {
        Write-Host "Successfully removed license from " $UPN >>$OutputFile
        }
    $NewLine = $UPN + "`t" + "Removed from Group" >> $OutputFile
    }

After prompting for Azure credentials this script reads in the list of UPNs and then attempts to remove the user from the MFA Deployment group. It then attempts to remove the EMS license from the user.

*** You may want to comment out the license removal if many of your users already have EMS licenses assigned prior to rolling out MFA. ***

This solution was designed for a very particular use case, and may not be a perfect fit for every requirement, but I hope it provides food for thought. Feel free to take the bits that work for you, to produce your own solution.

Powershell GUI utility to create Intunewin files for Win32 Intune applications

Although Intune is often used to manage mobile devices and applications, it can also manage and deploy Windows 10 applications. It can easily deploy AppX and MSIX applications, but Win32 applications need to be wrapped into an Intunewin package before they can be deployed. This makes them look more like the AppX/MSIX style applications that Intune was originally designed to deploy.

There is a command line tool called the Microsoft Win32 Content Prep Tool that can be used to wrap a Win32 application into an Intunewin format. This work is often be done by application packagers, or by the Intune deployment team.

To make use of this tool more convenient to use, and also more suitable for less technical personnel, I have created a GUI application, written in Powershell. The utility comes as an MSI installer, that includes the Powershell script, the content prep tool executable and a shortcut.

The utility can be downloaded below.

https://github.com/HigginsonConsultancy/Media/blob/master/IntuneWinUtility.msi

Once installed, the utility can be started by running the following shortcut.

and when opened, the utility looks like this.

From this window the source folder, output folder and setup file name can be selected, using the browse buttons. Input is validated to ensure that it exists.

The Microsoft Win32 Content Prep Tool has a known bug that causes the tool to fail if output is redirected, therefore I have allowed the CMD window output to be displayed so that it is possible to check that the conversion is working correctly, rather than giving no output at all. The CMD windows closes automatically once the content prep tool has finished, and the Intunewin package will be found in the Output Folder path.

Troubleshooting a Custom Script Extension as part of an ARM Template Deployment

I recently decided to test the use of a Custom Script Extension, as I wanted to deploy a VM with client software installed. I created a Powershell script that downloaded, extracted and installed the MSI tool Orca. If I could get this to deploy and run successfully it would give me a basis to install any software I needed, plus perform any other configuration I might need.

I added lots of error logging to the script so that I would have plenty of information when it came to troubleshooting. The script I used is located here.

Create ARM Template

I took an existing ARM template that I used to create a Windows 10 VM, renamed it and added the Custom Script Extension code. The template is here and the parameter file I used is here.

The Custom Script Extension code itself is shown here.

To deploy the template I used the following Powershell commands (after installing the Az module).

Connect-AzAccount
New-AzResourceGroupDeployment -Name TestDeployment -ResourceGroupName HiggCon -TemplateFile C:\Dev\VM\VMcse.template.json -TemplateParameterFile C:\Dev\VM\VM.parameters.json

And that is where the problems started…

There is a part of me that always hopes that something like this will work first time, but I have to be honest, it hardly ever does! So the error I got back is below.

The deployment failed

I looked back through my Custom Script Extension code (which I had got, and edited, from the Microsoft documentation), and found the following line that I hadn’t noticed.

"name": "virtualMachineName/config-app",

Hmm, that didn’t look right, so I changed it to this.

"name": "[Concat(parameters('virtualMachineName'),'/config-app')]",

Hurrah, the deployment was then successful…but.

Looking in Azure, I could see that the VM appeared to have been created successfully. Unfortunately, after logging into the VM, Orca wasn’t installed.

I looked in the Eventlog. There were no entries from my script so it looked like the script hadn’t run, but why?

First I looked in the C:\WindowsAzure\Logs\WaAppAgent.Log file. It looked like some Custom ScriptExtensions had run successfully, but they looked Azure specific, with no hint that my script had run. After some digging I managed to find the VMConfig.ps1 file, that was my script, in C:\Packages\Plugins\Microsoft.Compute.CustomScriptExtension\1.10.8\Downloads\0. So that showed that the script had been copied down correctly. I tried running the script manually…and got the “Running Scripts is disabled” message. Doh!

I changed my command to execute to the following, deleted the VM and reran the deployment.

"commandToExecute": "powershell -ExecutionPolicy Unrestricted -File VMConfig.ps1"

Success! My Deployment worked AND my script worked. Orca was installed successfully. My script had also written successfully to the Event Viewer.

I looked in C:\WindowsAzure\Logs\WaAppAgent.Log and found these entries near the bottom.

Its not exactly clear, but I think it is related to my script as it wasn’t there previously. The middle line includes this “ExtensionName: ”’ seqNo ‘0’ reached terminal state”. My script was downloaded to a directory named 0, so my thinking is that they are related. The end of the last line stating “completed with status: RunToCompletion” sounds right too. Unfortunately it is difficult to know definitely.

Summary

So what have we learnt? Well first of all, I think that when writing a script to deploy using Custom Script Extension, it makes a lot of sense to include as much logging as possible, and to test if fully before trying to deploy, as it will be difficult to determine what is wrong otherwise.

The deployment can really be split into two sections, Deployment and Execution.

Deployment

It is easy to misconfigure the ARM template, as I found to my cost. I used Visual Studio Code to edit the template as it supports ARM templates and identifies error syntax. Make sure all errors are resolved before trying to deploy. The “New-AzResourceGroupDeployment” Powershell cmdlet will tell you if the deployment was successful or not. The error messages aren’t always easy to interpret though.

Execution

Once successfully deployed, if you don’t get the result you are expecting check the

C:\Packages\Plugins\Microsoft.Compute.CustomScriptExtension\1.10.8\Downloads

directory for a subdirectory holding your script. That way you know that it is actually there. Then trying running the script and see what error messages you get.

I also looked in

C:\Packages\Plugins\Microsoft.Compute.CustomScriptExtension\1.10.8\Status

and found a “0.status” file that gave me a little more information on my script, and a success status code.

In

C:\Packages\Plugins\Microsoft.Compute.CustomScriptExtension\1.10.8\RuntimeSettings

I found a file named “0”, which contained, amongst other things, the URL the script was download from and the command to execute. So be aware if you are working in a secure environment, or are using a public repository.

Logging to the Windows Eventlog using Powershell

When automating tasks, such as software installation, using Powershell, it is useful to output to a logfile for fault finding purposes, but I have never liked the idea of creating log files, as these are not easily managed and can build up over time. Another problem is that no-one, other than yourself, is likely to know that the logs exist, or where it is located. Everyone, however, knows about the Windows Eventlog and how to access it, so it makes sense to log your information there.

Adding script output to the Windows Eventlog is reasonably straightforward, but has to be done in the correct way. To use the Eventlog you need to create two items, a new Eventlog and a Source for the log entries. Optionally you can then configure the log that you have created. The code for this is below.

New-EventLog -LogName Application -Source "Documentum_Webtop-AddIn_1.0_x86_R01"
Limit-EventLog -OverflowAction OverWriteAsNeeded -MaximumSize 64KB -LogName Application

The first line of code creates a log called Application, with a source called Documentum_Webtop-AddIn_1.0_x86_R01. The source is simply the packaged application that I am going to be installing. Using this name makes it easy to identify log entries from the application within the Eventlog.

The second line of code sets the maximum size of the log to 64Kb and configures the log to overwrite when it gets to the maximum size.

I can now write to the Eventlog by using the following command.

Write-EventLog -LogName "Application" -Source "Documentum_Webtop-AddIn_1.0_x86_R01" -EventID 25001  -EntryType Information -Message "Begining install of Application Documentum_Webtop-AddIn_1.0_x86_R01"

The EventID should be 25000 or greater, as this range is undocumented.

ErrorType can be Information, Warning or Error.

When writing install and uninstall scripts, I like to include the New-Eventlog and Limit-EventLog entries at the beginning of the Install script, and then remove the Source at the end of the Uninstall script. This keeps things tidy, especially if you have a large number of applications to install.

To remove the source use the following command.

Remove-EventLog -Source "Documentum_Webtop-AddIn_1.0_x86_R01"

Deploying Objects in Azure using ARM Templates

Object creation in Azure can be automated in many ways, one of which is by using ARM Templates. ARM Templates can either be authored from scratch, or can be based on manually deployed objects or Resource Groups.

In this post I simply want to detail the commands to use to deploy ARM Templates whether they are located locally, or in GitHub.

Start by connecting your Powershell session to Azure, using the command below (ensure that you have installed the Azure Powershell module previously).

Connect-AzAccount

This will prompt you for your Azure credentials and give you access to create objects in your Azure subscription.

Deploy Local ARM Templates

The command to deploy ARM templates that are stored locally is as follows.

New-AzResourceGroupDeployment -Name DeploymentName -ResourceGroupName <NameofResourceGroup> -TemplateFile "C:\DefaultVNETandNSG.json" -TemplateParameterFile "C:\DefaultVNETandNSG.parameters.json"

-Name is the name you wish to give this deployment.

-ResourceGroupName is the name of the Resource Group the objects will be created in.

-TemplateFile is the path to the template file you are using.

-TemplateParameterFile is the path to the parameter file that you are using (if any).

Deploying Templates stored in GitHub

The command to deploy templates store in GitHub is only slightly different.

New-AzResourceGroupDeployment -Name TestDeployment -ResourceGroupName <NameofResourceGroup> -TemplateUri "https://raw.githubusercontent.com/<NameofRepository>/Templates/master/DefaultVNETandNSG.json" -TemplateParameterUri "https://raw.githubusercontent.com/<NameofRepository>/Templates/master/DefaultVNETandNSG.parameters.json"

Only the following switches are different with this command.

-TemplateUri is the RAW URL for the template file.

-TemplateParameterUri is the RAW URL for the parameter file (if used).

*** Remember to only use the RAW URL for the paths for both files. Otherwise the deployment will fail. ***

Copying or migrating users between groups

A common task when implementing new systems is to copy or migrate users from one AD group to another. It’s often a requirement when deploying upgraded application versions too. I wrote a small utility that makes this task easier. Being GUI based means it can be used by less technical team members as well.

The PowerShell code for this utility is below.

#region ScriptForm Designer

#region Constructor

[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
[void][System.Reflection.Assembly]::LoadWithPartialName("System.Drawing")

#endregion

#region Post-Constructor Custom Code

#endregion

#region Form Creation
#Warning: It is recommended that changes inside this region be handled using the ScriptForm Designer.
#When working with the ScriptForm designer this region and any changes within may be overwritten.
#~~< GroupMembershipMigrationToolForm >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$GroupMembershipMigrationToolForm = New-Object System.Windows.Forms.Form
$GroupMembershipMigrationToolForm.ClientSize = New-Object System.Drawing.Size(839, 479)
$GroupMembershipMigrationToolForm.Text = "Group Membership Migration Tool"
#~~< UpdateButton >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$UpdateButton = New-Object System.Windows.Forms.Button
$UpdateButton.Location = New-Object System.Drawing.Point(695, 422)
$UpdateButton.Size = New-Object System.Drawing.Size(75, 23)
$UpdateButton.TabIndex = 13
$UpdateButton.Text = "Update"
$UpdateButton.UseVisualStyleBackColor = $true
#~~< TextBox2 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$TextBox2 = New-Object System.Windows.Forms.TextBox
$TextBox2.Location = New-Object System.Drawing.Point(38, 422)
$TextBox2.Size = New-Object System.Drawing.Size(635, 20)
$TextBox2.TabIndex = 12
$TextBox2.Text = ""
#~~< Label1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Label1 = New-Object System.Windows.Forms.Label
$Label1.Location = New-Object System.Drawing.Point(38, 395)
$Label1.Size = New-Object System.Drawing.Size(100, 23)
$Label1.TabIndex = 11
$Label1.Text = "Label1"
$Label1.add_MouseClick({UpdateButtonMouseClick($Label1)})
#~~< MigrateButton >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$MigrateButton = New-Object System.Windows.Forms.Button
$MigrateButton.Location = New-Object System.Drawing.Point(695, 268)
$MigrateButton.Size = New-Object System.Drawing.Size(75, 23)
$MigrateButton.TabIndex = 10
$MigrateButton.Text = "Migrate"
$MigrateButton.UseVisualStyleBackColor = $true
$MigrateButton.add_MouseClick({MigrateButtonMouseClick($MigrateButton)})
#~~< ClearButton >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ClearButton = New-Object System.Windows.Forms.Button
$ClearButton.Location = New-Object System.Drawing.Point(583, 268)
$ClearButton.Size = New-Object System.Drawing.Size(75, 23)
$ClearButton.TabIndex = 9
$ClearButton.Text = "Clear"
$ClearButton.UseVisualStyleBackColor = $true
$ClearButton.add_MouseClick({ClearButtonMouseClick($ClearButton)})
#~~< CloseButton1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$CloseButton1 = New-Object System.Windows.Forms.Button
$CloseButton1.Location = New-Object System.Drawing.Point(469, 268)
$CloseButton1.Size = New-Object System.Drawing.Size(75, 23)
$CloseButton1.TabIndex = 8
$CloseButton1.Text = "Close"
$CloseButton1.UseVisualStyleBackColor = $true
$CloseButton1.add_MouseClick({CloseButton1MouseClick($CloseButton1)})
#~~< ResultsLabel >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ResultsLabel = New-Object System.Windows.Forms.Label
$ResultsLabel.Location = New-Object System.Drawing.Point(38, 119)
$ResultsLabel.Size = New-Object System.Drawing.Size(100, 15)
$ResultsLabel.TabIndex = 7
$ResultsLabel.Text = "Results"
$ResultsLabel.add_Click({Label1Click($ResultsLabel)})
#~~< TextBox1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$TextBox1 = New-Object System.Windows.Forms.TextBox
$TextBox1.Location = New-Object System.Drawing.Point(38, 146)
$TextBox1.Multiline = $true
$TextBox1.Size = New-Object System.Drawing.Size(347, 213)
$TextBox1.TabIndex = 6
$TextBox1.Text = ""
#~~< DeleteMembersCheckBox >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$DeleteMembersCheckBox = New-Object System.Windows.Forms.CheckBox
$DeleteMembersCheckBox.Location = New-Object System.Drawing.Point(450, 146)
$DeleteMembersCheckBox.Size = New-Object System.Drawing.Size(198, 33)
$DeleteMembersCheckBox.TabIndex = 5
$DeleteMembersCheckBox.Text = "Delete members from Source Group"
$DeleteMembersCheckBox.UseVisualStyleBackColor = $true
#~~< GroupMembershipMigrationToolLabel >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$GroupMembershipMigrationToolLabel = New-Object System.Windows.Forms.Label
$GroupMembershipMigrationToolLabel.Font = New-Object System.Drawing.Font("Tahoma", 12.0, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$GroupMembershipMigrationToolLabel.Location = New-Object System.Drawing.Point(258, 9)
$GroupMembershipMigrationToolLabel.Size = New-Object System.Drawing.Size(333, 23)
$GroupMembershipMigrationToolLabel.TabIndex = 4
$GroupMembershipMigrationToolLabel.Text = "Group Membership Migration Tool"
#~~< DestinationGroupLabel >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$DestinationGroupLabel = New-Object System.Windows.Forms.Label
$DestinationGroupLabel.Location = New-Object System.Drawing.Point(450, 54)
$DestinationGroupLabel.Size = New-Object System.Drawing.Size(141, 23)
$DestinationGroupLabel.TabIndex = 3
$DestinationGroupLabel.Text = "Destination Group"
$DestinationGroupLabel.add_Click({Label2Click($DestinationGroupLabel)})
#~~< SourceGroupLabel >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$SourceGroupLabel = New-Object System.Windows.Forms.Label
$SourceGroupLabel.Location = New-Object System.Drawing.Point(38, 54)
$SourceGroupLabel.Size = New-Object System.Drawing.Size(141, 23)
$SourceGroupLabel.TabIndex = 2
$SourceGroupLabel.Text = "Source Group"
#~~< ListBox2 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ListBox2 = New-Object System.Windows.Forms.ListBox
$ListBox2.FormattingEnabled = $true
$ListBox2.Location = New-Object System.Drawing.Point(450, 80)
$ListBox2.SelectedIndex = -1
$ListBox2.Size = New-Object System.Drawing.Size(347, 17)
$ListBox2.TabIndex = 1
#~~< ListBox1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ListBox1 = New-Object System.Windows.Forms.ListBox
$ListBox1.FormattingEnabled = $true
$ListBox1.Location = New-Object System.Drawing.Point(38, 80)
$ListBox1.SelectedIndex = -1
$ListBox1.Size = New-Object System.Drawing.Size(347, 17)
$ListBox1.TabIndex = 0
$GroupMembershipMigrationToolForm.Controls.Add($UpdateButton)
$GroupMembershipMigrationToolForm.Controls.Add($TextBox2)
$GroupMembershipMigrationToolForm.Controls.Add($Label1)
$GroupMembershipMigrationToolForm.Controls.Add($MigrateButton)
$GroupMembershipMigrationToolForm.Controls.Add($ClearButton)
$GroupMembershipMigrationToolForm.Controls.Add($CloseButton1)
$GroupMembershipMigrationToolForm.Controls.Add($ResultsLabel)
$GroupMembershipMigrationToolForm.Controls.Add($TextBox1)
$GroupMembershipMigrationToolForm.Controls.Add($DeleteMembersCheckBox)
$GroupMembershipMigrationToolForm.Controls.Add($GroupMembershipMigrationToolLabel)
$GroupMembershipMigrationToolForm.Controls.Add($DestinationGroupLabel)
$GroupMembershipMigrationToolForm.Controls.Add($SourceGroupLabel)
$GroupMembershipMigrationToolForm.Controls.Add($ListBox2)
$GroupMembershipMigrationToolForm.Controls.Add($ListBox1)

#endregion

#region Custom Code

#endregion

#region Event Loop

function Main{
     [System.Windows.Forms.Application]::EnableVisualStyles()
     [System.Windows.Forms.Application]::Run($GroupMembershipMigrationToolForm)
}

#endregion

#endregion

#region Event Handlers



function MigrateButtonMouseClick( $object ){

}

function ClearButtonMouseClick( $object ){

}

function CloseButton1MouseClick( $object ){

        

}


function UpdateButtonMouseClick( $object ){

    

    # **** Change the -searchbase entry below to point at the Active Directory OU containing Application Deployment Groups ****
    $Groups = get-adgroup -filter * -searchbase "OU="Application Deployment Groups", OU=Groups, OU=EUC, OU="Thames Water", DC=TWUTIL, DC=NET" | sort Name
    If ($SearchBase -ne "")
    {
    $Groups = get-adgroup -filter * -searchbase $SearchBase | sort Name
        if ($error[0] -gt "")
            {
                #$TextBox2.AppendText("Unable to get AD group list" + "`r`n")
                #$TextBox2.AppendText("Error message " + $Error[0] + "`r`n")
                $error.Clear()
            }
        else
            {
                #$TextBox2.AppendText("Successfully added " + $CompName + " to " + $SelectedGroup + "`r`n") 
                ForEach ($Group in $Groups)
                {
                    ListBox1.Items.Add($Group.Name)
                }
            }


Set-ItemProperty -path HKCU:\Software\GroupMembershipMigrationTool -Name SearchBase -Value $TextBox1.text
    $SearchBase= $TextBox1.Text 
    Try {$Groups = get-adgroup -filter * -searchbase $SearchBase | sort Name}
        Catch
            {
                #$TextBox2.AppendText("Unable to get AD group list" + "`r`n")
                #$TextBox2.AppendText("Error message " + $Error[0] + "`r`n")
                $error.Clear()
            }

        }
}



function Label1Click( $object ){

}

  


Main # This call must remain below all other event functions

#endregion

SCCM Collection Query for groups

SCCM Collections can have Devices and Users added directly to them, but this doesn’t scale and means that the person adding the Devices or Users needs access to SCCM. It makes much more sense to create a Collection Query that queries an AD group. The following query is used for Devices.

select SMS_R_SYSTEM.ResourceID,SMS_R_SYSTEM.ResourceType,SMS_R_SYSTEM.Name,SMS_R_SYSTEM.SMSUniqueIdentifier,SMS_R_SYSTEM.ResourceDomainORWorkgroup,SMS_R_SYSTEM.Client from SMS_R_System where SMS_R_System.SystemGroupName = "<Domain>\\<AD Group>"

And this one is used for Users.

select
 
SMS_R_USER.ResourceID,SMS_R_USER.ResourceType,SMS_R_USER.Name,SMS_R_USER.UniqueUserName,SMS_R_USER.WindowsNTDomain
from SMS_R_User where SMS_R_User.UserGroupName = "<Domain>\\<AD Group>"

Don’t forget to replace the <Domain> and <AD Group> entries with your own details!