Category: Infrastructure

A guide to getting started with C4 diagrams and Structurizr

Discover the benefits of using Structurizr and Domain Specific Language (DSL) to create interactive and versioned C4 diagrams as code, enabling precise CA modelling and visualization for your software architecture.

Table of Contents

What are C4 diagrams?

The C4 model diagram strategy was created to establish a standard set of simple notation principals for visually documenting software architectures.

It consist of a standard set of hierarchical abstractions (software systems, containers, components, and code) that are used to create a set of hierarchical diagrams (system context, containers, components, and code). Although the code diagram is almost never created, other than from the code itself.

Each level of diagram digs deeper into the details of the systems you are trying to communicate and can be thought of as different scales on a map.

The C4 model for visualising software architecture from c4modeI.com

For detailed explanations see The C4 model for visualising software architecture

Structurizr is a toolset that helps code, manage and display C4 diagrams. This guide will help you get setup and started quickly.

You can use a variety of tools to create and conform to this diagramming standard. The key ones I have used are:

For a full list of tools see C4 model tools

Using Structurizr and Domain Specific Language (DSL)

Getting started with Visio is straight forward, just using the stencil. However, what we are after is diagrams as code, that we can version along with our design and code changes. To do this we can either use Mermaid or Domain Specific Language (DSL). DSL allows for more precise C4 modelling and Structurizr allows for creating interactive visualizations.

This is a quick getting started guide to get going locally rendering views of your models on Windows. You can read all the details and options of Structurizr and the DSL syntax at Structurizr.

Prerequisites

Setup Steps and getting started

1. Working Folder

You will need to have a folder that’s dedicated for the Structurizr C4 diagrams code and configuration files. Ideally this will be for a single domain and in your local git folders. That way you can branch, versions and commit your diagrams as versioned code.

For example: c:\gitrepos\myprojectrepo\docs\architecture\diagrams\my-domain\structurizr-data\

Note: Currently you can only have one workspace per folder, so you may need several workspaces, such as one per domain.

2. Docker setup

After you have installed Docker Desktop then the next step is to ‘pull’ (or download) the Structurizr docker image from a Docker registry, typically Docker Hub. By pulling this image, you鈥檙e downloading the necessary files to run Structurizr Lite in a Docker container on your local machine. To do this run the following command

# Pull the Structurizr docker image
docker pull structurizr/lite

The easiest way to execute these commands is from the Docker Desktop built-in Terminal. Click on the ‘>_ Terminal’ button at the bottom.

3. Create and run a container

Once the image is downloaded and ready in Docker, then from the Docker terminal run the following command in this format

docker run -it --rm -p <local port>:8080 -v "<host machine Structurizr folder>:/usr/local/structurizr" structurizr/lite

The -it option supports both:
-i (interactive) keeps the STDIN open even if not attached.
-t (tty) allocates a pseudo-TTY, which allows you to interact with the container.

The -rm option automatically removes the container when it exits, ensuring no leftover containers. You may not want to do this if you are going to run this container repeatedly and have the necessary disk space.

In this case it would be something like…

docker run -it --rm -p 8080:8080 -v "c:\gitrepos\myprojectrepo\docs\architecture\diagrams\my-domain\structurizr-data\:/usr/local/structurizr" structurizr/lite

Once you have your command line tested, it’s a good idea to put this into a readme.md file in the structurizr-data folder, so that others can easily create a server pointing to the correct folders (unless everyone uses the same root paths, they will need to adjust it for themselves, but it’s a good starter).

If you have all the paths correct then you should see a successful start and something like this in the terminal.

4. Access Structurizr Web Dashboard

Once the container is running you can now simply open a browser and navigate to http://localhost:<local port> or in our example http://localhost:8080

A default Structurizr workspace will be automatically created for you. It will look like this…

and a corresponding set of file and folders will be added to your folder.

It’s the workspace.dsl that you will be editing to define your architectural model and how it should be visualized.

Quick-start on modelling using DSL

workspace.dsl

The workspace.dsl file is a text-based domain-specific language (DSL) file used to define a software architecture model based on the C4 model. This file allows you to describe the elements of your software system, their relationships, and how they should be visualized. Here are some key components:

  • Model Definition: Defines the people, software systems, containers, components, and their relationships.
{
    "model": {
        "people": [
            {
                "id": "1",
                "name": "User",
                "description": "A user of my software system."
            }
        ],
        "softwareSystems": [
            {
                "id": "2",
                "name": "Software System",
                "description": "My software system."
            }
        ],
        "relationships": [
            {
                "sourceId": "1",
                "destinationId": "2",
                "description": "Uses"
            }
        ]
    }
}
  • Views: Specifies how the elements should be visualized in different diagrams (e.g., system context, container, component diagrams).
{
    "views": {
        "systemContextViews": [
            {
                "softwareSystemId": "2",
                "description": "System Context diagram for Software System",
                "elements": [
                    { "id": "1" },
                    { "id": "2" }
                ],
                "automaticLayout": true
            }
        ],
        "styles": {
            "elements": [
                {
                    "tag": "Software System",
                    "background": "#1168bd",
                    "color": "#ffffff"
                },
                {
                    "tag": "Person",
                    "shape": "person",
                    "background": "#08427b",
                    "color": "#ffffff"
                }
            ]
        }
    }
}

workspace.json

Both files serve the same purpose but cater to different use cases. The DSL file is more human-readable and easier to write manually, while the JSON file is better suited for automated processing and integration with other tools.

The web app, Structurizr Lite generates and maintains this file and you should very rarely need to view or update this file manually.

.structurizr folder

The .structurizr folder is a directory used by Structurizr to store various configuration and data files related to your workspace such as images, other assets, logs and temporary files used during operations. This is managed by the app should not be interferred with 馃槈

Tips for building you model

Add a title and description to your workspace

workspace "Model Title Here" "Add a description of the model here" {

    model {
    ...
    }
    views {
    ...
    }
    configuration {
    ...
    }

Set Identifiers as hierarchical

By default, all identifiers are treated as being globally scoped, however by using the !identifiers keyword you can specify that element identifiers should be treated as hierarchical (relationship identifiers are unaffected by this setting).

workspace {

    !identifiers hierarchical

    model {
        softwareSystem1 = softwareSystem "Software System 1" {
            api = container "API"
        }

        softwareSystem2 = softwareSystem "Software System 2" {
            api = container "API"
        }
    }
}

So now each api can have the same local name, but be referenced softwareSystem1.api and softwareSystem2.api respectively.

see Identifiers | Structurizr

Add your users/personas at the top

Syntax

person <name> [description] [tags] {
    // Define properties and relationships here
}

Example

model {
        customer = person "Online Shopping Customer" "A customer"
        picker = person "Picker" "A warehouse picker" "Warehouse"
        whmanager= person "Warehouse Manager" "A warehouse manager" "Warehouse"
        despatcher = person "Despatch Operator" "A warehouse despatcher" "Warehouse"
        ...

Then your high level systems

In Structurizr DSL, the softwareSystem element is used to define a software system within your architecture model. A software system represents a major part of your overall system, typically encompassing multiple containers and components.

Syntax

softwareSystem <name> [description] [tags] {
    // Define properties and relationships here
}

Example

webapp= softwaresystem "Store Web App" "The main web store" "Existing System"
email = softwaresystem "E-mail System" "The internal Microsoft Exchange e-mail system." "Existing System"
atm = softwaresystem "ATM" "Allows customers to withdraw cash." "Existing System"

Use Groups

The group element is used to define a named grouping of elements, which will be rendered as a boundary around those elements. This is useful for organizing and visually separating different parts of your architecture model.

Example

workspace {
    model {
        group "Company 1" {
            a = softwareSystem "System A" "Description of System A."
        }
        group "Company 2" {
            b = softwareSystem "System B" "Description of System B."
        }
        a -> b "Uses"
    }
    views {
        systemLandscape {
            include *
            autoLayout lr
        }
        styles {
            element "Group" {
                color #ff0000
            }
        }
    }
}

Use the Azure or other custom themes

The theme element is used to apply a set of predefined styles to your diagrams and is used in the views section. Themes help you maintain a consistent look and feel across your diagrams, especially when using common visual styles for elements and relationships.

Syntax

theme <url>

Example of the Azure theme

Here I have also added some shapes that get overridden by the Azure theme.

views {
        // theme default
        themes https://static.structurizr.com/themes/microsoft-azure-2021.01.26/theme.json
        // https://structurizr.com/help/theme?url=https://static.structurizr.com/themes/microsoft-azure-2021.01.26/theme.json
        styles {
            element "Database" {
                shape "cylinder"
            }
            element "Person" {
                shape "Person"
                background "#08427b"
                color "#ffffff"
            }
            element "Software System" {
                background "#1168bd"
                color "#ffffff"
            }
        }
        ...

For more themes see Themes | Structurizr

Allow manual layout in Structurizr Lite

The autolayout element is used to automatically arrange the elements in a view, making it easier to create well-organized and visually appealing diagrams without manually positioning each element.

Syntax

autolayout [direction] [rankSeparation] [nodeSeparation]
  • Direction: Specifies the direction of the layout. Possible values are:
    • lr (left to right)
    • rl (right to left)
    • tb (top to bottom)
    • bt (bottom to top)
  • Rank Separation: (Optional) Specifies the separation between ranks (levels) in the layout. Default is 300.
  • Node Separation: (Optional) Specifies the separation between nodes (elements) in the layout. Default is 300

When you add this to a view definition, you will not be able to manually reposition the shapes in the GUI. Just remove or remark the line to allow it.

views {        
       systemLandscape "SystemLandscape" {
            include *
            // autolayout lr
        }

        systemContext eCommerceSystem "SystemContext" {
            include *
            autolayout lr
        }
        ...

Here the SystemLandscape is manually arranged and the eCommerceSystem is arrange left-to-right.

Notes, Examples and References

Structuring YAML Pipelines and nested variables in Azure DevOps

When managing pipelines for large and complex repositories with multiple ‘Platforms’, each containing multiple apps and services, then the folder structure and variable strategy can be complicated. However, if this is done right, then the payoff for template reuse is dramatic.

Here, I outline my approach on the pipeline folder and YAML structure only. The variable structure allows for a full set of naming conventions to easily default across all your projects apps and delegate standards to organisation and platform levels. This, ideally, leaves only app specific configurations for your dev teams to manage and worry about.

This strategy rests on top of my general approach to structuring a mono-repository. For more details on that see Mono-repository folder structures.

Continue reading “Structuring YAML Pipelines and nested variables in Azure DevOps”

Azure Key Vault – List Secrets that have not been set

When working across multiple environments, with restricted access, it can by difficult tracking which Key Vault secrets have been configured and which still need values set.

On my projects I like to give the dev teams autonomy on the dev environments setting their own Key Vault Keys and Secrets (following an agreed convention).

Periodically we then copy these keys to the other environment Key Vaults (see my other post on https://home-5012866824.webspace-host.com/wordpress/2021/08/09/azure-key-vault-script-for-copying-secrets-from-one-to-another/ )

The following script can be run to output all the Key Vault secrets, or only show the ones that need configuration. It can also be set to output pure markdown like…

# hms-sapp-kv-qa-ne (2021-07-03 14:31:04.590)

| Secret Name                                           | Needs Configuration                    |
|-------------------------------------------------------|----------------------------------------|
| apim-api-subscription-primary-key                     | ******                                 |
| apim-tests-subscription-primary-key                   | ******                                 |
| b2c-client-id-extensions-app                          | ******                                 |
| b2c-client-id-postman                                 | ******                                 |
| saleforce-client-id                                   | Needs Configuration                    |
| salesforce-client-secret                              | Needs Configuration                    |
| sql-database-ro-password                              | ******                                 |
| sql-database-rw-password                              | ******                                 |

that can be copy and pasted into the Azure or Github Wiki too and displays like the table below when rendered.

hms-sapp-kv-qa-ne (2021-07-03 14:31:04.590)

Secret Name Needs Configuration
apim-api-subscription-primary-key ******
apim-tests-subscription-primary-key ******
b2c-client-id-extensions-app ******
b2c-client-id-postman ******
saleforce-client-id Needs Configuration
salesforce-client-secret Needs Configuration
sql-database-ro-password ******
sql-database-rw-password ******

The script will output ‘Needs Configuration’ for any Secret values that are either blank, contain ‘Needs Configuration’ or ‘TBC’.

Script below, aslo contains examples:

<#PSScriptInfo
.VERSION 1.1
.GUName 48b4b27a-b77e-41e6-8a37-b3767da5caee
.AUTHOR Nicholas Rogoff

.RELEASENOTES
Initial version.
#>
<# 
.SYNOPSIS 
Copy Key Vault Secrets from one Vault to another. If the secret name exists it will NOT overwrite the value. 
 
.DESCRIPTION 
Loops through all secrets and copies them or fills them with a 'Needs Configuration'.
Will not copy secrets of ContentType application/x-pkcs12

PRE-REQUIREMENT
---------------
Run 'Import-Module Az.Accounts'
Run 'Import-Module Az.KeyVault'
You need to be logged into Azure and have the access necessary rights to both Key Vaults.

.INPUTS
None. You cannot pipe objects to this!

.OUTPUTS
None.

.PARAMETER SrcSubscriptionName
This is the Source Subscription Name

.PARAMETER SrcKvName
The name of the Source Key Vault

.PARAMETER MarkdownOnly
Output Markdown Only

.PARAMETER NameOnly
Set to only copy across the secret name and NOT the actual secret. The secret will be populated with 'Needs Configuration'

.NOTES
  Version:        1.1
  Author:         Nicholas Rogoff
  Creation Date:  2021-08-09
  Purpose/Change: Refined for publication
   
.EXAMPLE 
PS> .\List-UnSet-KeyVault-Secrets.ps1 -SrcSubscriptionName $srcSubscriptionName -SrcKvName $srcKvName 
This will list the secrets keys in the specified key vault and diplay 'Needs Configuration' for those that are blank, contain 'Needs Configuration' or 'TBC'

.EXAMPLE 
PS> .\List-UnSet-KeyVault-Secrets.ps1 -SrcSubscriptionName $srcSubscriptionName -SrcKvName $srcKvName -MarkdownOnly
Use for updating the Wiki. This will list the secrets keys, as MarkDown ONLY, in the specified key vault and diplay 'Needs Configuration' for those that are blank, contain 'Needs Configuration' or 'TBC'


.EXAMPLE 
PS> .\List-UnSet-KeyVault-Secrets.ps1 -SrcSubscriptionName $srcSubscriptionName -SrcKvName $srcKvName -ShowSecrets
This will list the secrets keys in the specified key vault and show the secrets


#>
#---------------------------------------------------------[Script Parameters]------------------------------------------------------
[CmdletBinding()]
Param(
  [Parameter(Mandatory = $true, HelpMessage = "This is the Source Subscription Name")]
  [string] $SrcSubscriptionName,
  [Parameter(Mandatory = $true, HelpMessage = "The name of the Source Key Vault")]
  [string] $SrcKvName,
  [Parameter(Mandatory = $false, HelpMessage = "Output Markdown Only")]
  [switch] $MarkdownOnly,
  [Parameter(Mandatory = $false, HelpMessage = "Output the secrets set")]
  [switch] $ShowSecrets
)

#---------------------------------------------------------[Initialisations]--------------------------------------------------------

# Set Error Action to Silently Continue
$ErrorActionPreference = 'Continue'

# Set Warnings Action to Silently Continue
$WarningPreference = "SilentlyContinue"

#----------------------------------------------------------[Declarations]----------------------------------------------------------
# Any Global Declarations go here

$SecretsFound = @()

#----------------------------------------------------------[Functions]----------------------------------------------------------

# Inline If Function
Function IIf($If, $Right, $Wrong) { If ($If) { $Right } Else { $Wrong } }

#-----------------------------------------------------------[Execution]------------------------------------------------------------

$checked = 0
$failed = 0

if (!$MarkdownOnly) {
  Write-Host "======================================================"
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Starting to check secrets in " + $SrcKvName + "... ") -ForegroundColor Blue
  Write-Host "======================================================"
}
else {
  Write-Host ("# " + $SrcKvName + " (" + $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + ")") -ForegroundColor Blue
}
Write-Host ""



$sourceSecrets = Get-AzKeyVaultSecret -VaultName $SrcKvName | Where-Object { $_.ContentType -notmatch "application/x-pkcs12" }

if (!$MarkdownOnly) {
  # Headers
  Write-Host -NoNewline "|".PadRight(60, "-")
  Write-Host -NoNewline "|".PadRight(60, "-")
  Write-Host "|"
}

Write-Host -NoNewline "| Secret Name".PadRight(60)
if (!$ShowSecrets) {
  Write-Host -NoNewline "| Needs Configuration".PadRight(60)  
}
else {
  Write-Host -NoNewline "| Secret Value".PadRight(60)      
}
Write-Host "|"

Write-Host -NoNewline "|".PadRight(60, "-")
Write-Host -NoNewline "|".PadRight(60, "-")
Write-Host "|"

ForEach ($sourceSecret in $sourceSecrets) {
  $Error.clear()

  $name = $sourceSecret.Name
  $plainTxtSecret = Get-AzKeyVaultSecret -VaultName $srckvName -Name $name -AsPlainText

  if ($plainTxtSecret -eq "Needs Configuration" -or $plainTxtSecret -eq "TBC" -or !$plainTxtSecret) {
    $secretToShow = $plainTxtSecret
  }
  elseif ($ShowSecrets) {
    $secretToShow = $plainTxtSecret
  }
  else {
    $secretToShow = "******"
  }

  Write-Host -NoNewline "| $name".PadRight(60)
  if ($secretToShow -eq "Needs Configuration" -or $plainTxtSecret -eq "TBC" -or !$plainTxtSecret) {
    Write-Host -NoNewline "| $secretToShow".PadRight(60) -ForegroundColor Magenta  
  }
  else {
    Write-Host -NoNewline "| $secretToShow".PadRight(60) -ForegroundColor DarkGray      
  }
  Write-Host "|"

  if (!$Error[0]) {
    $checked += 1
  }
  else {
    $failed += 1
    Write-Error "!! Failed to get secret $name"
  }
}

if (!$MarkdownOnly) {
  Write-Host -NoNewline "|".PadRight(60, "-")
  Write-Host -NoNewline "|".PadRight(60, "-")    
  Write-Host "|"

  Write-Host ""
  Write-Host ""
  Write-Host "================================="
  Write-Host "Completed Key Vault Secrets Copy"
  Write-Host "Checked: $checked"
  Write-Host "Failed: $failed"
  Write-Host "================================="
}
else {
  Write-Host ""
  Write-Host ("**Total Secrets Listed: " + $checked + "**")
  if ($failed -gt 0) {
    Write-Host "Failed: $failed" -ForegroundColor Red
  } 
  Write-Host ""
}
How to synchronize a large Microsoft (Office) 365 Group membership with Teams using PowerShell

How to synchronize a large Microsoft (Office) 365 Group membership with Teams using PowerShell

Microsoft Teams Logo

Although Microsoft have improved the use of Security and Distribution Group usage in managing large Team memberships, there are still constraints and limitation that may require a scripted solution (see https://docs.microsoft.com/en-us/microsoftteams/best-practices-large-groups).

For Teams limits see https://docs.microsoft.com/en-us/microsoftteams/limits-specifications-teams

You can add a Group (only some types of AD or Microsoft 365 Groups) to a Team, but this will only add the members in a one time action. It will not maintain the membership in sync.

It gets complicated, but you can make a Team from a Microsoft (Office) 365 Group and have it’s membership dynamic, linked to an AD group (see https://docs.microsoft.com/en-us/microsoftteams/dynamic-memberships ), but if you group is large (over 10,000) this can still also run into problems and the group can’t have additional members not part of that group (outsiders!).

I had a client that required a Team to include the membership of an Group that contained over 4,000 members. They also did NOT want members that had made the effort to join the Team, but were not members of the Microsoft 365 group to be removed.

The whole organization was much larger, so much so that they were not able to even create an ‘Org-wide Team’ which allows you to include everyone automatically in a Team up to 5,000 ( see https://docs.microsoft.com/en-us/microsoftteams/create-an-org-wide-team ). We had lots of fun and games with the limits of teams, but came to the following solution.

To get over these hurdles, in the end it was easier to use a PowerShell script and schedule the synching of the members. The following script can be used to add missing members of a Microsoft 365 AD group to a Teams membership. You can also choose whether you want to remove users that are no longer a member of the AD group or just leave them be 馃槈

Save the script below as Sync-Team-Members-With-AD-Group.ps1 and run the following, replacing the variables as appropriate

Sync-Team-Members-With-AD-Group.ps1
$ADGroupId = "67h3rc03e286-FAKE-ID HERE-8d1c-7b176431"
$TeamDisplayName = "My Big Team"

.\Sync-Team-Members-With-AD-Group.ps1  -ADGroupId $ADGroupId -TeamDisplayName $TeamDisplayName聽-Credential $Credential -RemoveInvalidTeamMembers $false -Verbose

from a PowerShell terminal. You will need to get the ObjectId of the Azure AD Group and an administrator should be able to help you if you don’t know it.

See the comments in the script for more options, such as limiting the number of users to process etc..

<#PSScriptInfo
.VERSION 1.1
.GUID 21a6ad93-df53-4a1a-82fd-4a902cb57350
.AUTHOR Nicholas Rogoff

.RELEASENOTES
Initial version.
#>
<#聽
.SYNOPSIS聽
Synchronizes Team membership with an Azure AD Group.聽
聽
.DESCRIPTION聽
Loops through all members of an AD group and adds any missing users to the membership. 
The script then loops through all the existing Team members and removes any that are no longer in the AD Group.

NOTE: This script will NOT remove Owners.

PRE-REQUIREMENT
---------------
Install Teams Module (MS Online)
PS> Install-Module -Name MSOnline

Install Microsoft Teams cmdlets module
PS> Install-Module -Name MicrosoftTeams

.INPUTS
None. You cannot pipe objects to this!

.OUTPUTS
None.

.PARAMETER ADGroupId
This is the Object Id of the Active Directory group you wish to populate the Team with

.PARAMETER TeamDisplayName
The display name of the Team who's membership you wish to alter

.PARAMETER Credential
The credentials (PSCredential object) for an Owner of the Team. Use '$Credential = Get-Credential' to prompt and store the credentials to securely pass

.PARAMETER MaxUsers
Max number of AD Group Users to process. This can be used to test a small batch. Set to 0 to process all members of the AD group

.PARAMETER RemoveInvalidTeamMembers
Default = False. All members of the Team that have are not part of the AD Group will remain members of the Team. E.g. Add-only. 
If you do want sync the membership of the Team exactly, e.g. to remove any Team Members that are not part of the AD group, then set this to True

.NOTES
  Version:        1.1
  Author:         Nicholas Rogoff
  Creation Date:  2020-10-08
  Purpose/Change: Added more detailed help
  聽
.EXAMPLE聽
PS>聽.\Sync-Team-Members-With-AD-Group.ps1 -ADGroupId "4b3f7f45-e8e3-47af-aa82-ecaf41f5e78d" -TeamDisplayName "My Team" -Credential $Credential
This will add all missing members of the AD Group to the Team

.EXAMPLE
PS>聽.\Sync-Team-Members-With-AD-Group.ps1 -ADGroupId "4b3f7f45-e8e3-47af-aa82-ecaf41f5e78d" -TeamDisplayName "My Team"聽-Credential $Credential -MaxUsers 10 -Verbose
This will add all missing members of the first 10 members AD Group to the Team and will output verbose details

.EXAMPLE聽
PS>聽.\Sync-Team-Members-With-AD-Group.ps1 -ADGroupId "4b3f7f45-e8e3-47af-aa82-ecaf41f5e78d" -TeamDisplayName "My Team" -Credential $Credential -RemoveInvalidTeamMembers
This will add all missing members of the AD Group to the Team, and REMOVE any members of the Team that are not in the AD Group (Except for Team Owners)

#>
#---------------------------------------------------------[Script Parameters]------------------------------------------------------
[CmdletBinding()]
Param(
  [Parameter(Mandatory = $true, HelpMessage = "This is the Object Id of the Azure Active Directory group you wish to populate the Team with")]
  [string] $ADGroupId,
  [Parameter(Mandatory = $true, HelpMessage = "The display name of the Team")]
  [string] $TeamDisplayName,
  [Parameter(Mandatory = $true, HelpMessage = "The credentials for an Owner of the Team")]
  [System.Management.Automation.PSCredential] $Credential,
  [Parameter(Mandatory = $false, HelpMessage = "Max number of AD Group Users to process")]
  [int] $MaxUsers = 0,
  [Parameter(Mandatory = $false, HelpMessage = "Default = False. If you do want to remove any Team Members that are not part of the AD group, then set this to True")]
  [bool] $RemoveInvalidTeamMembers = $false
)

#---------------------------------------------------------[Initialisations]--------------------------------------------------------
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Starting synchonisation") -ForegroundColor Blue

# Set Error Action to Silently Continue
$ErrorActionPreference = 'Continue'

# Signin to Office 365 stuff
Connect-MicrosoftTeams -Credential $Credential
Connect-MsolService -Credential $Credential

#----------------------------------------------------------[Declarations]----------------------------------------------------------

if (Get-Module -ListAvailable -Name MSOnline) {
  Write-Host "MSOnline Module exists"
} 
else {
  throw "MSOnline Module does not exist. You can install it by using 'Install-Module -Name MSOnline'"
}

if (Get-Module -ListAvailable -Name MicrosoftTeams) {
  Write-Host "MicrosoftTeams Module exists"
} 
else {
  throw "MicrosoftTeams Module does not exist. You can install it by using 'Install-Module -Name MicrosoftTeams'"
}

#----------------------------------------------------------[Functions]----------------------------------------------------------

function Add-MissingTeamMembers {
  [CmdletBinding()]
  Param(
    [Parameter(Mandatory = $true, HelpMessage = "This is the Team to add the users to")]
    [Microsoft.TeamsCmdlets.PowerShell.Custom.Model.TeamSettings] $team,
    [Parameter(Mandatory = $true, HelpMessage = "This is the AD Group membership from which to add missing members")]
    [Microsoft.Online.Administration.GroupMember[]] $ADGroupMembers
  )
  $TeamMembersAdded = [System.Collections.ArrayList]@()
  #Add missing members
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Checking membership of Team: " + $team.DisplayName + " ( " + $team.GroupId + " ) ") -ForegroundColor Yellow
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Against AD Group: " + $ADGroup.DisplayName + " ( " + $ADGroup.ObjectId + " ) ") -ForegroundColor Yellow
  Write-Host ("--------------------------------------------------------")
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Team Membership Total: " + $ExistingTeamMembers.count) -ForegroundColor Yellow
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " AD Group Membership Total: " + $ADGroupMembers.count) -ForegroundColor Yellow
  foreach ($groupMember in $ADGroupMembers) {
    #Check if exists in Teams already
    if ((($ExistingTeamMembers | Select-Object User) -Match $groupMember.EmailAddress).Count -eq 0 ) {
      #Add missing member
      Add-TeamUser -GroupId $team.GroupId -User $groupMember.EmailAddress
      Write-Verbose ("+ Added: " + $groupMember.EmailAddress)
      $TeamMembersAdded.Add($groupMember)
    }
    else {
      Write-Verbose ("| Existed: " + $groupMember.EmailAddress)
    }
  }
  Write-Host ("=====================")
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $TeamMembersAdded.count + " new members added") -ForegroundColor Yellow
  Write-Host ("")
}

function Remove-MissingADUsers {
  [CmdletBinding()]
  Param(
    [Parameter(Mandatory = $true, HelpMessage = "This is the list of existing Team users you want to search. The full type should be Microsoft.TeamsCmdlets.PowerShell.Custom.GetTeamUser+GetTeamUserResponse")]
    [object] $ExistingTeamMembers,
    [Parameter(Mandatory = $true, HelpMessage = "This is the AD Group membership from which to check against for invalid team members")]
    [Microsoft.Online.Administration.GroupMember[]] $ADGroupMembers
  )
  $TeamMembersRemoved = [System.Collections.ArrayList]@()
  # Now check for existing Team members that are no longer AD Group members
  foreach ($teamMember in $ExistingTeamMembers) {
    #Check if exists in Teams already
    if (((($ADGroupMembers | Select-Object EmailAddress) -Match $teamMember.User).Count -eq 0) -and ($teamMember.Role -notmatch "owner") ) {
      #Remove from team
      Remove-TeamUser -GroupId $team.GroupId -User $teamMember.User
      $TeamMembersRemoved.Add($teamMember)
      Write-Verbose (" - Removed: " + $teamMember.User)
    }
    else {
      Write-Verbose (" | Not removed: " + $teamMember.User)
    }
  }
  Write-Host ("---------------------")
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $TeamMembersRemoved.Count + " Team members removed") -ForegroundColor Yellow
}

#-----------------------------------------------------------[Execution]------------------------------------------------------------

# Get Team
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Getting the Team..." + $TeamDisplayName) -ForegroundColor Blue
$team = Get-Team -DisplayName $TeamDisplayName
# Get AD / Outlook Group Members
$ADGroup = Get-MsolGroup -ObjectId $ADGroupId
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Getting the AD Group..." + $ADGroup.DisplayName) -ForegroundColor Blue
if ($MaxUsers -gt 0) {
  $ADGroupMembers = Get-MsolGroupMember -GroupObjectId $ADGroupId -MaxResults $MaxUsers
}
else {
  $ADGroupMembers = Get-MsolGroupMember -GroupObjectId $ADGroupId -All
}
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $ADGroupMembers.Count + " ...AD Group Members fetched" ) -ForegroundColor Blue
#Get existing Team members
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Getting the latest Team members..." + $TeamDisplayName) -ForegroundColor Blue
$ExistingTeamMembers = Get-TeamUser -GroupId $team.GroupId
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $ADGroupMembers.Count + " ...Team Members fetched") -ForegroundColor Blue
Add-MissingTeamMembers -team $team -ADGroupMembers $ADGroupMembers
if (($RemoveInvalidTeamMembers) -and ($MaxUsers -eq 0)) {
  Remove-MissingADUsers -ExistingTeamMembers $ExistingTeamMembers -ADGroupMembers $ADGroupMembers
}

Write-Host ("=====================")
Write-Host ("****** Completed ******") -ForegroundColor Blue

Ideally, you should have this script run on a scheduled basis to keep things up to date. I may post a blog later on how to create a robust scheduler solution for your whole organisation to implement Team membership synching.

Reboot all the VM’s in a Windows Virtual Desktop Host Pool…safely ;-)

I have found that the session hosts often end up reporting a status of ‘Needs Assistance’. This can be caused by updates having been applied that require a reboot to complete…and other unknown issues. Often a reboot will sort them out. So I developed a simple script to assist.

The following script will allow you to specifiy the only the VM’s in a Host Pool and only those that ‘Need Assistance’ and that have no active sessions on too….or not!

Save the full script to a file called RebootHosts.ps1

<#PSScriptInfo
.VERSION 1.0
.GUID b053571a-b9f4-445d-ac05-45e184cf6f90
.AUTHOR Nicholas Rogoff
.RELEASENOTES

#>
<#
.SYNOPSIS
  Reboots VMs in a HostPool
.DESCRIPTION
  This will iterate through the VMs registered to a host pool and reboot them. 
.NOTES
  Version:        1.0
  Author:         Nicholas Rogoff
  Creation Date:  2020-09-03
  Purpose/Change: Initial script development

.EXAMPLE
  .\RebootHosts.ps1 -HostPoolName "my-host-pool" -HostPoolResourceGroupName "my-host-pool-rg" -OnlyDoIfNeedsAssistance -SkipIfActiveSessions
#>

#---------------------------------------------------------[Script Parameters]------------------------------------------------------
[CmdletBinding()]
Param (
    #Script parameters go here
    [Parameter(mandatory = $true)]
    [string]$HostPoolName,

    [Parameter(mandatory = $true)]
    [string]$HostPoolResourceGroupName,
    
    [Parameter(mandatory = $false)]
    [switch]$SkipIfActiveSessions,

    [Parameter(mandatory = $false)]
    [switch]$OnlyDoIfNeedsAssistance
)

#---------------------------------------------------------[Initialisations]--------------------------------------------------------

#Set Error Action to Silently Continue
$ErrorActionPreference = 'SilentlyContinue'

#----------------------------------------------------------[Declarations]----------------------------------------------------------

#Any Global Declarations go here

#-----------------------------------------------------------[Functions]------------------------------------------------------------


#-----------------------------------------------------------[Execution]------------------------------------------------------------

Write-Output "Starting to Enable Boot Diagnostics for VMs in Host Pool $HostPoolName ..."
if ($OnlyDoIfNeedsAttention) {
    Write-Output "!! Only hosts flagged as 'Needs Assistance' will be rebooted !!"
}
if ($SkipIfActiveSessions) {
    Write-Output "!! Only hosts with zero sessions will be rebooted !!"
}

$rebooted = 0
$skippedSessions = 0
$skippedOK = 0
$shutdown = 0

$sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $HostPoolResourceGroupName -HostPoolName $HostPoolName
foreach ($sh in $sessionHosts) {
    

    # Name is in the format 'host-pool-name/vmname.domainfqdn' so need to split the last part
    $VMName = $sh.Name.Split("/")[1]
    $VMName = $VMName.Split(".")[0]
    
    $Session = $sh.Session
    $Status = $sh.Status
    $UpdateState = $sh.UpdateState
    $UpdateErrorMessage = $sh.UpdateErrorMessage

    Write-output "=== Starting Reboot for VM: $VMName"
    Write-output "Session: $Session"
    Write-output "Status: $Status"
    Write-output "UpdateState: $UpdateState"
    Write-output "UpdateErrorMessage: $UpdateErrorMessage"

    if ($Status -ne "Unavailable") {
        if ($Status -ne "NeedsAssistance" -and $OnlyDoIfNeedsAssistance ) {
            $skippedOK += 1
            Write-output "!! The VM '$VMName' is not in 'Needs Assistance' state, so will NOT be rebooted. !!"       
        }
        elseif ($SkipIfActiveSessions -and $Session -gt 0) {
            $skippeSessions += 1
            Write-output "!! The VM '$VMName' has $Session session(s), so will NOT be rebooted. !!"       
        }
        else {
            $rebooted += 1
            Restart-AzVM -ResourceGroupName $HostPoolResourceGroupName -Name $VMName
            Write-output "=== Reboot initiated for VM: $VMName"       
        }
    }
    else {
        $shutdown += 1
        Write-output "!! The VM '$VMName' must be started in order to reboot it. !!"       
    }

}

Write-Output ""
Write-Output "============== Completed =========================="
Write-Output "Skipped due Not needing attention: $skippedOK"
Write-Output "Skipped due to active sessions: $skippedSessions"
Write-Output "Host not started: $shutdown"
Write-Output "Rebooted: $rebooted"
Write-Output "==================================================="

You will need to log in to Azure and select your subscription in the normal way using:

Login-AzAccount
Select-AzSubscription "my-subscription"

You can then simply run the script as follows:

.\RebootHosts.ps1 -HostPoolName "my-host-pool" -HostPoolResourceGroupName "my-host-pool-rg" -OnlyDoIfNeedsAssistance -SkipIfActiveSessions