Jekyll2023-12-22T05:16:53+00:00http://stealthpuppy.com/feed.xmlAaron ParkerPrincipal Modern Workplace Architect and EUC practice owner @Insentra, on end user computing, modern device management, enterprise mobility, and automation.
Aaron Parkeraaron@stealthpuppy.comValidate UAT Images with Azure Pipelines and Pester2023-12-20T00:03:00+00:002023-12-20T00:03:00+00:00http://stealthpuppy.com/user-acceptance-testing-for-vdi-with-azure-devops<ul id="markdown-toc">
<li><a href="#pester--azure-pipelines" id="markdown-toc-pester--azure-pipelines">Pester + Azure Pipelines</a></li>
<li><a href="#testing-with-pester" id="markdown-toc-testing-with-pester">Testing with Pester</a> <ul>
<li><a href="#single-application-test" id="markdown-toc-single-application-test">Single Application Test</a></li>
<li><a href="#multiple-application-tests" id="markdown-toc-multiple-application-tests">Multiple Application Tests</a> <ul>
<li><a href="#input-file" id="markdown-toc-input-file">Input File</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#azure-pipelines" id="markdown-toc-azure-pipelines">Azure Pipelines</a> <ul>
<li><a href="#agent-pool" id="markdown-toc-agent-pool">Agent Pool</a></li>
<li><a href="#pipeline" id="markdown-toc-pipeline">Pipeline</a></li>
<li><a href="#installing-the-azure-pipelines-agent" id="markdown-toc-installing-the-azure-pipelines-agent">Installing the Azure Pipelines agent</a></li>
<li><a href="#running-the-pipeline" id="markdown-toc-running-the-pipeline">Running the Pipeline</a></li>
</ul>
</li>
<li><a href="#results" id="markdown-toc-results">Results</a></li>
<li><a href="#getting-started" id="markdown-toc-getting-started">Getting Started</a></li>
</ul>
<p>As with any desktop environment, virtual desktops undergo regular change - monthly OS and application updates, new applications, and configurations all add to the variation. Change must be managed and should be well tested to ensure business services are not impacted with a new or updated image.</p>
<p>Pooled virtual desktops that are deployed from a gold image are useful for managing change at scale - the user environment is separate from the desktop and users can connect to any available desktop in a pool, so all virtual machines must run the same image.</p>
<p>The gold image build and change process can be automated on any platform and version of Windows, but automation can be a time consuming process. Investing in an image automation process will save your bacon when it counts.</p>
<p>A management and validation process is required to manage an image and this process will look similar to this:</p>
<p class="lead">BUILD > VALIDATE > USER ACCEPTANCE TESTING > DEPLOY</p>
<p>When a gold image is updated and deployed, most organisations will rely on manual user acceptance testing before promoting that image into production. Adding automated testing in the VALIDATE phase ensures you can capture things users won’t, or speed the mundane task of manually validating your images.</p>
<p>There are several commercial solutions that can automate application testing (for example, <a href="https://www.rimo3.com/">Rimo3</a>), but you may want to augment these with additional valiatdation tests. For example, testing that your image includes the intended applications, application versions, files, registry settings or service status.</p>
<h2 id="pester--azure-pipelines">Pester + Azure Pipelines</h2>
<p>With Pester and Azure Pipelines, we can create an image test framework that runs on a target virtual machine and generates reports to track the results. This approach uses a standard and well supported test framework, along with Azure Pipelines which is available in most enterprises (although, you could replace Azure Pipelines with just about any CI/CD service).</p>
<p>Here’s a look at what we’re going to build:</p>
<p><a href="/media/2023/12/PesterTestsPassed100.jpeg"><img src="/media/2023/12/PesterTestsPassed100.jpeg" alt="A screenshot of a successful Azure Pipeline run" /></a></p>
<p class="figcaption">Azure Pipelines result with 100% of tests passed.</p>
<p>Building this solution will involve a few components:</p>
<ul>
<li>PowerShell and <a href="https://pester.dev">Pester</a>, the testing framework in which we can write our tests</li>
<li><a href="https://stealthpuppy.com/evergreen/">Evergreen</a> to provide application version numbers - regardless of how you install applications, Evergreen enables you to query an image to determine whether it’s running the latest version of an application</li>
<li>JSON to define our tests so that we don’t have to hard code all tests in Pester</li>
<li>Azure DevOps to store the tests and code</li>
<li>Azure Pipelines and <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/agents?view=azure-devops&tabs=yaml%2Cbrowser#install">self-hosted agents</a> to run tests against our UAT images</li>
</ul>
<p>Note that in this example, I’m using Azure Virtual Desktop and Nerdio Manager to create images and run session hosts; however, this approach will work with any VDI solution including those deployed on-premises or in a public cloud platform.</p>
<h2 id="testing-with-pester">Testing with Pester</h2>
<h3 id="single-application-test">Single Application Test</h3>
<p>Pester can be used to perform an application configuration test. Here’s a simple example of using Pester to ensure that the default home page for Microsoft Edge has been set in the image.</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Describe</span><span class="w"> </span><span class="s2">"Microsoft Edge"</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="n">Context</span><span class="w"> </span><span class="s2">"Application preferences"</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have written the correct content to master_preferences"</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">${Env:ProgramFiles(x86)}</span><span class="s2">\Microsoft\Edge\Application\master_preferences"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="nf">homepage</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-BeExactly</span><span class="w"> </span><span class="s2">"https://www.microsoft365.com"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>This will generate a pass / fail result allowing us to determine whether the image has the correct configuration for Edge. If it fails, we know our image isn’t ready for UAT and can’t be pushed into production.</p>
<p>Additional tests can be written just like this example. Storing application tests in one Pester file per application can be a way to ensure those tests are highly portable between image configurations.</p>
<h3 id="multiple-application-tests">Multiple Application Tests</h3>
<p>Our tests will be more scalable if a single script can take input that defines tests for multiple applications. We can scale out our test results for each application without having to write additional code.</p>
<p><a href="/media/2023/12/Pester02.png"><img src="/media/2023/12/Pester02.png" alt="Pester testing of applications" /></a></p>
<p class="figcaption">Pester file to run tests against a set of applications.</p>
<p>For example, here’s a Pester block that takes input to test various properties of an application:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Context</span><span class="w"> </span><span class="s2">"Application configuration tests"</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="n">It</span><span class="w"> </span><span class="s2">"Should be the current version or better"</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="p">[</span><span class="n">System.Version</span><span class="p">]</span><span class="nv">$Installed</span><span class="o">.</span><span class="nf">Version</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-BeGreaterOrEqual</span><span class="w"> </span><span class="p">([</span><span class="n">System.Version</span><span class="p">]</span><span class="nv">$Latest</span><span class="o">.</span><span class="nf">Version</span><span class="p">)</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have application file installed: <_>"</span><span class="w"> </span><span class="nt">-ForEach</span><span class="w"> </span><span class="nv">$FilesExist</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="bp">$_</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-Exist</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have shortcut deleted or removed: <_>"</span><span class="w"> </span><span class="nt">-ForEach</span><span class="w"> </span><span class="nv">$ShortcutsNotExist</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="bp">$_</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-Exist</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">It</span><span class="w"> </span><span class="s2">"Should have the service disabled: <_>"</span><span class="w"> </span><span class="nt">-ForEach</span><span class="w"> </span><span class="nv">$ServicesDisabled</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="p">(</span><span class="n">Get-Service</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="bp">$_</span><span class="p">)</span><span class="o">.</span><span class="nf">StartType</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-Be</span><span class="w"> </span><span class="s2">"Disabled"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>This block does a few things:</p>
<ol>
<li>Compares the installed version of the application against the latest version returned by Evergreen</li>
<li>Tests that an array of files exists (i.e. key files exist in the expected locations)</li>
<li>Tests that an array of shortcuts do not exist (to test that an install script has removed shortcuts)</li>
<li>Tests that an array of services has been disabled (some applications include services that are recommended to be disabled in VDI images)</li>
</ol>
<h4 id="input-file">Input File</h4>
<p>The Pester script takes input via a JSON file that describes the tests for applications in our image. The script and the input file could be extended to test practically anything in the image. The example input file below lists tests for 3 applications to determine that the application is installed, is current, has files in the expected directories, and services in a disabled state:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MicrosoftFSLogixApps"</span><span class="p">,</span><span class="w">
</span><span class="nl">"Filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get-EvergreenApp -Name </span><span class="se">\"</span><span class="s2">MicrosoftFSLogixApps</span><span class="se">\"</span><span class="s2"> | Where-Object { $_.Channel -eq </span><span class="se">\"</span><span class="s2">Production</span><span class="se">\"</span><span class="s2"> } | Select-Object -First 1"</span><span class="p">,</span><span class="w">
</span><span class="nl">"Installed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft FSLogix Apps"</span><span class="p">,</span><span class="w">
</span><span class="nl">"FilesExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">frx.exe"</span><span class="p">,</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">ConfigurationTool.exe"</span><span class="p">,</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">frxcontext.exe"</span><span class="p">,</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">Apps</span><span class="se">\\</span><span class="s2">frxshell.exe"</span><span class="w">
</span><span class="p">],</span><span class="w">
</span><span class="nl">"ShortcutsNotExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">ProgramData</span><span class="se">\\</span><span class="s2">Microsoft</span><span class="se">\\</span><span class="s2">Windows</span><span class="se">\\</span><span class="s2">Start Menu</span><span class="se">\\</span><span class="s2">FSLogix</span><span class="se">\\</span><span class="s2">FSLogix Apps Online Help.lnk"</span><span class="w">
</span><span class="p">],</span><span class="w">
</span><span class="nl">"ServicesDisabled"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MicrosoftEdge"</span><span class="p">,</span><span class="w">
</span><span class="nl">"Filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get-EvergreenApp -Name </span><span class="se">\"</span><span class="s2">MicrosoftEdge</span><span class="se">\"</span><span class="s2"> | Where-Object { $_.Architecture -eq </span><span class="se">\"</span><span class="s2">x64</span><span class="se">\"</span><span class="s2"> -and $_.Channel -eq </span><span class="se">\"</span><span class="s2">Stable</span><span class="se">\"</span><span class="s2"> -and $_.Release -eq </span><span class="se">\"</span><span class="s2">Enterprise</span><span class="se">\"</span><span class="s2"> } | Sort-Object -Property </span><span class="se">\"</span><span class="s2">Version</span><span class="se">\"</span><span class="s2"> -Descending | Select-Object -First 1"</span><span class="p">,</span><span class="w">
</span><span class="nl">"Installed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"(^Microsoft Edge$)"</span><span class="p">,</span><span class="w">
</span><span class="nl">"FilesExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files (x86)</span><span class="se">\\</span><span class="s2">Microsoft</span><span class="se">\\</span><span class="s2">Edge</span><span class="se">\\</span><span class="s2">Application</span><span class="se">\\</span><span class="s2">master_preferences"</span><span class="w">
</span><span class="p">],</span><span class="w">
</span><span class="nl">"ShortcutsNotExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Users</span><span class="se">\\</span><span class="s2">Public</span><span class="se">\\</span><span class="s2">Desktop</span><span class="se">\\</span><span class="s2">Microsoft Edge*.lnk"</span><span class="w">
</span><span class="p">],</span><span class="w">
</span><span class="nl">"ServicesDisabled"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AdobeAcrobatReaderDC"</span><span class="p">,</span><span class="w">
</span><span class="nl">"Filter"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get-EvergreenApp -Name </span><span class="se">\"</span><span class="s2">AdobeAcrobatReaderDC</span><span class="se">\"</span><span class="s2"> | Where-Object { $_.Language -eq </span><span class="se">\"</span><span class="s2">MUI</span><span class="se">\"</span><span class="s2"> -and $_.Architecture -eq </span><span class="se">\"</span><span class="s2">x64</span><span class="se">\"</span><span class="s2"> } | Select-Object -First 1"</span><span class="p">,</span><span class="w">
</span><span class="nl">"Installed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Adobe Acrobat.*64-bit"</span><span class="p">,</span><span class="w">
</span><span class="nl">"FilesExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">Adobe</span><span class="se">\\</span><span class="s2">Acrobat DC</span><span class="se">\\</span><span class="s2">Acrobat</span><span class="se">\\</span><span class="s2">Acrobat.exe"</span><span class="p">,</span><span class="w">
</span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Program Files</span><span class="se">\\</span><span class="s2">Adobe</span><span class="se">\\</span><span class="s2">Acrobat DC</span><span class="se">\\</span><span class="s2">Acrobat</span><span class="se">\\</span><span class="s2">AdobeCollabSync.exe"</span><span class="w">
</span><span class="p">],</span><span class="w">
</span><span class="nl">"ShortcutsNotExist"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
</span><span class="nl">"ServicesDisabled"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"AdobeARMservice"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>
<h2 id="azure-pipelines">Azure Pipelines</h2>
<p>Azure Pipelines will run the tests against our target image via the Pipelines agent. To run our tests, we need two things - a pipeline, and an agent pool with self-hosted agents.</p>
<p><a href="/media/2023/12/SelfHostedAgent.jpeg"><img src="/media/2023/12/SelfHostedAgent.jpeg" alt="Self-hosted agent" /></a></p>
<p class="figcaption">An Azure Virtual Desktop session host as a self-hosted agent for Azure Pipelines.</p>
<h3 id="agent-pool">Agent Pool</h3>
<p>I won’t cover creating an agent pool in detail here, instead refer to the documentation - <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/pools-queues?view=azure-devops&tabs=yaml%2Cbrowser">Create and manage agent pools</a>. The agent pool name is important though as it needs to be referred to in the pipeline file and the agent install script.</p>
<h3 id="pipeline">Pipeline</h3>
<p>The pipeline configuration defines how the pipeline will run and post results back to DevOps for reporting. Below is a code listing of the pipeline file which does the following:</p>
<ul>
<li>Install Pester - note that Evergreen is also required, so you will need to add that to the list of installed modules, if Evergreen is not already installed into the image</li>
<li>Run the tests stored in the <code class="language-plaintext highlighter-rouge">tests</code> directory</li>
<li>Post the test results back to DevOps for reporting - this is displayed as the pass / fail status</li>
<li>Gather a list of the installed applications and post that back to DevOps as well. This information will enable you to track application versions across images</li>
</ul>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">pool</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Azure</span><span class="nv"> </span><span class="s">Virtual</span><span class="nv"> </span><span class="s">Desktop</span><span class="nv"> </span><span class="s">aue'</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">powershell</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">$params = @{</span>
<span class="s">Name = "Pester"</span>
<span class="s">SkipPublisherCheck = $true</span>
<span class="s">Force = $true</span>
<span class="s">ErrorAction = "Stop"</span>
<span class="s">}</span>
<span class="s">Install-Module @params</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">pester</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Install</span><span class="nv"> </span><span class="s">Pester'</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>
<span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">continue</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">false</span>
<span class="pi">-</span> <span class="na">powershell</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">Import-Module -Name "Pester" -Force -ErrorAction "Stop"</span>
<span class="s">$Config = New-PesterConfiguration</span>
<span class="s">$Config.Run.Path = "$(build.sourcesDirectory)\tests"</span>
<span class="s">$Config.Run.PassThru = $true</span>
<span class="s">$Config.TestResult.Enabled = $true</span>
<span class="s">$Config.TestResult.OutputFormat = "NUnitXml"</span>
<span class="s">$Config.TestResult.OutputPath = "$(build.sourcesDirectory)\TestResults.xml"</span>
<span class="s">$Config.Output.Verbosity = "Detailed"</span>
<span class="s">Invoke-Pester -Configuration $Config</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">test</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Validate</span><span class="nv"> </span><span class="s">installed</span><span class="nv"> </span><span class="s">apps'</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s">$(build.sourcesDirectory)</span>
<span class="na">errorActionPreference</span><span class="pi">:</span> <span class="s">continue</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">publish</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(build.sourcesDirectory)</span><span class="se">\\</span><span class="s">InstalledApplications.csv"</span>
<span class="na">artifact</span><span class="pi">:</span> <span class="s">InstalledApplications</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">publish</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(build.sourcesDirectory)</span><span class="se">\\</span><span class="s">TestResults.xml"</span>
<span class="na">artifact</span><span class="pi">:</span> <span class="s">TestResults</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PublishTestResults@2</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">testResultsFormat</span><span class="pi">:</span> <span class="s2">"</span><span class="s">NUnit"</span>
<span class="na">testResultsFiles</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(build.sourcesDirectory)</span><span class="se">\\</span><span class="s">TestResults.xml"</span>
<span class="na">failTaskOnFailedTests</span><span class="pi">:</span> <span class="no">true</span>
<span class="na">testRunTitle</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Publish</span><span class="nv"> </span><span class="s">Pester</span><span class="nv"> </span><span class="s">results"</span>
</code></pre></div></div>
<h3 id="installing-the-azure-pipelines-agent">Installing the Azure Pipelines agent</h3>
<p>The Azure Pipelines agent is required to add a virtual machine as a self-hosted agent. The Azure Pipelines agent should not be built into your gold image - it should be installed into a target session host after it’s been deployed.</p>
<p>In my Azure Virtual Desktop environment, Nerdio Manager is used to create images, deploy session hosts, and manage scaling. <a href="https://nmw.zendesk.com/hc/en-us/articles/4731662951447-Scripted-Actions-Overview">Nerdio Manager Scripted Actions</a> can be run when a session host is created. This enables us to install the Azure Pipelines agent on session hosts in a UAT host pool when the session host is created.</p>
<p>Nerdio Manager also makes it simple to pass secure strings into the agent install script at runtime. The install script uses Evergreen to find the latest version of the Azure Pipelines agent, download, unpack and install the agent, including creating a local user account required by the agent and setting Azure DevOps settings.</p>
<p>The install script requires the following variables to be set in <a href="https://nmw.zendesk.com/hc/en-us/articles/4731671517335-Scripted-Actions-Global-Secure-Variables">Scripted Actions Global Secure Variables</a></p>
<ul>
<li><code class="language-plaintext highlighter-rouge">DevOpsUrl</code> - the URL to our DevOps organisation</li>
<li><code class="language-plaintext highlighter-rouge">DevOpsPat</code> - the <a href="https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate">Personal Access Token</a> used to authenticate to Azure DevOps</li>
<li><code class="language-plaintext highlighter-rouge">DevOpsPool</code> - the agent pool that the self-hosted agent will register with</li>
<li><code class="language-plaintext highlighter-rouge">DevOpsUser</code> - the local user account name the install script will create. This account will be added to the local administrators group on the target session host; therefore, I recommend you do not deploy this to production workloads</li>
<li><code class="language-plaintext highlighter-rouge">DevOpsPassword</code> - the password for the local user account</li>
</ul>
<p><a href="/media/2023/12/NerdioSecureVariables.jpeg"><img src="/media/2023/12/NerdioSecureVariables.jpeg" alt="Nerdio Manager Secure Variables for the Azure Pipelines agent" /></a></p>
<p class="figcaption">Nerdio Manager Secure Variables for the Azure Pipelines agent.</p>
<p>The block below lists the code for the installing the Azure Pipelines agent. This uses Evergreen to find the latest version and download into the session host:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#description: Installs the Microsoft Azure Pipelines agent to enable automated testing via Azure Pipelines. Do not run on production session hosts.</span><span class="w">
</span><span class="c">#execution mode: Combined</span><span class="w">
</span><span class="c">#tags: Evergreen, Testing, DevOps</span><span class="w">
</span><span class="c"># Check that the required variables have been set in Nerdio Manager</span><span class="w">
</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$Value</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="s2">"DevOpsUrl"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsPat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsPool"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsUser"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DevOpsPassword"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$null</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nv">$Value</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">throw</span><span class="w"> </span><span class="s2">"</span><span class="nv">$Value</span><span class="s2"> is null"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="c"># Download and extract</span><span class="w">
</span><span class="p">[</span><span class="n">System.String</span><span class="p">]</span><span class="w"> </span><span class="nv">$Path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">SystemDrive</span><span class="s2">\agents"</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Path</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="s2">"Directory"</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="s2">"SilentlyContinue"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
</span><span class="nx">Import-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"Evergreen"</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="nv">$App</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-EvergreenApp</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"MicrosoftAzurePipelinesAgent"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Architecture</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"x64"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-First</span><span class="w"> </span><span class="nx">1</span><span class="w">
</span><span class="nv">$OutFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Save-EvergreenApp</span><span class="w"> </span><span class="nt">-InputObject</span><span class="w"> </span><span class="nv">$App</span><span class="w"> </span><span class="nt">-CustomPath</span><span class="w"> </span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">Temp</span><span class="w"> </span><span class="nt">-WarningAction</span><span class="w"> </span><span class="s2">"SilentlyContinue"</span><span class="w">
</span><span class="n">Expand-Archive</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$OutFile</span><span class="o">.</span><span class="nf">FullName</span><span class="w"> </span><span class="nt">-DestinationPath</span><span class="w"> </span><span class="nv">$Path</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="n">Push-Location</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$Path</span><span class="w">
</span><span class="c"># Create the local account that the DevOps Pipelines agent service will run under</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
</span><span class="nx">Name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="err">.</span><span class="nx">DevOpsUser</span><span class="w">
</span><span class="nx">Password</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nx">ConvertTo</span><span class="err">-</span><span class="nx">SecureString</span><span class="w"> </span><span class="err">-</span><span class="nx">String</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="err">.</span><span class="nx">DevOpsPassword</span><span class="w"> </span><span class="err">-</span><span class="nx">AsPlainText</span><span class="w"> </span><span class="err">-</span><span class="nx">Force</span><span class="err">)</span><span class="w">
</span><span class="nx">Description</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Azure Pipelines agent service for elevated exec."</span><span class="w">
</span><span class="nx">UserMayNotChangePassword</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="nx">Confirm</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$false</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">New-LocalUser</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span><span class="n">Add-LocalGroupMember</span><span class="w"> </span><span class="nt">-Group</span><span class="w"> </span><span class="s2">"Administrators"</span><span class="w"> </span><span class="nt">-Member</span><span class="w"> </span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsUser</span><span class="w">
</span><span class="c"># Agent install options</span><span class="w">
</span><span class="nv">$Options</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"--unattended
--url </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsUrl</span><span class="si">)</span><span class="se">`"</span><span class="s2">
--auth pat
--token </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsPat</span><span class="si">)</span><span class="se">`"</span><span class="s2">
--pool </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsPool</span><span class="si">)</span><span class="se">`"</span><span class="s2">
--agent </span><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">COMPUTERNAME</span><span class="s2">
--runAsService
--windowsLogonAccount </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsUser</span><span class="si">)</span><span class="se">`"</span><span class="s2">
--windowsLogonPassword </span><span class="se">`"</span><span class="si">$(</span><span class="nv">$SecureVars</span><span class="o">.</span><span class="nf">DevOpsPassword</span><span class="si">)</span><span class="se">`"</span><span class="s2">
--replace"</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
</span><span class="nx">FilePath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$Path</span><span class="s2">\config.cmd"</span><span class="w">
</span><span class="nx">ArgumentList</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">$(</span><span class="nv">$Options</span><span class="w"> </span><span class="err">-</span><span class="nx">replace</span><span class="w"> </span><span class="s2">"\s+"</span><span class="p">,</span><span class="w"> </span><span class="s2">" "</span><span class="err">)</span><span class="w">
</span><span class="nx">Wait</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="nx">NoNewWindow</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="nx">PassThru</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">Start-Process</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>
<p>This script should be imported into Nerdio Manager as a scripted action, and added to the VM Deployment settings on the host pool, so that the agent is installed when a session host is deployed.</p>
<h3 id="running-the-pipeline">Running the Pipeline</h3>
<p>When you have a new version of a gold image, deploy the image to a UAT host pool, then manually run the Azure Pipeline to validate the image.</p>
<p>Right now, it’s a manual process to start the pipeline because we don’t have a self-hosted agent running until a new image has been deployed. With some additional configuration this could be automated so the pipeline kicks off when a new image is deployed. The pipeline could be run via an API call to Azure DevOps when an a new session host is deployed via some external orchestration host.</p>
<p><a href="/media/2023/12/RunPipeline.jpeg"><img src="/media/2023/12/RunPipeline.jpeg" alt="Starting the pipeline" /></a></p>
<p class="figcaption">Manually starting the pipeline on a new image.</p>
<h2 id="results">Results</h2>
<p>A pipeline run only takes a few seconds and the results can be tracked across runs. Update your retention settings to retain reports for longer.</p>
<p><a href="/media/2023/12/PesterTestsPassed97.jpeg"><img src="/media/2023/12/PesterTestsPassed97.jpeg" alt="A screenshot of a failed Azure Pipeline run" /></a></p>
<p class="figcaption">Azure Pipelines result showing passed and failed tests.</p>
<p>Artifacts are stored on the pipeline run - <code class="language-plaintext highlighter-rouge">InstalledApplications.csv</code> will help keep track of installed applications and versions.</p>
<p><a href="/media/2023/12/Artifacts.jpeg"><img src="/media/2023/12/Artifacts.jpeg" alt="Artifact objects " /></a></p>
<p class="figcaption">Artifacts stored on the pipeline.</p>
<h2 id="getting-started">Getting Started</h2>
<p>In this article, I’ve assumed you have an understanding of PowerShell, Pester, and Azure DevOps / Pipelines. If any of the concepts aren’t clear, comment below, and I’ll might be able to expand on some details in future articles.</p>
<p>To get the most out of this approach, I highly recommended that you are also automating the build of new gold images. The framework outlined in this article could also be run at the end of an automated build; however, even if you are manually building images, this approach can assist in validation.</p>
<p>To get started with this test and validation solution, you can fork the code in my <code class="language-plaintext highlighter-rouge">vdi-uat</code> repository here: <a href="https://github.com/aaronparker/vdi-uat">https://github.com/aaronparker/vdi-uat</a>.</p>Aaron Parkeraaron@stealthpuppy.comSetting Up a Local Environment for IntuneCD2023-07-11T14:24:00+00:002023-07-11T14:24:00+00:00http://stealthpuppy.com/using-intunecd-interactively<ul id="markdown-toc">
<li><a href="#running-intunecd-locally" id="markdown-toc-running-intunecd-locally">Running IntuneCD Locally</a> <ul>
<li><a href="#authentication" id="markdown-toc-authentication">Authentication</a></li>
<li><a href="#prerequisites" id="markdown-toc-prerequisites">Prerequisites</a></li>
</ul>
</li>
<li><a href="#platform-configuration" id="markdown-toc-platform-configuration">Platform Configuration</a> <ul>
<li><a href="#windows" id="markdown-toc-windows">Windows</a></li>
<li><a href="#wsl2-and-linux" id="markdown-toc-wsl2-and-linux">WSL2 and Linux</a></li>
<li><a href="#macos" id="markdown-toc-macos">macOS</a></li>
</ul>
</li>
<li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
</ul>
<p>I’ve previously written about using IntuneCD in a GitHub or Azure DevOps pipeline to backup and document an Intune tenant in these articles:</p>
<ul>
<li><a href="https://stealthpuppy.com/automate-intune-documentation-github/">Automate Microsoft Intune As-Built Documentation on GitHub</a></li>
<li><a href="https://stealthpuppy.com/automate-intune-documentation-azure/">Automate Microsoft Intune As-Built Documentation on Azure DevOps</a></li>
</ul>
<p>The approach uses Linux runners hosted by GitHub or Microsoft, thus the platform configuration is already taken care of (Python, Node.js, and dependencies etc.). I do often find myself using IntuneCD locally to backup a tenant and produce an as-built document - running locally requires installing the prerequisites which can be a bit of fun (or hair pulling) depending on your target platform.</p>
<p>In this article, I’ll cover the steps to install and configure the prerequisites for running IntuneCD on Windows, Windows Subsystem for Linux (WSL2), Linux and macOS. <strong>Note</strong> - these are the install steps that have worked for me on each of these platforms. Your mileage may vary.</p>
<h2 id="running-intunecd-locally">Running IntuneCD Locally</h2>
<p>The scripts below can be used to backup your Intune tenant and create a new as-built document in PDF and HTML formats. This approach is useful where you might not want to go as far as creating a pipeline to automate the entire process. These scripts can be used for an adhoc approach to backup and document generation.</p>
<p>Below is the script in PowerShell format which has been tested on Windows. You can find the original source in my <a href="https://github.com/aaronparker/intune-backup-template">template repository on GitHub</a>.</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">New-Item</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-backup"</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="s2">"Directory"</span><span class="w">
</span><span class="n">IntuneCD-startbackup</span><span class="w"> </span><span class="nt">--mode</span><span class="o">=</span><span class="mi">1</span><span class="w"> </span><span class="nt">--output</span><span class="o">=</span><span class="n">json</span><span class="w"> </span><span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-backup"</span><span class="w"> </span><span class="nt">--localauth</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\auth.json"</span><span class="w">
</span><span class="c"># Generate the as-built document in markdown</span><span class="w">
</span><span class="nv">$Auth</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\auth.json"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$INTRO</span><span class="o">=</span><span class="s2">"Intune backup and documentation generated locally. <img align=</span><span class="se">`"</span><span class="s2">right</span><span class="se">`"</span><span class="s2"> width=</span><span class="se">`"</span><span class="s2">96</span><span class="se">`"</span><span class="s2"> height=</span><span class="se">`"</span><span class="s2">96</span><span class="se">`"</span><span class="s2"> src=</span><span class="se">`"</span><span class="s2">./logo.png</span><span class="se">`"</span><span class="s2">>"</span><span class="w">
</span><span class="n">IntuneCD-startdocumentation</span><span class="w"> </span><span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-backup"</span><span class="w"> </span><span class="nt">--outpath</span><span class="o">=</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-as-built.md"</span><span class="w"> </span><span class="nt">--tenantname</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nv">$Auth</span><span class="o">.</span><span class="nf">TENANT_NAME</span><span class="si">)</span><span class="s2">"</span><span class="w"> </span><span class="nt">--intro</span><span class="o">=</span><span class="s2">"</span><span class="nv">$INTRO</span><span class="s2">"</span><span class="w">
</span><span class="c"># Generate a PDF document from the as-built markdown</span><span class="w">
</span><span class="n">md-to-pdf</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-as-built.md"</span><span class="w"> </span><span class="nt">--config-file</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\md2pdf\pdfconfig.json"</span><span class="w">
</span><span class="c"># Generate a HTML document from the as-built markdown</span><span class="w">
</span><span class="n">md-to-pdf</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\prod-as-built.md"</span><span class="w"> </span><span class="nt">--config-file</span><span class="w"> </span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\md2pdf\htmlconfig.json"</span><span class="w"> </span><span class="nt">--as-html</span><span class="w">
</span></code></pre></div></div>
<p>Below is the same script in Shell Script format - this has been tested on WSL2, Linux and macOS:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-backup"</span>
IntuneCD-startbackup <span class="nt">--mode</span><span class="o">=</span>1 <span class="nt">--output</span><span class="o">=</span>json <span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-backup"</span> <span class="nt">--localauth</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/auth.json"</span>
<span class="c"># Generate the as-built document in markdown</span>
<span class="nv">TENANT</span><span class="o">=</span><span class="si">$(</span>jq .params.TENANT_NAME <span class="nv">$PWD</span>/auth.json | <span class="nb">tr</span> <span class="nt">-d</span> <span class="se">\"</span><span class="si">)</span>
<span class="nv">INTRO</span><span class="o">=</span><span class="s2">"Intune backup and documentation generated locally. <img align=</span><span class="se">\"</span><span class="s2">right</span><span class="se">\"</span><span class="s2"> width=</span><span class="se">\"</span><span class="s2">96</span><span class="se">\"</span><span class="s2"> height=</span><span class="se">\"</span><span class="s2">96</span><span class="se">\"</span><span class="s2"> src=</span><span class="se">\"</span><span class="s2">./logo.png</span><span class="se">\"</span><span class="s2">>"</span>
IntuneCD-startdocumentation <span class="nt">--path</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-backup"</span> <span class="nt">--outpath</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-as-built.md"</span> <span class="nt">--tenantname</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TENANT</span><span class="s2">"</span> <span class="nt">--intro</span><span class="o">=</span><span class="s2">"</span><span class="nv">$INTRO</span><span class="s2">"</span>
<span class="c"># Generate a PDF document from the as-built markdown</span>
md-to-pdf <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-as-built.md"</span> <span class="nt">--config-file</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/md2pdf/pdfconfig.json"</span>
<span class="c"># Generate a HTML document from the as-built markdown</span>
md-to-pdf <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/prod-as-built.md"</span> <span class="nt">--config-file</span> <span class="s2">"</span><span class="nv">$PWD</span><span class="s2">/md2pdf/htmlconfig.json"</span> <span class="nt">--as-html</span>
</code></pre></div></div>
<h3 id="authentication">Authentication</h3>
<p>IntuneCD uses an <a href="https://github.com/almenscorner/IntuneCD/wiki/Authentication">Azure AD app registration to authenticate</a> to the Microsoft Graph API. The script above uses a local JSON file with credentials (see the example below). If your local backup/export of your Intune tenant is hosted in a git repository, make sure you add <code class="language-plaintext highlighter-rouge">auth.json</code> to the <code class="language-plaintext highlighter-rouge">.gitignore</code> file - <a href="https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files">Ignoring files</a>.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
</span><span class="nl">"params"</span><span class="p">:{</span><span class="w">
</span><span class="nl">"TENANT_NAME"</span><span class="p">:</span><span class="w"> </span><span class="s2">"intunetenant.onmicrosoft.com"</span><span class="p">,</span><span class="w">
</span><span class="nl">"CLIENT_ID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"28f60124-eb81-40e1-b1c4-1bb06c44ec91"</span><span class="p">,</span><span class="w">
</span><span class="nl">"CLIENT_SECRET"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AsT8Q~j_8MqluNxFi_4TIC8kdXzRdjEwM.tZxcjS"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<h3 id="prerequisites">Prerequisites</h3>
<p>Running the scripts requires the following dependencies:</p>
<ul>
<li><a href="https://www.python.org/">Python</a> - IntuneCD is written in Python</li>
<li><a href="https://nodejs.org/">Node.js</a> - required by md-to-pdf</li>
<li><a href="https://jqlang.github.io/jq/">jq</a> - this is only required when running the script on WSL2, Linux or macOS. The PowerShell script uses <code class="language-plaintext highlighter-rouge">ConvertFrom-Json</code> instead</li>
</ul>
<h2 id="platform-configuration">Platform Configuration</h2>
<h3 id="windows">Windows</h3>
<p>Use the Windows Package Manager (winget) to install an environment on Windows. Elevate a Terminal window, and run the following winget commands to install Python, NVM for Windows (Node.js version manager), and git for Windows.</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Python and NVM for Windows</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Python.Python.3.11</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">CoreyButler.NVMforWindows</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span></code></pre></div></div>
<p>If you’re storing your configuration in git repository you can install git locally. Additionally, install the GitHub CLI will help with <a href="https://cli.github.com/manual/gh_auth_login">authenticating to GitHub</a>:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install git for Windows and GitHub CLI</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Git.Git</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Github.cli</span><span class="w"> </span><span class="nt">--silent</span><span class="w">
</span></code></pre></div></div>
<p>Close and restart an elevated Terminal window and install IntuneCD, Node.js, and md-to-pdf. The Node.js install is using NVM as recommended by Microsoft - <a href="https://learn.microsoft.com/en-us/windows/dev-environment/javascript/nodejs-on-windows">Install NodeJS on Windows</a>:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install IntuneCD, Node.js and md-to-pdf</span><span class="w">
</span><span class="n">pip3</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">IntuneCD</span><span class="w">
</span><span class="n">nvm</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">18.6.1</span><span class="w">
</span><span class="n">npm</span><span class="w"> </span><span class="nx">i</span><span class="w"> </span><span class="nt">-g</span><span class="w"> </span><span class="nx">md-to-pdf</span><span class="w">
</span></code></pre></div></div>
<h3 id="wsl2-and-linux">WSL2 and Linux</h3>
<p>The steps for setting up an environment on <a href="https://learn.microsoft.com/en-us/windows/wsl/about">WSL2</a> and Linux will be the same.</p>
<p>If your target platform is Windows, the benefit of WSL2 over Linux is that you don’t need to run an entire virtual machine just to run Linux. Additionally, using WSL2 on Windows instead of natively installing python and Node.js, means that your development environment is containerised within WSL, thus you can remove the entire environment by deleting the WSL instance.</p>
<p>However, you may need to weigh that against wasting hours of your life on getting Linux running that you’ll never get back.</p>
<p>The script below will configure an environment, and assumes you are using WLS2 with Ubuntu or an Ubuntu virtual machine with a minimal installation:</p>
<ul>
<li>Install required dependencies including <a href="https://jqlang.github.io/jq/">jq</a> with <code class="language-plaintext highlighter-rouge">apt-get</code></li>
<li><a href="https://brew.sh/">Homebrew</a> this will simplfy the installation of additional components including Python. I had issues installing Python with pyenv</li>
<li>Install Python with Homebrew</li>
<li>Install IntuneCD</li>
<li>Install <a href="https://github.com/nvm-sh/nvm">nvm</a> and Node.js</li>
<li>Install md-to-pdf</li>
</ul>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Update the OS and install git, curl and build-essential required for Homebrew</span>
<span class="nb">sudo </span>apt-get update<span class="p">;</span> <span class="nb">sudo </span>apt-get upgrade <span class="nt">-y</span>
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> git curl build-essential jq
<span class="c"># Install Node.js depdendencies</span>
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0
<span class="c"># Install Homebrew and Python</span>
/bin/bash <span class="nt">-c</span> <span class="s2">"</span><span class="si">$(</span>curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="si">)</span><span class="s2">"</span>
brew <span class="nb">install </span>python@3.11
<span class="c"># Update pip and install IntuneCD</span>
python3.11 <span class="nt">-m</span> pip <span class="nb">install</span> <span class="nt">--upgrade</span> pip
pip3 <span class="nb">install </span>IntuneCD
<span class="c"># Install Node.js version manager and Node.js LTS</span>
curl <span class="nt">-o-</span> https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
<span class="nb">export </span><span class="nv">NVM_DIR</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.nvm"</span>
<span class="o">[</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span> <span class="o">]</span> <span class="o">&&</span> <span class="se">\.</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span>
nvm <span class="nb">install</span> <span class="nt">--lts</span>
<span class="c"># Install md-to-pdf</span>
npm i <span class="nt">-g</span> md-to-pdf
</code></pre></div></div>
<h3 id="macos">macOS</h3>
<p>The script below will set up the required dependencies and tools on macOS. This assumes you are using the default zsh shell and will install the following:</p>
<ul>
<li><a href="https://brew.sh/">Homebrew</a> which is the best package manger for macOS</li>
<li><a href="https://github.com/pyenv/pyenv">pyenv</a> to simplify the installation of Python. Follow the install instructions to set up pyenv for macOS and zsh</li>
<li><a href="https://jqlang.github.io/jq/">jq</a> and <a href="https://cli.github.com/manual/gh_auth_login">GitHub CLI</a></li>
<li>Install Python with pyenv and set the default version</li>
<li>Install IntuneCD</li>
<li>Install nvm and Node.js</li>
<li>Install md-to-pdf</li>
</ul>
<div class="language-zsh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Homebrew, pyenv, and jq</span>
/bin/bash <span class="nt">-c</span> <span class="s2">"</span><span class="si">$(</span>curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="si">)</span><span class="s2">"</span>
brew update
brew <span class="nb">install </span>pyenv jq gh
<span class="c"># Set up the shell environment for pyenv</span>
<span class="nb">echo</span> <span class="s1">'export PYENV_ROOT="$HOME/.pyenv"'</span> <span class="o">>></span> ~/.zshrc
<span class="nb">echo</span> <span class="s1">'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"'</span> <span class="o">>></span> ~/.zshrc
<span class="nb">echo</span> <span class="s1">'eval "$(pyenv init -)"'</span> <span class="o">>></span> ~/.zshrc
<span class="c"># Install Python and set a global version</span>
pyenv <span class="nb">install </span>3.11.4
pyenv global 3.11.4
<span class="c"># Install IntuneCD</span>
pip3 <span class="nb">install </span>IntuneCD
<span class="c"># Install Node.js version manager and Node.js LTS</span>
curl <span class="nt">-o-</span> https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
<span class="nb">export </span><span class="nv">NVM_DIR</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.nvm"</span>
<span class="o">[</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span> <span class="o">]</span> <span class="o">&&</span> <span class="se">\.</span> <span class="s2">"</span><span class="nv">$NVM_DIR</span><span class="s2">/nvm.sh"</span>
nvm <span class="nb">install</span> <span class="nt">--lts</span>
<span class="c"># Install md-to-pdf</span>
npm i <span class="nt">-g</span> md-to-pdf
</code></pre></div></div>
<h2 id="wrap-up">Wrap Up</h2>
<p>The commands listed this article should enable you to set up your local environment to use with IntuneCD. This includes each of the required dependencies. I’ve tested on Windows 11, macOS 13.4 and Ubuntu 22.04 across multiple devices and virtual machines, so the commands should be well tested and hopefully work for you.</p>
<p>My preferred platforms for using IntuneCD locally are macOS and Windows. Both platforms are simple to configure and starting using IntuneCD to export an Intune configuration and create an as-built document. However, if you prefer Linux the commands listed in this article will assist in configuring that platform.</p>Aaron Parkeraaron@stealthpuppy.comYou Should Be Testing Intune Endpoint Privilege Management Today2023-05-09T07:47:00+00:002023-05-09T07:47:00+00:00http://stealthpuppy.com/intune-endpoint-privilege-management-is-here<p>Microsoft recently released the preview of Endpoint Privilege Management in Microsoft Intune which is a powerful capability for removing administrative privileges from end users on managed Windows desktops. In its current preview form, it is worth considering carefully how it should be implemented.</p>
<p class="lead">Here’s why.</p>
<p>The initial implementation of Endpoint Privilege Management is well thought out with the ability to require a user to add a business justification for an elevation request and monitor those elevations from within Intune. EPM has the added benefit that the client-side components are built into the operating system, so no additional agents are required.</p>
<p>It can be configured to require additional authentication via password or Windows Hello when elevating an application, which assists in proving identity when users are elevating to administrator.</p>
<h2 id="with-great-power-comes-great-responsibility">With great power, comes great responsibility</h2>
<p>Intune policies for Endpoint Privilege Management provide several options for defining rules for applications that users can elevate. Rules include file hash, certificate, path, and file information – similar rules to AppLocker. You can be confident that only known authorised applications can be elevated.</p>
<p>In this preview state however, it is worth noting that child processes of an application you elevate to administrator will also inherit administrator rights. This means that you could be granting more rights to the target machine that you had originally intended.</p>
<p>The good news is that <a href="https://twitter.com/DeviceDeploy/status/1640487199668928513">Microsoft is working on this</a>, so that only the specified application will be elevated and child processes will not be elevated.</p>
<h2 id="which-scenarios-is-endpoint-privilege-management-good-for">Which scenarios is Endpoint Privilege Management good for?</h2>
<p><strong>Staff who require administrative access to Windows to complete tasks</strong> – typically IT professionals or developers. EPM will allow you to remove standing administrator access and require users to elevate with a gate for business justification. In this scenario you might allow elevation of applications such as PowerShell or Visual Studio that can be used to make any change to the system; however, these users require elevation of the tools to complete their work.
Insentra is a great example of this scenario – as a professional and <a href="https://www.insentragroup.com/au/services/">managed services</a> business, we have a number of consultants who need administrative access on occasions to complete tasks.</p>
<p>In effect, this approach should allow simpler delegation of administrative powers where users are restricted to this elevation action on an approved list of devices or desktops. Users only elevate for specific purposes, as required.</p>
<p><strong>Legacy applications that require administrative rights to run</strong> – there’s plenty of these poorly written applications still around that could benefit from automatic elevation and no additional modification. Remember – in the preview, child processes of the elevated parent process are also elevated with administrative privileges, so be careful with what you allow to elevate.</p>
<p>This can create an escape hatch for gaining unauthorised privileges that were not the intention of the original rule to elevate a specific application. Consider elevating a legacy application that must run as administrator, but that application provides a way to browse the file system via an Open dialog box. A user could potentially launch PowerShell to make system changes.</p>
<p>During the preview period, I would recommend using EPM for this scenario carefully, unless security controls or endpoint detection and response solutions are in place (e.g., Microsoft Defender for Endpoint).</p>
<p><strong>Ad-hoc application installs</strong> - a common scenario would be to allow end-users or support staff to manually install specific applications. Many organisations have a long tail of applications with a small number of installed instances. Thus, it may not make practical sense to package and deploy those applications via Configuration Manager or Intune.</p>
<p>Is allowing ad-hoc application installs right for your organisation? This may depend on your regulatory and compliance requirements, the types of applications used, and a decision made knowing the risks involved in allowing end-users to install software. Reporting on software inventories and actively updating installed software is still recommended.</p>
<p>Where possible, avoid this approach and use application packaging solutions such as Patch My PC, and create a framework for those line of business applications that aren’t yet supported by these tools.</p>
<h2 id="wrap-up">Wrap Up</h2>
<p>Third parties have already been provisioning similar solutions to Endpoint Privilege Management, and while EPM will be an add-on to Intune, there a couple of benefits to the Microsoft solution:</p>
<ol>
<li>Policy controls are built into Microsoft Intune – security controls for Endpoint Privilege Management are managed along-side other OS controls including Microsoft Defender</li>
<li>Client-side component are built into Windows 10 and Windows 11 – no agents need to be deployed, making the solution simpler to implement
Implementing a least privilege model is one of the key steps to take to protect Windows endpoints. Therefore, Endpoint Privilege Management will be beneficial to organisations of all sizes. Now is the time to evaluate EPM and understand its capabilities.</li>
</ol>Aaron Parkeraaron@stealthpuppy.comMicrosoft recently released the preview of Endpoint Privilege Management in Microsoft Intune which is a powerful capability for removing administrative privileges from end users on managed Windows desktops. In its current preview form, it is worth considering carefully how it should be implemented.An Intune Package Factory for the Microsoft 365 Apps2023-04-20T23:00:00+00:002023-04-20T23:00:00+00:00http://stealthpuppy.com/microsoft-365-apps-packager<ul id="markdown-toc">
<li><a href="#the-problem" id="markdown-toc-the-problem">The Problem</a></li>
<li><a href="#anatomy-of-a-microsoft-365-apps-win32-package" id="markdown-toc-anatomy-of-a-microsoft-365-apps-win32-package">Anatomy of a Microsoft 365 Apps Win32 Package</a></li>
<li><a href="#the-solution" id="markdown-toc-the-solution">The Solution</a> <ul>
<li><a href="#requirements" id="markdown-toc-requirements">Requirements</a></li>
<li><a href="#powershell-modules" id="markdown-toc-powershell-modules">PowerShell modules</a></li>
<li><a href="#configuration-files" id="markdown-toc-configuration-files">Configuration Files</a></li>
<li><a href="#using-the-packager" id="markdown-toc-using-the-packager">Using the Packager</a> <ul>
<li><a href="#clone-the-repository" id="markdown-toc-clone-the-repository">Clone the repository</a></li>
<li><a href="#usage-via-administrator-sign-in" id="markdown-toc-usage-via-administrator-sign-in">Usage via Administrator Sign-in</a></li>
<li><a href="#usage-via-app-registration" id="markdown-toc-usage-via-app-registration">Usage via App Registration</a></li>
</ul>
</li>
<li><a href="#automating-the-packager" id="markdown-toc-automating-the-packager">Automating the Packager</a> <ul>
<li><a href="#azure-ad-app-registration" id="markdown-toc-azure-ad-app-registration">Azure AD App Registration</a></li>
<li><a href="#git-and-github-actions" id="markdown-toc-git-and-github-actions">Git and GitHub Actions</a></li>
<li><a href="#new-package-workflow" id="markdown-toc-new-package-workflow">New Package Workflow</a></li>
<li><a href="#update-binaries-workflow" id="markdown-toc-update-binaries-workflow">Update Binaries Workflow</a></li>
<li><a href="#workflow-secrets" id="markdown-toc-workflow-secrets">Workflow Secrets</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
</ul>
<p>Deploying the <a href="/office-365-proplus-deploy-intune/">Microsoft 365 Apps via Microsoft Intune</a> is as simple as <a href="https://docs.microsoft.com/en-us/mem/intune/apps/apps-add-office365">using using the built in tools to create a package</a> without having to manually package any binaries.</p>
<p><a href="/media/2023/04/m365apps.jpeg"><img src="/media/2023/04/m365apps.jpeg" alt="Creating a Microsoft 365 Apps package in Intune" /></a></p>
<p class="figcaption">Creating a Microsoft 365 Apps package in the Microsoft Intune admin center</p>
<p>This approach works great for new devices, particularly PCs deployed via Windows Autopilot. However, there are challenges with the in-built package solution.</p>
<h2 id="the-problem">The Problem</h2>
<p>The in-built Microsoft 365 Apps package doesn’t consistently upgrade older versions of Microsoft Office. At <a href="https://insentragroup.com">Insentra</a> seen issues with failed deployments due to a failure in the package upgrading over existing Microsoft Office installations in several customer environments.</p>
<p>Additionally, the in-built Microsoft 365 Apps package cannot be used as a dependency by another Win32 application package (e.g., ensure the Microsoft 365 Apps is installed before an add-in package is installed).</p>
<p>If your environment experiences upgrade issues or you need to use the Dependencies feature, you’ll need to create <a href="https://docs.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management">a custom Win32 application package</a> to deploy the Microsoft 365 Apps.</p>
<p>For small environments, creating a custom package could be a one off action, thus the package can be created manually; however, for larger environments you could create multiple packages, and could have a team of engineers creating packages. This could be in house engineers, or consultant or managed services engineers working across multiple customer environments. In these environments, it’s important to ensure consistency across multiple packages - without packages built to a common standard, your devices could experience inconsistent deployments and you’ll spend more time troubleshooting issues.</p>
<p class="note" title="Consider this">How do we ensure standardisation and a simple method for creating Microsoft 365 Apps packages? <em>With automation, of course</em>.</p>
<h2 id="anatomy-of-a-microsoft-365-apps-win32-package">Anatomy of a Microsoft 365 Apps Win32 Package</h2>
<p>Let’s start by taking a look at what should be included in a custom Microsoft 365 Apps Win32 package:</p>
<ul>
<li>A <code class="language-plaintext highlighter-rouge">configuration.xml</code> that defines the Microsoft 365 Apps package. Create the configuration XMl files in the <a href="https://docs.microsoft.com/en-us/deployoffice/admincenter/overview-office-customization-tool">Office Customization Tool</a></li>
<li>An <code class="language-plaintext highlighter-rouge">uninstall.xml</code> that defines removal of the Microsoft 365 Apps from a target PC</li>
<li><code class="language-plaintext highlighter-rouge">setup.exe</code> from the <a href="https://www.microsoft.com/en-au/download/details.aspx?id=49117">Office Deployment Tool</a>. This will process the <code class="language-plaintext highlighter-rouge">configuration.xml</code> and the <code class="language-plaintext highlighter-rouge">uninstall.xml</code> to install or uninstall the Microsoft 365 Apps</li>
<li>A detection method for Intune to determine whether the application is installed. Microsoft lists the existence of the <code class="language-plaintext highlighter-rouge">HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\O365ProPlusRetail - en-us</code> registry key <a href="https://docs.microsoft.com/en-us/deployoffice/deploy-microsoft-365-apps-configuration-manager-2012r2">in the documentation</a>; however, this is a very simple approach and you may want instead use registry keys or values unique to your package. The registry key <code class="language-plaintext highlighter-rouge">HKLM\SOFTWARE\Microsoft\Office\ClickToRun\Configuration</code> includes several values useful for detection rules.</li>
<li>For upgrade scenarios, Microsoft provides <a href="https://github.com/OfficeDev/Office-IT-Pro-Deployment-Scripts/tree/master/Office-ProPlus-Deployment/Deploy-OfficeClickToRun">scripts that are useful for uninstalling and cleaning</a> up older versions of Microsoft Office. These VBScripts provide a more consistent result than relying on the Office Deployment Tool to complete the uninstall and upgrade.</li>
<li>Finally, wrapping the install package with the <a href="https://psappdeploytoolkit.com/">PSAppDeployToolkit</a> provides additional install logic and handling, particularly for in-place upgrades on existing devices.</li>
</ul>
<h2 id="the-solution">The Solution</h2>
<p>To solve this challenge, I’ve built PowerShell packaging factory for the Microsoft 365 Apps and Microsoft Intune, that can be run locally or via GitHub Actions.</p>
<p>The <a href="https://github.com/aaronparker/m365apps">Microsoft 365 Apps packager repository</a> consists of the following:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> - This is the key script that creates and imports a Microsoft 365 Apps package into Intune. The script can be run on a Windows machine in a copy of the repository or via GitHub Actions (if you clone the repository)</li>
<li><code class="language-plaintext highlighter-rouge">Create-Win32App.ps1</code> imports the intunewin package into the target Intune tenant, using <code class="language-plaintext highlighter-rouge">App.json</code> as the template. This script uses the <code class="language-plaintext highlighter-rouge">IntuneWin32App</code> PowerShell module and is called by <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> to import the package into an Intune tenant</li>
<li>Template Microsoft 365 Apps deployment configurations - deployment configurations are created in the <a href="https://config.office.com/">Microsoft 365 Apps admin center</a>, but every organisation is going to deploy a similar configuration, so these templates should be suitable for the most common deployments</li>
<li>A GitHub workflow that uses <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> to package the Microsoft 365 Apps on a GitHub hosted runner - the workflow can import the package into an Intune tenant. It also uploads the generated Microsoft 365 Apps package <a href="https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts">a workflow artifact</a> for importing into Intune manually</li>
</ul>
<h3 id="requirements">Requirements</h3>
<p><code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> must be run on a supported Windows version, and has been written for PowerShell 5.1. Parameters for <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> are:</p>
<table>
<thead>
<tr>
<th style="text-align: left">Parameter</th>
<th style="text-align: left">Description</th>
<th style="text-align: left">Required</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left">Path</td>
<td style="text-align: left">Path to the top level directory of the m365apps repository on a local Windows machine.</td>
<td style="text-align: left">No</td>
</tr>
<tr>
<td style="text-align: left">ConfigurationFile</td>
<td style="text-align: left">Full path to the <a href="https://learn.microsoft.com/en-us/deployoffice/office-deployment-tool-configuration-options">Microsoft 365 Apps package configuration file</a>. Specify the full path to a configuration file included in the repository or the path to an external configuration file.</td>
<td style="text-align: left">Yes</td>
</tr>
<tr>
<td style="text-align: left">Channel</td>
<td style="text-align: left">A supported Microsoft 365 Apps release channel.</td>
<td style="text-align: left">No. Defaults to MonthlyEnterprise</td>
</tr>
<tr>
<td style="text-align: left">CompanyName</td>
<td style="text-align: left">Company name to include in the configuration.xml.</td>
<td style="text-align: left">No. Defaults to stealthpuppy</td>
</tr>
<tr>
<td style="text-align: left">TenantId</td>
<td style="text-align: left">The tenant id (GUID) of the target Azure AD tenant.</td>
<td style="text-align: left">Yes</td>
</tr>
<tr>
<td style="text-align: left">ClientId</td>
<td style="text-align: left">The client id (GUID) of the target Azure AD app registration.</td>
<td style="text-align: left">No</td>
</tr>
<tr>
<td style="text-align: left">ClientSecret</td>
<td style="text-align: left">Client secret used to authenticate against the app registration.</td>
<td style="text-align: left">No</td>
</tr>
<tr>
<td style="text-align: left">Import</td>
<td style="text-align: left">Switch parameter to specify that the the package should be imported into the Microsoft Intune tenant.</td>
<td style="text-align: left">No</td>
</tr>
</tbody>
</table>
<h3 id="powershell-modules">PowerShell modules</h3>
<p>These PowerShell modules are required:</p>
<ul>
<li><a href="https://www.powershellgallery.com/packages/Evergreen/">Evergreen</a></li>
<li><a href="https://www.powershellgallery.com/packages/IntuneWin32App/">IntuneWin32App</a></li>
<li><a href="https://www.powershellgallery.com/packages/MSAL.PS/">MSAL.PS</a></li>
</ul>
<p>If you are running the packager locally, install the modules with:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Install-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="nx">Evergreen</span><span class="p">,</span><span class="w"> </span><span class="nx">MSAL.PS</span><span class="p">,</span><span class="w"> </span><span class="nx">IntuneWin32App</span><span class="w"> </span><span class="nt">-SkipPublisherCheck</span><span class="w">
</span></code></pre></div></div>
<h3 id="configuration-files">Configuration Files</h3>
<p>Microsoft 365 Apps configuration files are included in the repository - these files can be used to create packages for any target tenant as some key options will be updated dynamically by <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code>.</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">O365BusinessRetail.xml</code> - Configuration file for Microsoft 365 Apps for business</li>
<li><code class="language-plaintext highlighter-rouge">O365BusinessRetail-VDI.xml</code> - Configuration file for Microsoft 365 Apps for business with shared licensing enabled, and OneDrive and Teams excluded</li>
<li><code class="language-plaintext highlighter-rouge">O365ProPlus.xml</code> - Configuration file for Microsoft 365 Apps for enterprise</li>
<li><code class="language-plaintext highlighter-rouge">O365ProPlus-VDI.xml</code> - Configuration file for Microsoft 365 Apps for enterprise with shared licensing enabled, and OneDrive and Teams excluded</li>
<li><code class="language-plaintext highlighter-rouge">O365ProPlusVisioProRetailProjectProRetail.xml</code> - Configuration file for Microsoft 365 Apps for enterprise, Visio, and Project</li>
<li><code class="language-plaintext highlighter-rouge">O365ProPlusVisioProRetailProjectProRetail-VDI.xml</code> - Configuration file for Microsoft 365 Apps for enterprise, Visio, and Project with shared licensing enabled, and OneDrive and Teams excluded</li>
<li><code class="language-plaintext highlighter-rouge">Uninstall-Microsoft365Apps.xml</code> - A configuration that will uninstall all Microsoft 365 Apps</li>
</ul>
<p>When the package is generated, the following properties will be updated:</p>
<ul>
<li>Company Name - this is the organisation name that sets the Company property on Office documents</li>
<li>Tenant Id - the target Azure AD tenant ID</li>
<li>Channel - the <a href="https://learn.microsoft.com/en-us/deployoffice/updates/overview-update-channels">Microsoft 365 Apps update channel</a></li>
</ul>
<h3 id="using-the-packager">Using the Packager</h3>
<p>If you’re looking to download and use the Packager locally, follow these steps:</p>
<h4 id="clone-the-repository">Clone the repository</h4>
<p>If you’re not familiar with clone a repository, use <a href="https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/adding-and-cloning-repositories/cloning-a-repository-from-github-to-github-desktop">GitHub Desktop to clone the repository</a> and keep your local copy up to date with changes to the source repository.</p>
<h4 id="usage-via-administrator-sign-in">Usage via Administrator Sign-in</h4>
<p>Use <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> by authenticating with an Intune Administrator account before running the script. Run <code class="language-plaintext highlighter-rouge">Connect-MSIntuneGraph</code> to authenticate with administrator credentials using a sign-in window or device login URL.</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Connect-MSIntuneGraph</span><span class="w"> </span><span class="nt">-TenantID</span><span class="w"> </span><span class="s2">"lab.stealthpuppy.com"</span><span class="w">
</span><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
</span><span class="nx">Path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"E:\project\m365apps"</span><span class="w">
</span><span class="nx">ConfigurationFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"E:\project\m365apps\configs\O365ProPlus.xml"</span><span class="w">
</span><span class="nx">Channel</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Current"</span><span class="w">
</span><span class="nx">CompanyName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"stealthpuppy"</span><span class="w">
</span><span class="nx">TenantId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"6cdd8179-23e5-43d1-8517-b6276a8d3189"</span><span class="w">
</span><span class="nx">Import</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="o">.</span><span class="n">\New-Microsoft365AppsPackage.ps1</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>
<p>Which will look similar to this when run in Terminal on Windows 11:</p>
<p><a href="/media/2023/04/New-Microsoft365AppsPackage.png"><img src="/media/2023/04/New-Microsoft365AppsPackage.png" alt="Running New-Microsoft365AppsPackage.ps1 on Windows 11" /></a></p>
<p class="figcaption">Running <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> on Windows 11</p>
<p>When <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> has successfully completed, the <code class="language-plaintext highlighter-rouge">package\output</code> folder will contain the <code class="language-plaintext highlighter-rouge">setup.intunewin</code> package, a copy of the configuration XML file in the package, and <code class="language-plaintext highlighter-rouge">m365apps.json</code> that is used by <code class="language-plaintext highlighter-rouge">Create-Win32App.ps1</code> to import the package into Intune.</p>
<p><a href="/media/2023/04/package-output.png"><img src="/media/2023/04/package-output.png" alt="Package output" /></a></p>
<p class="figcaption">Contents of the <code class="language-plaintext highlighter-rouge">package\output</code> folder once the package has been created</p>
<p>If <code class="language-plaintext highlighter-rouge">-Import</code> is specified when running <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code>, a standardised Microsoft 365 Apps package will be imported into the target Intune tenant:</p>
<p><a href="/media/2023/04/intune-package.png"><img src="/media/2023/04/intune-package.png" alt="The Microsoft 365 Apps package imported into Intune" /></a></p>
<p class="figcaption">The Microsoft 365 Apps package imported into Intune</p>
<p>If <code class="language-plaintext highlighter-rouge">-Import</code> is not specified, the package can be imported into Intune manually or by running <code class="language-plaintext highlighter-rouge">Create-Win32App.ps1</code>:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
</span><span class="nx">Json</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"E:\project\m365apps\output\m365apps.json"</span><span class="w">
</span><span class="nx">PackageFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"E:\project\m365apps\output\setup.intunewin"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="o">&</span><span class="w"> </span><span class="s2">"E:\project\m365apps\scripts\Create-Win32App.ps1"</span><span class="w"> </span><span class="err">@</span><span class="n">params</span><span class="w">
</span></code></pre></div></div>
<h4 id="usage-via-app-registration">Usage via App Registration</h4>
<p>Use <code class="language-plaintext highlighter-rouge">New-Microsoft365AppsPackage.ps1</code> to create a new package by passing credentials to an Azure AD app registration (see below) that has rights to import applications into Microsoft Intune:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
</span><span class="nx">Path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"E:\project\m365Apps"</span><span class="w">
</span><span class="nx">ConfigurationFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"E:\project\m365Apps\configs\O365ProPlus.xml"</span><span class="w">
</span><span class="nx">Channel</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"MonthlyEnterprise"</span><span class="w">
</span><span class="nx">CompanyName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"stealthpuppy"</span><span class="w">
</span><span class="nx">TenantId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"6cdd8179-23e5-43d1-8517-b6276a8d3189"</span><span class="w">
</span><span class="nx">ClientId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"60912c81-37e8-4c94-8cd6-b8b90a475c0e"</span><span class="w">
</span><span class="nx">ClientSecret</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"<secret>"</span><span class="w">
</span><span class="nx">Import</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="o">.</span><span class="n">\New-Microsoft365AppsPackage.ps1</span><span class="w"> </span><span class="err">@</span><span class="nx">params</span><span class="w">
</span></code></pre></div></div>
<h3 id="automating-the-packager">Automating the Packager</h3>
<p>The Microsoft 365 Apps Packager can be automated in multiple ways; however, the repository includes a method based on <a href="https://docs.github.com/en/actions/using-workflows">workflows</a> and GitHub Actions. To use this approach <a href="https://docs.github.com/en/get-started/quickstart/fork-a-repo">fork</a> the repository and configure in your own GitHub account.</p>
<h4 id="azure-ad-app-registration">Azure AD App Registration</h4>
<p>The workflows must authenticate to the Microsoft Graph API using a non-interactive authentication method. Create an Azure AD app registration and enable the <a href="https://docs.microsoft.com/en-us/graph/api/intune-shared-devicemanagement-update"><code class="language-plaintext highlighter-rouge">DeviceManagementApps.ReadWrite.All</code></a> permission.</p>
<p>The app registration requires the following API permissions:</p>
<table>
<thead>
<tr>
<th style="text-align: left">API / Permissions name</th>
<th style="text-align: left">Type</th>
<th style="text-align: left">Description</th>
<th style="text-align: left">Admin consent required</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left">DeviceManagementApps.ReadAll</td>
<td style="text-align: left">Application</td>
<td style="text-align: left">Read Microsoft Intune apps</td>
<td style="text-align: left">Yes</td>
</tr>
<tr>
<td style="text-align: left">DeviceManagementApps.ReadWriteAll</td>
<td style="text-align: left">Application</td>
<td style="text-align: left">Read and write Microsoft Intune apps</td>
<td style="text-align: left">Yes</td>
</tr>
</tbody>
</table>
<p><a href="/media/2023/04/graphapi.jpeg"><img src="/media/2023/04/graphapi.jpeg" alt="Assigning the DeviceManagementApps.ReadWrite.All API to the app registration" /></a></p>
<p class="figcaption">Assigning the DeviceManagementApps.ReadWrite.All API to the app registration</p>
<h4 id="git-and-github-actions">Git and GitHub Actions</h4>
<p>A Git repository is a natural choice for teams managing the Microsoft 365 Apps to maintain a library of configurations and track changes to packages. A repository could be hosted on several providers; however, Azure DevOps or GitHub are my go-to for hosting Git repositories. For internal teams, Azure DevOps could be the better choice for authentication and authorisation via Azure AD.</p>
<p><a href="https://docs.github.com/en/actions/using-workflows">GitHub Workflows</a> is an easy platform built into GitHub repositories for automating a workflow that creates a Win32 package in <a href="https://github.com/Microsoft/Microsoft-Win32-Content-Prep-Tool">intunewin</a> format and imports it into an Intune tenant. The approach in this article could be used with <a href="https://azure.microsoft.com/en-us/services/devops/pipelines/">Azure Pipelines</a> if preferred; however, Azure Pipelines variables are not as flexible as inputs in GitHub Workflows.</p>
<p>This solution includes two pipelines:</p>
<ol>
<li><strong>update-binaries</strong> - this workflow is scheduled to run weekly, and will update the repository with new versions of the Office Deployment Tool, the Microsoft Win32 Content Prep Tool, and the PSAppDeployToolkit</li>
<li><strong>new-package</strong> - this workflow will create a package for the Microsoft 365 Apps, import the package into an Intune tenant (with app registration details stored securely in repository secrets), and upload the package as a workflow artifact</li>
</ol>
<h4 id="new-package-workflow">New Package Workflow</h4>
<p>Here’s what running the <strong>new-package</strong> workflow looks like - the repository hosts several configurations from which a package can be created.</p>
<p><a href="/media/2023/04/m365-configurationxml.jpeg"><img src="/media/2023/04/m365-configurationxml.jpeg" alt="Microsoft 365 Apps configuration packages in the repository" /></a></p>
<p class="figcaption">Microsoft 365 Apps configuration packages in the repository</p>
<p>The <strong>new-package</strong> workflow is run from the Actions tab on the repository on GitHub. The <strong>Run workflow</strong> action will provide a prompt for several inputs:</p>
<ol>
<li>Configuration XML - select from a list of configuration files stored in the repository</li>
<li>Update channel - select the Microsoft 365 Apps update channel to apply to the package</li>
<li>Company name - a string that will be injected into the Company value in the configuration XML file</li>
<li>Import - choose to import the package into the target Intune tenant</li>
</ol>
<p><a href="/media/2023/04/run-new-package.jpeg"><img src="/media/2023/04/run-new-package.jpeg" alt="Starting the workflow and selecting inputs" /></a></p>
<p class="figcaption">Starting the workflow and selecting inputs</p>
<p>The workflow should run to create the Microsoft 365 Apps package and import it into the target tenant.</p>
<p><a href="/media/2023/04/workflow-run.jpeg"><img src="/media/2023/04/workflow-run.jpeg" alt="The workflow run after completing successfully" /></a></p>
<p class="figcaption">The workflow run after completing successfully</p>
<p>Once the workflow has run successfully, you should see a new Win32 package in your Intune tenant.</p>
<p><a href="/media/2023/04/m365-package.jpeg"><img src="/media/2023/04/m365-package.jpeg" alt="A Microsoft 365 Apps package imported into Microsoft Intune" /></a></p>
<p class="figcaption">A Microsoft 365 Apps package imported into Microsoft Intune</p>
<p>Finally, once the workflow is finished, the result and details of the package it created, are saved to the workflow summary:</p>
<p><a href="/media/2023/04/new-package-workflow-result.jpeg"><img src="/media/2023/04/new-package-workflow-result.jpeg" alt="Workflow summary results" /></a></p>
<p class="figcaption">The workflow is updated with a summary of the results of that run including details of the package.</p>
<h4 id="update-binaries-workflow">Update Binaries Workflow</h4>
<p>The repository includes copies of the following binaries and support files that are automatically kept updated with the latest versions:</p>
<ul>
<li><a href="https://www.microsoft.com/en-us/download/details.aspx?id=49117">Microsoft 365 Apps / Office Deployment Tool</a> (<code class="language-plaintext highlighter-rouge">setup.exe</code>) - the key installer required to install, configure and uninstall the Microsoft 365 Apps</li>
<li><a href="https://github.com/Microsoft/Microsoft-Win32-Content-Prep-Tool">Microsoft Win32 Content Prep Tool</a> (<code class="language-plaintext highlighter-rouge">IntuneWinAppUtil.exe</code>) - the tool that converts Win32 applications into the intunewin package format</li>
<li><a href="https://psappdeploytoolkit.com/">PSAppDeployToolkit</a> - the install is managed with the PowerShell App Deployment Toolkit</li>
</ul>
<p>If you have cloned this repository, ensure that you synchronise changes to update binaries to the latest version releases.</p>
<h4 id="workflow-secrets">Workflow Secrets</h4>
<p>The <strong>new-package</strong> workflow that will package and import the Microsoft 365 Apps package into a single tenant each time the workflow is run. The following secrets are required for this workflow:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">TENANT_ID</code> - the target tenant ID</li>
<li><code class="language-plaintext highlighter-rouge">CLIENT_ID</code> - the Azure AD <a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app">app registration</a> client ID used to authenticate to the target tenent</li>
<li><code class="language-plaintext highlighter-rouge">CLIENT_SECRET</code> - password used by to authenticate to the target tenent</li>
</ul>
<p>The <strong>update-binaries</strong> workflow will update executables and scripts required by the solution and commit changes to the repository, thus signed commits are recommended. Signing commits ensures that commits to the repository from people in your team adding, Microsoft 365 Apps configurations to the repository, are verified.</p>
<p>This workflow uses the following secrets to configure and sign commits:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">COMMIT_EMAIL</code> - email address used for commits</li>
<li><code class="language-plaintext highlighter-rouge">COMMIT_NAME</code> - user name used for commits</li>
<li><code class="language-plaintext highlighter-rouge">GPGKEY</code> - <a href="https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key">GPG key</a> to sign commits</li>
<li><code class="language-plaintext highlighter-rouge">GPGPASSPHRASE</code> - passphrase to unlock the GPG key</li>
</ul>
<h2 id="wrap-up">Wrap Up</h2>
<p>While Intune includes a simple solution to creating a Microsoft 365 Apps package to deploy to Windows devices. Using that in-built solution is not without its drawbacks and limitations. Deploying the Microsoft 365 Apps to managed devices via a Win32 package will provide a more consistent result.</p>
<p>The <a href="https://github.com/aaronparker/m365apps">Microsoft 365 Apps packager for Intune</a>, provides a consistent and repeatable process for creating a Win32 version of the package, whether you’re importing packages into a single Intune tenant or multiple tenants.</p>Aaron Parkeraaron@stealthpuppy.comDeploy a Single Microsoft 365 Apps Package Everywhere All At Once2023-04-10T13:07:00+00:002023-04-10T13:07:00+00:00http://stealthpuppy.com/deploy-the-microsoft-365-apps-a-single-package<ul id="markdown-toc">
<li><a href="#viewer-mode" id="markdown-toc-viewer-mode">Viewer Mode</a></li>
<li><a href="#what-about-access" id="markdown-toc-what-about-access">What about Access</a> <ul>
<li><a href="#fslogix-app-masking-for-access" id="markdown-toc-fslogix-app-masking-for-access">FSLogix App Masking for Access</a></li>
</ul>
</li>
<li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
</ul>
<p>Viewer mode for the Microsoft 365 Apps for enterprise enables support for users without Microsoft 365 or Office 365 licenses to use those applications in a read-only mode. This feature is <a href="https://stealthpuppy.com/m365apps-frontline-workers/">useful for frontline workers</a>, but viewer mode is also an opportunity to optimise how you deploy and update the Microsoft 365 Apps.</p>
<p>With currently supported releases of the Microsoft 365 Apps, viewer mode can help you simplify the deployment and management of these applications across physical and virtual desktops.</p>
<p>In many organisations, packages for Microsoft Project and Visio may be deployed separately to the core Microsoft 365 Apps. Typically only deployed to devices for the user who is licensed, or installed on a shared virtual desktop with access to the applications managed with FSLogix App Masking.</p>
<p>Viewer mode now means that you can deploy a single Microsoft 365 Apps package including Project and Visio to all devices and enable viewer mode by default. Licensed users will be able to use a fully activated copy of the applications with full functionality, while unlicensed users will use viewer mode.</p>
<p class="note" title="Consider this">No need for seperate packages, allowing users to install on-demand, targeted installs via device groups, or having to configure FSLogix App Masking to remove the applications from unlicensed users. If someone without a license needs to view and print a Word document they can. If another user needs to review a Visio document or project plan, but doesn’t have a license for Visio or Project, they can do that too.</p>
<h2 id="viewer-mode">Viewer Mode</h2>
<p>Microsoft introduced <a href="https://learn.microsoft.com/en-us/deployoffice/overview-viewer-mode">viewer mode in the Microsoft 365 Apps</a> with the 1902 release, and expanded support for apps that support viewer mode with the 2005 release.</p>
<p>Viewer mode is supported for version 1902 or later of Word, Excel, and PowerPoint, and version 2005 or later of Project and Visio. At the time of writing, the latest supported version of the Microsoft 365 Apps is 2202. Given the <a href="https://msrc.microsoft.com/blog/2023/03/microsoft-mitigates-outlook-elevation-of-privilege-vulnerability/">recent vulnerability in Outlook</a>, all environments should be current and be able to use viewer mode.</p>
<p>To optimise the solution outlined in this article, it is important to have deployed a current version of the Microsoft 365 Apps, because:</p>
<blockquote class="lead">
<p>(For Version 2205 and later) If viewer mode is enabled, but the user has a license for the product, such as Visio, then the user will have an activated, fully functional version of that product. The other unlicensed products on the device, such as Project, will remain in viewer mode.</p>
</blockquote>
<p>To enable viewer mode, use either of the following configuration settings, depending on how you manage PCs or virtual desktops:</p>
<ul>
<li>In a Group Policy Object assigned to the organisational unit containing the target computer accounts, enable the <strong>Use Viewer Mode</strong> policy setting under <strong>Computer Configuration / Policies / Administrative Templates / Microsoft Office 2016 (Machine) / Licensing Settings</strong></li>
<li>In Microsoft Intune, create a device configuration profile using the Settings Catalog, and enable <strong>Use Viewer Mode</strong> under <strong>Microsoft Office 2016 (Machine) / Licensing Settings</strong>. Assign the policy to the target devices</li>
</ul>
<p>If your environment is running version 2208 or above, and it should be, this is the only policy to configure. With the policy in place, here’s a couple of examples of the user experience when running unlicensed applications. Here’s the Microsoft 365 Apps on a Windows Server Remote Desktop Session Host:</p>
<p><a href="/media/2023/04/Windows2022Microsoft365Apps.png"><img src="/media/2023/04/Windows2022Microsoft365Apps.png" alt="Project in viewer mode on Windows Server 2022" /></a></p>
<p class="figcaption">Project in viewer mode, and Word, Excel, and PowerPoint in full licensed mode on Windows Server 2022.</p>
<p>And here’s a similar experience on a Windows 11 PC:</p>
<p><a href="/media/2023/04/Windows11Microsoft365Apps.png"><img src="/media/2023/04/Windows11Microsoft365Apps.png" alt="Project in viewer mode on Windows 11" /></a></p>
<p class="figcaption">Project in viewer mode, and Word in full licensed mode on Windows 11.</p>
<p class="note" title="Consider this">With this policy in place on any device type, you can now manage a single package with any mix of licensed and unlicensed users. <strong>In fact, I think this approach works so well, this policy setting should be enabled by default</strong>.</p>
<h2 id="what-about-access">What about Access</h2>
<p>There is one application that could force you to deploy multiple packages - Microsoft Access. There are a few approaches you could take managing access to Access.</p>
<ol>
<li>Don’t include Access in your package - hopefully the default for most environments</li>
<li>Create a default package without Access (or with the Access Runtime) if you need it, and a seperate package that includes Access. I’m assuming that Access is only required on a small number of machines, so it shouldn’t be too difficult to maintain a seperate package. This would be the simplest approach for physical PCs</li>
<li>Include Access in your single Microsoft 365 Apps package and use FSLogix App Masking to control who can use Access. This approach would be needed for shared virtual desktops, but could also be used for physical desktops</li>
</ol>
<h3 id="fslogix-app-masking-for-access">FSLogix App Masking for Access</h3>
<p>To create an FSLogix Apps Masking rule set for Access, the rule set can define the minimum components for Access, including the Access shortcut and <code class="language-plaintext highlighter-rouge">MSACCESS.EXE</code>. I won’t go into full detail here on how App Masking works, but I will provide an approach to easily create an App Masking rule set for Access.</p>
<p>Creation of the rule set can be simplified with <code class="language-plaintext highlighter-rouge">New-MicrosoftOfficeRuleset.ps1</code> - this script is hosted on GitHub in my <a href="https://github.com/aaronparker/fslogix/tree/main/Rules">fslogix</a> repository and for details on how to use the script, review the <a href="https://stealthpuppy.com/fslogix/applicationkeys/">FSLogix App masking</a> documentation.</p>
<p>To generate App Masking rule set for Access, use the following PowerShell commands on a virtual machine with a Microsoft 365 Apps for enterprise installation:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Install-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"FSLogix.PowerShell.Rules"</span><span class="w">
</span><span class="o">.</span><span class="n">\New-MicrosoftOfficeRuleset.ps1</span><span class="w"> </span><span class="nt">-SearchString</span><span class="w"> </span><span class="s2">"Access"</span><span class="w">
</span></code></pre></div></div>
<p class="note" title="Important">Review the generated App Masking rule set and validate in a test environment before implementing in production. The generated rule set will include a bunch of registry keys below <code class="language-plaintext highlighter-rouge">HKLM\SOFTWARE\Classes\CSLID</code> and other registry items that do not relate to Access that should be removed before deployment.</p>
<h2 id="wrap-up">Wrap Up</h2>
<p>Providing a view or read-only mode for the Microsoft Office or Microsoft 365 Apps is not new, but a common challenge. In the past Microsoft has provided dedicated Office viewer applications; however, beyond Visio, these apps no longer exist.</p>
<p>Although not without some specific challenges (see <a href="https://stealthpuppy.com/m365apps-frontline-workers/#enable-the-microsoft-365-apps-in-viewer-mode">here re:Outlook and OneNote</a>), viewer mode for the Microsoft 365 Apps now makes it much easier to deploy the Microsoft 356 Apps and cater for mixed use-cases or environments with mixed licensing.</p>
<p>For many organisations, or at least the majority of corporate Windows desktops, a one size fits all approach deployment is now possible.</p>Aaron Parkeraaron@stealthpuppy.comSupport Frontline Workers on Shared Virtual Desktops2023-04-09T13:56:00+00:002023-04-09T13:56:00+00:00http://stealthpuppy.com/support-frontline-workers-on-shared-virtual-desktops<ul id="markdown-toc">
<li><a href="#supporting-frontline-workers-on-a-single-image" id="markdown-toc-supporting-frontline-workers-on-a-single-image">Supporting Frontline Workers on a Single Image</a></li>
<li><a href="#enable-the-microsoft-365-apps-in-viewer-mode" id="markdown-toc-enable-the-microsoft-365-apps-in-viewer-mode">Enable the Microsoft 365 Apps in viewer mode</a></li>
<li><a href="#hide-microsoft-outlook-and-onenote" id="markdown-toc-hide-microsoft-outlook-and-onenote">Hide Microsoft Outlook and OneNote</a></li>
<li><a href="#implement-web-application-alternatives" id="markdown-toc-implement-web-application-alternatives">Implement Web Application Alternatives</a> <ul>
<li><a href="#default-mail-handler" id="markdown-toc-default-mail-handler">Default Mail Handler</a></li>
</ul>
</li>
<li><a href="#wrap-up" id="markdown-toc-wrap-up">Wrap Up</a></li>
</ul>
<p>The <a href="https://learn.microsoft.com/en-us/microsoft-365/frontline/flw-licensing-options">Microsoft 365 F1/F3 and Office 365 F3 licenses</a> are aimed at frontline workers. These licenses do not include the desktop versions of the Microsoft 365 Apps, so users with these licenses cannot run those applications.</p>
<p>You could create two images - one with the Microsoft 365 Apps to support licensed users and one without to support frontline users; however, the result will be less than optimal use of your VDI compute capacity by requiring more session hosts for the same number of concurrent users.</p>
<p>The Microsoft 365 Apps support a <a href="https://learn.microsoft.com/en-us/deployoffice/overview-viewer-mode">viewer mode</a> which until recently put the applications into a view-only mode for all users on same session host. Fortunately, Microsoft changed how this feature works in August 2022. For the purposes of this article, I will assume your environment is on the Microsoft 365 Apps for enterprise Semi-Annual Channel 2208 or later. This minimum version means that only a single policy configuration is required and all supported applications work as expected.</p>
<h2 id="supporting-frontline-workers-on-a-single-image">Supporting Frontline Workers on a Single Image</h2>
<p>In this article, I’ll show you how to deploy a configuration that will support users with mixed licenses on the same session host. This provides a targeted experience each licensed user type on a single image. The image could be multi-session Windows Server or Windows 10/11 multi-session or pooled desktops on Windows 10/11. The configuration will:</p>
<ul>
<li>Enable the Microsoft 365 Apps in viewer mode using Group Policy or Microsoft Intune</li>
<li>Hide applications that don’t behave as expected in viewer mode using FSLogix App Masking</li>
<li>Implement web alternatives for these applications using Group Policy or Microsoft Intune</li>
</ul>
<p>On the same image, users with Microsoft 365 E3/E5 licenses will have access to the full desktop application experience that those licenses entitle them for.</p>
<p class="note" title="Important">For the FSLogix App masking solution to work, the session hosts must be joined to Active Directory. FSLogix App Masking assignments do not yet support Azure AD joined machines.</p>
<h2 id="enable-the-microsoft-365-apps-in-viewer-mode">Enable the Microsoft 365 Apps in viewer mode</h2>
<p>The Microsoft 365 Apps in viewer mode will allow a frontline worker to view and print documents in Word, Excel, PowerPoint, Visio or Project, providing them with a familiar workflow when using a Windows desktop. To use viewer mode, the <a href="https://learn.microsoft.com/en-us/officeupdates/update-history-microsoft365-apps-by-date">version of the Microsoft 365 Apps for enterprise in the image must be current</a>.</p>
<p>Viewer mode is supported for version 1902 or later of Word, Excel, and PowerPoint, and version 2005 or later of Project and Visio. At the time of writing, the latest supported version of the Microsoft 365 Apps is 2202. Given the <a href="https://msrc.microsoft.com/blog/2023/03/microsoft-mitigates-outlook-elevation-of-privilege-vulnerability/">recent vulnerability in Outlook</a>, all environments should be current and be able to use viewer mode.</p>
<p>To optimise the solution outlined in this article, it is important to have deployed a current version of the Microsoft 365 Apps, because:</p>
<blockquote class="lead">
<p>For version 2205 and later, if viewer mode is enabled, but the user has a license for the product, such as Visio, then the user will have an activated, fully functional version of that product. The other unlicensed products on the device, such as Project, will remain in viewer mode.</p>
</blockquote>
<p>To enable viewer mode, use either of the following configuration settings (depending on how the session hosts are managed):</p>
<ul>
<li>In a Group Policy Object assigned to the organisational unit containing the target computer accounts, enable the <strong>Use Viewer Mode</strong> policy setting under <strong>Computer Configuration / Policies / Administrative Templates / Microsoft Office 2016 (Machine) / Licensing Settings</strong></li>
<li>In Intune, create a device configuration profile using the Settings Catalog, and enable <strong>Use Viewer Mode</strong> under <strong>Microsoft Office 2016 (Machine) / Licensing Settings</strong>. Assign the policy to the target devices</li>
</ul>
<p>Once enabled, a user without a license or with the Microsoft 365 F1/F3 license will see application such as Word in viewer mode:</p>
<p><a href="/media/2023/04/WordInViewerMode.png"><img src="/media/2023/04/WordInViewerMode.png" alt="Microsoft Word in viewer mode" /></a></p>
<p class="figcaption">Microsoft Word in viewer mode.</p>
<p>Viewer mode works just as expected for Word, Excel, PowerPoint, Visio and Project - a document can be viewed and printed. Viewer mode for Outlook and OneNote though don’t provide an optimised experience.</p>
<p>Here’s Outlook in viewer mode - I can view my mailbox, and even delete emails and mark them as read or unread; however, I cannot reply to emails or create new emails. Users may find this experience confusing.</p>
<p><a href="/media/2023/04/OutlookInViewerMode.png"><img src="/media/2023/04/OutlookInViewerMode.png" alt="Microsoft Outlook in viewer mode" /></a></p>
<p class="figcaption">Microsoft Outlook in viewer mode. Note that buttons in the ribbon are not greyed out.</p>
<p>Here’s OneNote in viewer mode - I can view my notes, and even create new note and make changes to existing notes that are synchronised; however, there are various functions such as Paste that do not work. Like Outlook, this experience may also be confusing.</p>
<p><a href="/media/2023/04/OneNoteInViewerMode.png"><img src="/media/2023/04/OneNoteInViewerMode.png" alt="Microsoft OneNote in viewer mode" /></a></p>
<p class="figcaption">Microsoft OneNote in viewer mode.</p>
<p>To make the solution effective for frontline workers, we need to remove these applications from view (not from the image), which we can do with <a href="https://learn.microsoft.com/en-us/fslogix/tutorial-application-rule-sets">FSLogix App Masking</a>.</p>
<h2 id="hide-microsoft-outlook-and-onenote">Hide Microsoft Outlook and OneNote</h2>
<p>To remove access to the desktop versions of Outlook and OneNote, FSLogix Apps Masking can be used to define these applications and hide them from the end user. I won’t go into full detail here on how App Masking works, but I will provide an approach to easily create an App Masking rule set for Outlook and OneNote.</p>
<p><code class="language-plaintext highlighter-rouge">New-MicrosoftOfficeRuleset.ps1</code> can be used to generate an App Masking rule set for individual applications in the Microsoft 365 Apps suite. The script is hosted on GitHub in my <a href="https://github.com/aaronparker/fslogix/tree/main/Rules">fslogix</a> repository and for details on how to use the script review the <a href="https://stealthpuppy.com/fslogix/applicationkeys/">FSLogix App masking</a> documentation.</p>
<p>To use the script to generate App Masking rule sets for Outlook and OneDrive, use the following PowerShell commands on a virtual machine with a Microsoft 365 Apps for enterprise installation:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Install-Module</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"FSLogix.PowerShell.Rules"</span><span class="w">
</span><span class="o">.</span><span class="n">\New-MicrosoftOfficeRuleset.ps1</span><span class="w"> </span><span class="nt">-SearchString</span><span class="w"> </span><span class="s2">"Outlook"</span><span class="w">
</span><span class="o">.</span><span class="n">\New-MicrosoftOfficeRuleset.ps1</span><span class="w"> </span><span class="nt">-SearchString</span><span class="w"> </span><span class="s2">"OneNote"</span><span class="w">
</span></code></pre></div></div>
<p class="note" title="Important">Review the generated App Masking rule set and validate in a test environment before implementing in production.</p>
<p>Configure assignments on the rule set so that an Active Directory group is targeted to the rule similar to the example below. Here we are using an AD group that is also used to manage license assignments in Azure AD.</p>
<p><a href="/media/2023/04/FSLogixAssignments.png"><img src="/media/2023/04/FSLogixAssignments.png" alt="FSLogix App Masking assignments" /></a></p>
<p class="figcaption">Assignments on the App Masking rules set.</p>
<h2 id="implement-web-application-alternatives">Implement Web Application Alternatives</h2>
<p>At this point, frontline workers can sign into a virtual desktop and perform most tasks in the browser, but they will have access to a mix of local desktop application and web applications. To improve the user experience, we can configure specific web applications to run as <a href="https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/ux">Progressive Web Apps (PWAs)</a>.</p>
<p>Here’s Outlook for the web running as a desktop application:</p>
<p><a href="/media/2023/04/OutlookPwa.png"><img src="/media/2023/04/OutlookPwa.png" alt="Outlook web application" /></a></p>
<p class="figcaption">Microsoft Outlook as a web application. The application experience is similar to the preview of the new Outlook desktop application.</p>
<p class="note">OneNote requires a user specific URL, so adding OneNote as a web application doesn’t work as intended, so I’ve not included it in the example approach here.</p>
<p>Web apps are added for the user with the <a href="https://learn.microsoft.com/en-us/deployedge/microsoft-edge-policies#configure-list-of-force-installed-web-apps">Configure list of force-installed Web Apps</a> policy.</p>
<ul>
<li>In a Group Policy Object assigned to the organisational unit containing the target user accounts (or via loopback on the computer account OU), enable the <strong>Configure list of force-installed Web Apps</strong> policy setting under <strong>User Configuration / Policies / Administrative Templates / Microsoft Edge</strong></li>
<li>In Intune, create a device configuration profile using the Settings Catalog, and enable <strong>Configure list of force-installed Web Apps (User)</strong> under <strong>Microsoft Edge</strong>. Assign the policy to an Azure AD user group</li>
</ul>
<p>Each policy accepts a JSON representation of the web apps as defined in the Microsoft documentation. Below is an example including Outlook.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://outlook.office.com/mail/"</span><span class="p">,</span><span class="w">
</span><span class="nl">"default_launch_container"</span><span class="p">:</span><span class="w"> </span><span class="s2">"window"</span><span class="p">,</span><span class="w">
</span><span class="nl">"create_desktop_shortcut"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
</span><span class="nl">"fallback_app_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Outlook"</span><span class="p">,</span><span class="w">
</span><span class="nl">"custom_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Outlook"</span><span class="p">,</span><span class="w">
</span><span class="nl">"install_as_shortcut"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>
<p>Before pasting into the policy, <a href="https://jsonformatter.org/json-minify">minify</a> the JSON string. After the policy is applied, the application shortcuts will be added to the Start menu as in the example below.</p>
<p><a href="/media/2023/04/StartMenu.png"><img src="/media/2023/04/StartMenu.png" alt="Start menu showing the installed web apps" /></a></p>
<p class="figcaption">Start menu on Windows Server 2022, showing the installed web apps including Microsoft Outlook (Outlook PWA).</p>
<p class="note" title="Important">The shortcut name for Outlook will be renamed to <strong>Outlook (PWA)</strong> because this it what is defined in the PWA definition by Microsoft. The <code class="language-plaintext highlighter-rouge">custom_name</code> value in the JSON will not take effect unless your image is running Microsoft Edge 112. The overall experience of this solution should be improved with Edge 112 or higher</p>
<p>After the policy is applied, the web apps will not be created until Microsoft Edge is launched. The policy is browser specific and not tied to the OS, thus it is not read until the browser is started.</p>
<p>To ensure the web apps are added after sign-in without waiting for the user to start Edge, enable the following policy:</p>
<ul>
<li>In a Group Policy Object assigned to the organisational unit containing the target user accounts (or via loopback on the computer account OU), enable the <strong>Enable startup boost</strong> policy setting under <strong>User Configuration / Policies / Administrative Templates / Microsoft Edge / Performance</strong></li>
<li>In Intune, create a device configuration profile using the Settings Catalog, and enable <strong>Enable startup boost (User)</strong> under <strong>Microsoft Edge / Performance</strong>. Assign the policy to an Azure AD user group</li>
</ul>
<p>This policy will cause several Microsoft Edge processes to start at sign-in and the web apps will be created soon after. If you’re concerned about CPU and RAM consumption, this may be a trade-off for an improved user experience.</p>
<p class="note" title="Important">A successful authentication to Microsoft 365 / Azure AD is required for the Outlook web app to complete its configuration including the shortcut icon and registering as a mail handler. Authentication should occur after any instance of Edge is started and signed into.</p>
<h3 id="default-mail-handler">Default Mail Handler</h3>
<p>The Outlook web app can be set as the default mail handler. The user can choose to change their defaults from the Settings app, via a prompt in Outlook.</p>
<p><a href="/media/2023/04/OutlookPwaMailHandler.png"><img src="/media/2023/04/OutlookPwaMailHandler.png" alt="The Outlook web app asking the user to be the default main handler" /></a></p>
<p class="figcaption">The Outlook web app asking the user to be the default main handler.</p>
<p>Setting this option on behalf of the user could be a challenge due to timing. It’s worth testing whether the Outlook web app can be set as the default mail client, after the user launches the application, via Group Policy, <a href="https://kolbi.cz/blog/2017/10/25/setuserfta-userchoice-hash-defeated-set-file-type-associations-per-user/">SetUserFTA</a>, or <a href="https://github.com/DanysysTeam/PS-SFTA">PS-SFTA</a>.</p>
<h2 id="wrap-up">Wrap Up</h2>
<p>Supplying frontline workers, who using a Windows desktop, an application experience that is familiar will make it easier on the organisation to support these important users. Additionally, being able to optimise the delivery of mixed user personas on the same virtual desktop image will help you avoid a less than optimal use of compute resources.</p>Aaron Parkeraaron@stealthpuppy.comAutomate Microsoft Intune As-Built Documentation on Azure DevOps2022-03-02T21:00:00+00:002022-03-02T21:00:00+00:00http://stealthpuppy.com/automate-intune-documentation-azure<ul id="markdown-toc">
<li><a href="#backup-your-intune-configurations" id="markdown-toc-backup-your-intune-configurations">Backup your Intune Configurations</a> <ul>
<li><a href="#generating-a-markdown-report" id="markdown-toc-generating-a-markdown-report">Generating a Markdown Report</a></li>
<li><a href="#convert-the-markdown-to-pdf" id="markdown-toc-convert-the-markdown-to-pdf">Convert the Markdown to PDF</a></li>
</ul>
</li>
<li><a href="#code-and-pipeline-hosting-options" id="markdown-toc-code-and-pipeline-hosting-options">Code and Pipeline Hosting Options</a></li>
<li><a href="#intune-backup-and-document-pipeline-with-azure-devops" id="markdown-toc-intune-backup-and-document-pipeline-with-azure-devops">Intune Backup and Document Pipeline with Azure DevOps</a> <ul>
<li><a href="#backup-document-tag-publish-pipeline" id="markdown-toc-backup-document-tag-publish-pipeline">Backup, Document, Tag, Publish Pipeline</a></li>
<li><a href="#configure-the-repository" id="markdown-toc-configure-the-repository">Configure the Repository</a></li>
<li><a href="#configure-the-pipeline" id="markdown-toc-configure-the-pipeline">Configure the Pipeline</a></li>
<li><a href="#configure-permissions" id="markdown-toc-configure-permissions">Configure Permissions</a></li>
<li><a href="#execute-the-pipeline" id="markdown-toc-execute-the-pipeline">Execute the Pipeline</a></li>
</ul>
</li>
<li><a href="#concluding" id="markdown-toc-concluding">Concluding</a> <ul>
<li><a href="#github-repository-template" id="markdown-toc-github-repository-template">GitHub Repository Template</a></li>
</ul>
</li>
</ul>
<p>If there’s anything that’s certain in a Microsoft Endpoint Manager project, it’s the rate of change. Such is the rate of change, that <a href="https://docs.microsoft.com/en-us/mem/intune/fundamentals/whats-new">Intune receives updates</a> almost every week.</p>
<p>If change is constant, what value is there in manually creating an as-built document for your MEM projects? The as-built is out of date as soon as you’ve finished writing it, and the document will quickly lose value as time passes.</p>
<p>What is more valuable to an IT operational team, is to track changes made to the Intune tenant allowing the administrator to make a before and after comparison when troubleshooting, report on configuration changes, and perhaps even looking for who to blame when something goes wrong. OK, don’t do the last one - the blame game is not healthy.</p>
<p class="lead">How should you generate documentation and track changes? What is a better approach?</p>
<h2 id="backup-your-intune-configurations">Backup your Intune Configurations</h2>
<p>The <a href="https://github.com/jseerden/IntuneBackupAndRestore">IntuneBackupAndRestore</a> PowerShell module has been around for some time and does a great job of backup and restore/import of configurations in a single tenant or across tenants.</p>
<p>However, <a href="https://github.com/almenscorner/IntuneCD">IntuneCD</a> is a solution that can make this process simpler. IntuneCD is a <a href="https://pypi.org/project/IntuneCD/">Python project</a> that performs several key functions:</p>
<ul>
<li>Backup or export of the Intune configurations from the tenant</li>
<li>Automate the export of configurations from a dev/test tenant and import into a production tenant</li>
<li>Generate documentation in markdown format from the exported configurations</li>
</ul>
<p>The documentation covers how to configure <a href="https://github.com/almenscorner/IntuneCD/blob/main/README.md#required-azure-ad-application-graph-api-permissions">authentication to the Microsoft Graph API</a> using an <a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration">Azure AD app registration</a> and how to <a href="https://github.com/almenscorner/IntuneCD/blob/main/README.md#how-do-i-use-it">use the tool</a>. Follow the documentation and validate that you can authenticate to your tenant.</p>
<p>For the purposes of this article, I am assuming you’re already familiar with Azure DevOps and Git, Azure AD, Intune, and have some level of experience with automating these tools. I have included links to the documentation where I can for further reading on specific topics.</p>
<h3 id="generating-a-markdown-report">Generating a Markdown Report</h3>
<p>With authentication working, creating a backup from your Intune tenant is via the <code class="language-plaintext highlighter-rouge">IntuneCD-startbackup</code> command, which will export the configuration in YAML or JSON format. The <code class="language-plaintext highlighter-rouge">IntuneCD-startdocumentation</code> command will then create a an as-built document in markdown format from the exported configuration files:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IntuneCD-startbackup <span class="nt">-m</span> 1 <span class="nt">-o</span> yaml <span class="nt">-p</span> ./backup-path
IntuneCD-startdocumentation <span class="nt">-p</span> ./backup-path <span class="nt">-o</span> ./as-built.md <span class="nt">-t</span> nameoftenant <span class="nt">-i</span> <span class="s1">'Intune as-built'</span>
</code></pre></div></div>
<h3 id="convert-the-markdown-to-pdf">Convert the Markdown to PDF</h3>
<p>I tested a couple of Python python projects for converting markdown into a PDF document; however, these could not handle the markdown output from IntuneCD. Instead, I’ve found that <a href="https://www.npmjs.com/package/md-to-pdf">Markdown to PDF</a>, a Node.js command line tool, could handle the conversion without issue.</p>
<p>To install md-to-pdf and covert the markdown into PDF, we can use the following commands:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm i <span class="nt">-g</span> md-to-pdf
md-to-pdf ./as-built.md <span class="nt">--pdf-options</span> <span class="s1">'{ "format": "A4", "margin": "10mm", "printBackground": false }'</span>
</code></pre></div></div>
<p>These commands can be run locally on any system that supports Python and Node.js; however, a better approach would be to automate the entire process via a pipeline that performs the backup and generation of the documentation on a schedule.</p>
<h2 id="code-and-pipeline-hosting-options">Code and Pipeline Hosting Options</h2>
<p>Hosting the exported configurations in a <a href="https://git-scm.com/">Git</a> repository provides an ideal solution for change tracking and portability. IntuneCD outputs Intune configurations in JSON or YAML, thus the output suits management in a version control system. Configuration output files are plain text, so comparing changes across Git version history is easy.</p>
<p><a href="/media/2022/02/fork2.png"><img src="/media/2022/02/fork2.png" alt="Intune configuration repository viewed in Fork" /></a></p>
<p class="figcaption">Exported configurations from Intune hosted in a Azure DevOps Git repository</p>
<p>There are plenty of options for hosting Git repositories, and my preferences are GitHub and Azure DevOps. I’ve covered <a href="https://stealthpuppy.com/automate-intune-documentation-github/">setting up this process previously for GitHub</a>.</p>
<p>In this article, I’ll cover the setup of a pipeline that will automate the backup and document generation of Intune with IntuneCD on Azure DevOps. In my view, Azure DevOps makes most sense for production environments because you can manage access to the DevOps project via Azure AD.</p>
<h2 id="intune-backup-and-document-pipeline-with-azure-devops">Intune Backup and Document Pipeline with Azure DevOps</h2>
<p>Using an <a href="https://docs.microsoft.com/en-au/azure/devops/pipelines/?view=azure-devops">Azure Pipline</a>, we can schedule the backup and report generation of an Intune tenant. The first thing you’ll need to do is <a href="https://docs.microsoft.com/en-us/azure/devops/organizations/projects/create-project">create a project</a> ensuring the project is private.</p>
<p><a href="/media/2022/02/devops-newproject.jpeg"><img src="/media/2022/02/devops-newproject.jpeg" alt="Creating a new Azure DevOps project" /></a></p>
<p class="figcaption">Creating a new Azure DevOps project</p>
<p class="note" title="Important">It’s vitally important that the project is set to <em>private</em>, because the Intune backup will contain sensitive information. Even if the configuration backup does not include passwords, it does provide a detailed view of how you secure your devices.</p>
<p>Using an Azure DevOps project with the pipeline covered below, the backup and as-built will be generated, and we can we can compare commits to track changes to the tenant:</p>
<video controls="">
<source src="/media/2022/02/DevOpsCompareReleases.mp4" type="video/mp4" />
</video>
<p class="figcaption">Compare configurations across tags and releases in an Azure DevOps project.</p>
<h3 id="backup-document-tag-publish-pipeline">Backup, Document, Tag, Publish Pipeline</h3>
<p>The pipeline includes four stages - Backup (the Intune configurations are exported to YAML or JSON), Document (the as-built PDF document is generated), and <a href="https://git-scm.com/book/en/v2/Git-Basics-Tagging">Tag</a> (the repository is tagged for changes committed from that workflow), and publishes the as-built to the pipeline.</p>
<p><a href="/media/2022/02/devops-pipelinerun.jpeg"><img src="/media/2022/02/devops-pipelinerun.jpeg" alt="Azure Pipline run" /></a></p>
<p class="figcaption">Azure Pipeline run for Intune backup, document, tag the repository and publishing the as-built.</p>
<p>The Azure Pipeline runs the following tasks, but unlike my previous article using GitHub, this pipeline is not configured to sign commits.</p>
<ul>
<li><strong>Backup stage</strong>
<ul>
<li>Check out the repository</li>
<li>Configure git commit credentials</li>
<li>Install IntuneCD</li>
<li>Backup the Intune configuration</li>
<li>Commit changes to the repository</li>
</ul>
</li>
<li><strong>Document stage</strong>
<ul>
<li>Check out the repository</li>
<li>Configure git commit credentials</li>
<li>Install IntuneCD</li>
<li>Create the as-built in markdown format</li>
<li>Convert the markdown to PDF format</li>
<li>Commit changes to the repository</li>
</ul>
</li>
<li><strong>Tag stage</strong>
<ul>
<li>Check out the repository</li>
<li>Configure git commit credentials</li>
<li>Tag the repository</li>
</ul>
</li>
<li><strong>Publish stage</strong>
<ul>
<li>Check out the repository</li>
<li>Configure git commit credentials</li>
<li><a href="https://docs.microsoft.com/en-us/azure/devops/pipelines/artifacts/artifacts-overview">Publish the as-built document to the pipeline</a></li>
</ul>
</li>
</ul>
<p>Once the pipeline is completed, a version of as-built document is added as an artifact on the pipeline:</p>
<p><a href="/media/2022/02/DevOpsArtifacts.jpeg"><img src="/media/2022/02/DevOpsArtifacts.jpeg" alt="Azure DevOps artifacts" /></a></p>
<p class="figcaption">As-built documentation artifact published to an Azure DevOps pipeline.</p>
<h3 id="configure-the-repository">Configure the Repository</h3>
<p>Once you have created your project, <a href="https://docs.microsoft.com/en-us/azure/devops/repos/git/clone">clone the repository</a> and commit the required files for the project.</p>
<p><a href="/media/2022/02/DevOpsRepository.png"><img src="/media/2022/02/DevOpsRepository.png" alt="Git repository in the Azure DevOps project" /></a></p>
<p class="figcaption">Git repository in the Azure DevOps project.</p>
<p>Create a <code class="language-plaintext highlighter-rouge">.gitignore</code> file, so the temporary markdown document isn’t committed to the repository during the pipeline run:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#file: ".gitignore"</span>
<span class="c1">## Ignore files for macOS</span>
<span class="s">.DS_Store</span>
<span class="c1">## You can specify authentication details for IntuneCD in a JSON file</span>
<span class="s">auth.json</span>
<span class="c1">## Pipeline files</span>
<span class="c1"># prod-as-built.pdf is the as-built document output from the pipeline</span>
<span class="c1"># this file is added as an artifact on a release, so it could excluded from commits</span>
<span class="s">prod-as-built.pdf</span>
<span class="s">prod-as-built.html</span>
</code></pre></div></div>
<p>Add the pipeline as <code class="language-plaintext highlighter-rouge">intune-backup.yml</code> in the root of the repository:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#file: "intune-backup.yml"</span>
<span class="na">trigger</span><span class="pi">:</span> <span class="s">none</span>
<span class="na">schedules</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0</span><span class="nv"> </span><span class="s">1</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1am"</span>
<span class="na">branches</span><span class="pi">:</span>
<span class="na">include</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">main</span>
<span class="na">jobs</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">backup</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">backup</span>
<span class="na">pool</span><span class="pi">:</span>
<span class="na">vmImage</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">checkout</span><span class="pi">:</span> <span class="s">self</span>
<span class="na">persistCredentials</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Set git global settings</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Configure Git</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git config --global user.name $(USER_NAME)</span>
<span class="s">git config --global user.email $(USER_EMAIL)</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Remove existing prod-backup directory</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">rm -f -r -v "$(Build.SourcesDirectory)/prod-backup"</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="c1"># Install IntuneCD</span>
<span class="c1"># https://github.com/almenscorner/IntuneCD</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Install IntuneCD</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">pip3 install IntuneCD</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Backup the latest configuration, using the current directory</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">IntuneCD backup</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">mkdir -p "$(Build.SourcesDirectory)/prod-backup"</span>
<span class="s">IntuneCD-startbackup \</span>
<span class="s">--mode=1 \</span>
<span class="s">--output=json \</span>
<span class="s">--path="$(Build.SourcesDirectory)/prod-backup"</span>
<span class="s">#--localauth=./auth.json</span>
<span class="s">#--exclude=assignments</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="na">env</span><span class="pi">:</span>
<span class="na">TENANT_NAME</span><span class="pi">:</span> <span class="s">$(TENANT_NAME)</span>
<span class="na">CLIENT_ID</span><span class="pi">:</span> <span class="s">$(CLIENT_ID)</span>
<span class="na">CLIENT_SECRET</span><span class="pi">:</span> <span class="s">$(CLIENT_SECRET)</span>
<span class="c1"># Commit changes and push to repo</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Commit changes</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">DATEF=`date +%Y.%m.%d`</span>
<span class="s">git add --all</span>
<span class="s">git commit -m "Intune config backup $DATEF"</span>
<span class="s">git push origin HEAD:main</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">document</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">document</span>
<span class="na">dependsOn</span><span class="pi">:</span> <span class="s">backup</span>
<span class="na">pool</span><span class="pi">:</span>
<span class="na">vmImage</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">checkout</span><span class="pi">:</span> <span class="s">self</span>
<span class="na">persistCredentials</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Set git global settings</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Configure Git</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git config --global user.name $(USER_NAME)</span>
<span class="s">git config --global user.email $(USER_EMAIL)</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Pull origin</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git pull origin main</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="c1"># Install IntuneCD</span>
<span class="c1"># https://github.com/almenscorner/IntuneCD</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Install IntuneCD</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">pip3 install IntuneCD</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Create markdown documentation</span>
<span class="c1"># Install IntuneCD</span>
<span class="c1"># https://github.com/almenscorner/IntuneCD</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Generate markdown document</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">INTRO="Endpoint Manager backup and documentation generated at $(Build.Repository.Uri) <img align=\"right\" width=\"96\" height=\"96\" src=\"./logo.png\">"</span>
<span class="s">IntuneCD-startdocumentation \</span>
<span class="s">--path="$(Build.SourcesDirectory)/prod-backup" \</span>
<span class="s">--outpath="$(Build.SourcesDirectory)/prod-as-built.md" \</span>
<span class="s">--tenantname=$TENANT_NAME \</span>
<span class="s">--intro="$INTRO" \</span>
<span class="s">#--split=Y</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="na">env</span><span class="pi">:</span>
<span class="na">TENANT_NAME</span><span class="pi">:</span> <span class="s">$(TENANT_NAME)</span>
<span class="c1"># Commit changes and push to repo</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Commit changes</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">DATEF=`date +%Y.%m.%d`</span>
<span class="s">git add --all</span>
<span class="s">git commit -m "MEM config as-built $DATEF"</span>
<span class="s">git push origin HEAD:main</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">tag</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">tag</span>
<span class="na">dependsOn</span><span class="pi">:</span> <span class="s">document</span>
<span class="na">pool</span><span class="pi">:</span>
<span class="na">vmImage</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">checkout</span><span class="pi">:</span> <span class="s">self</span>
<span class="na">persistCredentials</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Set git global settings</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Configure Git</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git config --global user.name $(USER_NAME)</span>
<span class="s">git config --global user.email $(USER_EMAIL)</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Pull origin</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git pull origin main</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="c1"># Commit changes and push to repo</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Git tag</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">DATEF=`date +%Y.%m.%d`</span>
<span class="s">git tag -a "v$DATEF" -m "Microsoft Endpoint Manager configuration snapshot $DATEF"</span>
<span class="s">git push origin "v$DATEF"</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">publish</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">publish</span>
<span class="na">dependsOn</span><span class="pi">:</span> <span class="s">tag</span>
<span class="na">pool</span><span class="pi">:</span>
<span class="na">vmImage</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
<span class="na">continueOnError</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">checkout</span><span class="pi">:</span> <span class="s">self</span>
<span class="na">persistCredentials</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Set git global settings</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Configure Git</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git config --global user.name $(USER_NAME)</span>
<span class="s">git config --global user.email $(USER_EMAIL)</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Pull origin</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">git pull origin main</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">false</span>
<span class="c1"># Install md-to-pdf</span>
<span class="c1"># https://github.com/simonhaenisch/md-to-pdf</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Install md-to-pdf</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">npm i --location=global md-to-pdf</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="c1"># Convert markdown document to HTML</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Convert markdown to HTML</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">cat "$(Build.SourcesDirectory)/prod-as-built.md" | md-to-pdf --config-file "$(Build.SourcesDirectory)/md2pdf/pdfconfig.json" --as-html > "$(Build.SourcesDirectory)/prod-as-built.html"</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PublishBuildArtifacts@1</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">pathToPublish</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(Build.SourcesDirectory)/prod-as-built.html"</span>
<span class="na">artifactName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod-as-built.html"</span>
<span class="c1"># Convert markdown document to PDF</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
<span class="na">displayName</span><span class="pi">:</span> <span class="s">Convert markdown to PDF</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
<span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
<span class="s">cat "$(Build.SourcesDirectory)/prod-as-built.md" | md-to-pdf --config-file "$(Build.SourcesDirectory)/md2pdf/pdfconfig.json" > "$(Build.SourcesDirectory)/prod-as-built.pdf"</span>
<span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
<span class="na">failOnStderr</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PublishBuildArtifacts@1</span>
<span class="na">inputs</span><span class="pi">:</span>
<span class="na">pathToPublish</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$(Build.SourcesDirectory)/prod-as-built.pdf"</span>
<span class="na">artifactName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod-as-built.pdf"</span>
</code></pre></div></div>
<h3 id="configure-the-pipeline">Configure the Pipeline</h3>
<p>After committing the files to the repository, <a href="https://docs.microsoft.com/en-us/azure/devops/pipelines/create-first-pipeline">create a pipeline</a> from <code class="language-plaintext highlighter-rouge">intune-backup.yml</code>.</p>
<p><a href="/media/2022/02/DevOpsCreatePipeline.jpeg"><img src="/media/2022/02/DevOpsCreatePipeline.jpeg" alt="Creating an Azure Pipeline from a YAML file in the repository" /></a></p>
<p class="figcaption">Creating an Azure Pipeline from a YAML file in the repository.</p>
<p>The pipeline relies on several variables that you will need to create:</p>
<p><a href="/media/2022/02/DevOpsPipelineVariables.jpeg"><img src="/media/2022/02/DevOpsPipelineVariables.jpeg" alt="Azure DevOps pipeline variables" /></a></p>
<p class="figcaption">Variables used on an Azure DevOps pipeline.</p>
<p>Create the following <a href="https://docs.microsoft.com/en-au/azure/devops/pipelines/process/variables">variables</a>:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">TENANT_NAME</code> - your Azure AD tenant name, .e.g., <code class="language-plaintext highlighter-rouge">stealthpuppylab.onmicrosoft.com</code></li>
<li><code class="language-plaintext highlighter-rouge">CLIENT_ID</code> - the Application (client) ID of the app registration, e.g., <code class="language-plaintext highlighter-rouge">3ea822c2-3644-4e99-984e-279f54f71da4</code></li>
<li><code class="language-plaintext highlighter-rouge">CLIENT_SECRET</code> - the client secret to enable authentication via the app registration</li>
<li><code class="language-plaintext highlighter-rouge">USER_NAME</code> - <a href="https://docs.github.com/en/get-started/getting-started-with-git/setting-your-username-in-git">name of the user</a> for Git commits</li>
<li><code class="language-plaintext highlighter-rouge">USER_EMAIL</code> - <a href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-user-account/managing-email-preferences/setting-your-commit-email-address">email address</a> of the user for Git commits</li>
</ul>
<h3 id="configure-permissions">Configure Permissions</h3>
<p>To allow the pipeline to commit changes to the repository, <a href="https://docs.microsoft.com/en-us/azure/devops/repos/git/set-git-repository-permissions">configure Contribute permissions</a> for the Project Collection Build Service Accounts group or the Build Service account.</p>
<p><a href="/media/2022/02/DevOpsRepoSecurity.png"><img src="/media/2022/02/DevOpsRepoSecurity.png" alt="Configure Contribute permissions for the Project Collection Build Service Accounts group" /></a></p>
<p class="figcaption">Configure Contribute permissions for the Project Collection Build Service Accounts group.</p>
<h3 id="execute-the-pipeline">Execute the Pipeline</h3>
<p>With the project, repository, permissions and the pipeline configured, we can run the pipeline manually or wait for the pipeline to run via the schedule as specified in the pipeline file. The default schedule in the pipeline is 01:00 every 24 hours, give you a daily backup.</p>
<p><a href="/media/2022/02/DevOpsPipelineRuns.jpeg"><img src="/media/2022/02/DevOpsPipelineRuns.jpeg" alt="Viewing runs of the pipeline" /></a></p>
<p class="figcaption">Viewing runs of the pipeline.</p>
<h2 id="concluding">Concluding</h2>
<p>In this article, I’ve provided the foundations for using IntuneCD to automate the backup of your Intune tenant and create an as-built document, using Azure DevOps for hosting the repository and Azure Pipelines to automate the process.</p>
<p>For an IT operational team, service desk or managed service, automatically performing these tasks would be far better time spent that manually creating an as-built document.</p>
<p>If you haven’t read the article already, I’ve also covered the <a href="https://stealthpuppy.com/automate-intune-documentation-github/">same process using GitHub and GitHub Workflows</a>.</p>
<h3 id="github-repository-template">GitHub Repository Template</h3>
<p>Rather than having to build all of this from scratch, I have created a template repository on GitHub that you can clone or fork to start building in your own environment. Hop over to GitHub to get started: <a href="https://github.com/aaronparker/intune-backup-template">intune-backup-template</a>.</p>Aaron Parkeraaron@stealthpuppy.comAutomate Microsoft Intune As-Built Documentation on GitHub2022-03-02T03:17:00+00:002022-03-02T03:17:00+00:00http://stealthpuppy.com/automate-intune-documentation-github<ul id="markdown-toc">
<li><a href="#backup-your-intune-configurations" id="markdown-toc-backup-your-intune-configurations">Backup your Intune Configurations</a> <ul>
<li><a href="#generating-a-markdown-report" id="markdown-toc-generating-a-markdown-report">Generating a Markdown Report</a></li>
<li><a href="#convert-the-markdown-to-pdf" id="markdown-toc-convert-the-markdown-to-pdf">Convert the Markdown to PDF</a></li>
</ul>
</li>
<li><a href="#code-and-pipeline-hosting-options" id="markdown-toc-code-and-pipeline-hosting-options">Code and Pipeline Hosting Options</a></li>
<li><a href="#intune-backup-and-document-pipeline-with-github" id="markdown-toc-intune-backup-and-document-pipeline-with-github">Intune Backup and Document Pipeline with GitHub</a> <ul>
<li><a href="#backup-document-tag-workflow" id="markdown-toc-backup-document-tag-workflow">Backup, Document, Tag Workflow</a></li>
<li><a href="#release-workflow" id="markdown-toc-release-workflow">Release Workflow</a></li>
<li><a href="#configure-the-repository" id="markdown-toc-configure-the-repository">Configure the Repository</a></li>
<li><a href="#workflow-code" id="markdown-toc-workflow-code">Workflow Code</a> <ul>
<li><a href="#backup-document-tag" id="markdown-toc-backup-document-tag">Backup, Document, Tag</a></li>
<li><a href="#release" id="markdown-toc-release">Release</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#concluding" id="markdown-toc-concluding">Concluding</a> <ul>
<li><a href="#github-repository-template" id="markdown-toc-github-repository-template">GitHub Repository Template</a></li>
</ul>
</li>
</ul>
<p>If there’s anything that’s certain in a Microsoft Endpoint Manager project, it’s the rate of change. Such is the rate of change, that <a href="https://docs.microsoft.com/en-us/mem/intune/fundamentals/whats-new">Intune receives updates</a> almost every week.</p>
<p>If change is constant, what value is there in manually creating an as-built document for your MEM projects? The as-built is out of date as soon as you’ve finished writing it, and the document will quickly lose value as time passes.</p>
<p>What is more valuable to an IT operational team, is to track changes made to the Intune tenant allowing the administrator to make a before and after comparison when troubleshooting, report on configuration changes, and perhaps even looking for who to blame when something goes wrong. OK, don’t do the last one - the blame game is not healthy.</p>
<p class="lead">How should you generate documentation and track changes? What is a better approach?</p>
<h2 id="backup-your-intune-configurations">Backup your Intune Configurations</h2>
<p>The <a href="https://github.com/jseerden/IntuneBackupAndRestore">IntuneBackupAndRestore</a> PowerShell module has been around for some time and does a great job of backup and restore/import of configurations in a single tenant or across tenants.</p>
<p>However, <a href="https://github.com/almenscorner/IntuneCD">IntuneCD</a> is a solution that can make this process simpler. IntuneCD is a <a href="https://pypi.org/project/IntuneCD/">Python project</a> that performs several key functions:</p>
<ul>
<li>Backup or export of the Intune configurations from the tenant</li>
<li>Automate the export of configurations from a dev/test tenant and import into a production tenant</li>
<li>Generate documentation in markdown format from the exported configurations</li>
</ul>
<p>The documentation covers how to configure <a href="https://github.com/almenscorner/IntuneCD/blob/main/README.md#required-azure-ad-application-graph-api-permissions">authentication to the Microsoft Graph API</a> using an <a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration">Azure AD app registration</a> and how to <a href="https://github.com/almenscorner/IntuneCD/blob/main/README.md#how-do-i-use-it">use the tool</a>. Follow the documentation and validate that you can authenticate to your tenant.</p>
<p>For the purposes of this article, I am assuming you’re already familiar with GitHub and Git, Azure AD, Intune, and have some level of experience with automating these tools. I have included links to the documentation where I can for further reading on specific topics.</p>
<h3 id="generating-a-markdown-report">Generating a Markdown Report</h3>
<p>With authentication working, creating a backup from your Intune tenant is via the <code class="language-plaintext highlighter-rouge">IntuneCD-startbackup</code> command, which will export the configuration in YAML or JSON format. The <code class="language-plaintext highlighter-rouge">IntuneCD-startdocumentation</code> command will then create a an as-built document in markdown format from the exported configuration files:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IntuneCD-startbackup <span class="nt">-m</span> 1 <span class="nt">-o</span> yaml <span class="nt">-p</span> ./backup-path
IntuneCD-startdocumentation <span class="nt">-p</span> ./backup-path <span class="nt">-o</span> ./as-built.md <span class="nt">-t</span> nameoftenant <span class="nt">-i</span> <span class="s1">'Intune as-built'</span>
</code></pre></div></div>
<h3 id="convert-the-markdown-to-pdf">Convert the Markdown to PDF</h3>
<p>I tested a couple of Python python projects for converting markdown into a PDF document; however, these could not handle the markdown output from IntuneCD. Instead, I’ve found that <a href="https://www.npmjs.com/package/md-to-pdf">Markdown to PDF</a>, a Node.js command line tool, could handle the conversion without issue.</p>
<p>To install md-to-pdf and covert the markdown into PDF, we can use the following commands:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm i <span class="nt">-g</span> md-to-pdf
md-to-pdf ./as-built.md <span class="nt">--pdf-options</span> <span class="s1">'{ "format": "A4", "margin": "10mm", "printBackground": false }'</span>
</code></pre></div></div>
<p>These commands can be run locally on any system that supports Python and Node.js; however, a better approach would be to automate the entire process via a pipeline that performs the backup and generation of the documentation on a schedule.</p>
<h2 id="code-and-pipeline-hosting-options">Code and Pipeline Hosting Options</h2>
<p>Hosting the exported configurations in a <a href="https://git-scm.com/">Git</a> repository provides an ideal solution for change tracking and portability. IntuneCD outputs Intune configurations in JSON or YAML, thus the output suits management in a version control system. Configuration output files are plain text, so comparing changes across Git version history is easy.</p>
<p><a href="/media/2022/02/fork.png"><img src="/media/2022/02/fork.png" alt="Intune configuration repository viewed in Fork" /></a></p>
<p class="figcaption">Exported configurations from Intune hosted in a Git repository</p>
<p>There are plenty of options for hosting Git repositories, but my preferences are GitHub and Azure DevOps. There are some considerations for each:</p>
<ul>
<li><strong>GitHub</strong> - supports individual and organisational accounts, and private repositories even for free accounts. <a href="https://docs.github.com/en/actions/using-workflows/triggering-a-workflow">GitHub Workflows</a> are feature rich and well supported, and management of GitHub repositories are quite easy</li>
<li><a href="https://azure.microsoft.com/en-au/solutions/devops/"><strong>Azure DevOps</strong></a> - supports <a href="https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/access-with-azure-ad">authentication via Azure AD</a>, thus a better solution for managing authentication and authorisation. <a href="https://azure.microsoft.com/en-au/services/devops/pipelines/">Azure DevOps pipelines</a> are also feature rich and well supported</li>
</ul>
<p>I’ll cover the setup of a pipeline that will automate the backup and document generation of Intune with IntuneCD on GitHub; however, in my view Azure DevOps makes most sense as a hosting option for production environments.</p>
<h2 id="intune-backup-and-document-pipeline-with-github">Intune Backup and Document Pipeline with GitHub</h2>
<p>Using a <a href="https://docs.github.com/en/actions/using-workflows">GitHub Workflow</a>, we can schedule the backup and report generation of an Intune tenant. The first thing you’ll need to do is <a href="https://docs.github.com/en/get-started/quickstart/create-a-repo">create</a> a <a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility">private repository</a>.</p>
<p class="note" title="Important">It’s vitally important that the repository is set to <em>private</em>, because the Intune backup will contain sensitive information. Even if the configuration backup does not include passwords, it does provide a detailed view of how you secure your devices.</p>
<p>Using a GitHub repository with the workflows covered below, the backup and as-built will be generated, and we can we can compare commits to track changes to the tenant:</p>
<video controls="">
<source src="/media/2022/02/CompareReleases.mp4" type="video/mp4" />
</video>
<p class="figcaption">Compare configurations across tags and releases in a GitHub repository.</p>
<p>For GitHub, I’ve created two workflows - the first workflow performs the following steps:</p>
<ol>
<li>Backup the Intune configuration on a schedule</li>
<li>Generate an as-built document in markdown and covert the document to PDF format</li>
<li>Tag the updated configuration, enabling us to create a release</li>
</ol>
<p><a href="/media/2022/02/intune-release.png"><img src="/media/2022/02/intune-release.png" alt="Intune as-built release" /></a></p>
<p class="figcaption">Intune configuration changes and the as-built provided as a release on the GitHub repository.</p>
<h3 id="backup-document-tag-workflow">Backup, Document, Tag Workflow</h3>
<p>The first workflow includes three stages - Backup (the Intune configurations are exported to YAML or JSON), Document (the as-built PDF document is generated), and <a href="https://git-scm.com/book/en/v2/Git-Basics-Tagging">Tag</a> (the repository is tagged for changes committed in that workflow).</p>
<p><a href="/media/2022/02/github-workflowrun.jpeg"><img src="/media/2022/02/github-workflowrun.jpeg" alt="GitHub workflow run" /></a></p>
<p class="figcaption">GitHub workflow run for Intune backup, document and tagging the repository.</p>
<h3 id="release-workflow">Release Workflow</h3>
<p>Once the first workflow tags changes to the repository, the second workflow will create a release package for the as-built document. This provides a point in time version of the as-built document.</p>
<p><a href="/media/2022/02/github-workflowrun2.png"><img src="/media/2022/02/github-workflowrun2.png" alt="GitHub workflow run" /></a></p>
<p class="figcaption">GitHub workflow run for creating an as-built document release.</p>
<h3 id="configure-the-repository">Configure the Repository</h3>
<p>The workflows rely on several repository secrets that are used as variables:</p>
<p><a href="/media/2022/02/repo-secrets.png"><img src="/media/2022/02/repo-secrets.png" alt="GitHub repository secrets" /></a></p>
<p class="figcaption">Secrets in the repository used by GitHub Actions.</p>
<p>Create the following <a href="https://docs.github.com/en/actions/security-guides/encrypted-secrets">secrets</a> on the repository:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">TENANT_NAME</code> - your Azure AD tenant name, .e.g., <code class="language-plaintext highlighter-rouge">stealthpuppylab.onmicrosoft.com</code></li>
<li><code class="language-plaintext highlighter-rouge">CLIENT_ID</code> - the Application (client) ID of the app registration, e.g., <code class="language-plaintext highlighter-rouge">3ea822c2-3644-4e99-984e-279f54f71da4</code></li>
<li><code class="language-plaintext highlighter-rouge">CLIENT_SECRET</code> - the client secret to enable authentication via the app registration</li>
<li><code class="language-plaintext highlighter-rouge">PAT</code> - a <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token">GitHub personal access token</a>. The personal access token is used in the Tag phase of the first workflow to authenticate to GitHub when pushing the tag. The <a href="https://docs.github.com/en/actions/security-guides/automatic-token-authentication">GITHUB_TOKEN</a> cannot be used here, because <a href="https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow">the second workflow will not be triggered</a>. <a href="https://docs.github.com/en/developers/overview/managing-deploy-keys#deploy-keys">Deploy keys</a> can be used in place of a personal access token, particularly for an organisational account</li>
<li><code class="language-plaintext highlighter-rouge">COMMIT_NAME</code> - <a href="https://docs.github.com/en/get-started/getting-started-with-git/setting-your-username-in-git">name of the user</a> for Git commits</li>
<li><code class="language-plaintext highlighter-rouge">COMMIT_EMAIL</code> - <a href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-user-account/managing-email-preferences/setting-your-commit-email-address">email address</a> of the user for Git commits</li>
<li><code class="language-plaintext highlighter-rouge">GPGKEY</code> - <a href="https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key">GPG signing key</a>, for signing commits</li>
<li><code class="language-plaintext highlighter-rouge">GPGPASSPHRASE</code> - passphrase for unlocking the GPG signing key</li>
</ul>
<p>Note that the workflow is configured to sign commits with a GPG key using the <a href="https://github.com/crazy-max/ghaction-import-gpg">ghaction-import-gpg</a> action. If you aren’t interested in signing your commits, you’ll need to update this portion of the workflow. Use the <a href="https://github.com/marketplace/actions/git-commit-and-push">Git Commit and Push</a> action instead.</p>
<h3 id="workflow-code">Workflow Code</h3>
<p>Here’s the code listing for each workflow:</p>
<h4 id="backup-document-tag">Backup, Document, Tag</h4>
<p><a href="https://gist.github.com/aaronparker/f69f82223271f63eb6c0d1d3850aa7ed">This workflow</a> performs the following tasks:</p>
<ul>
<li><strong>Backup stage</strong>
<ul>
<li>Check out the repository</li>
<li>Import the GPG key and configure git commit credentials</li>
<li>Install IntuneCD</li>
<li>Backup the Intune configuration</li>
<li>Commit changes to the repository</li>
</ul>
</li>
<li><strong>Document stage</strong>
<ul>
<li>Check out the repository</li>
<li>Import the GPG key and configure git commit credentials</li>
<li>Install IntuneCD</li>
<li>Create the as-built in markdown format</li>
<li>Convert the markdown to PDF format</li>
<li>Commit changes to the repository</li>
</ul>
</li>
<li><strong>Tag stage</strong>
<ul>
<li>Check out the repository</li>
<li>Import the GPG key and configure git commit credentials</li>
<li>Tag the repository</li>
</ul>
</li>
</ul>
<script src="https://gist.github.com/aaronparker/f69f82223271f63eb6c0d1d3850aa7ed.js"></script>
<h4 id="release">Release</h4>
<p><a href="https://gist.github.com/aaronparker/b5383bfb5a1fec9af372596c4051674b">This workflow</a> performs the following tasks:</p>
<ul>
<li><strong>Release stage</strong>
<ul>
<li>Check out the repository</li>
<li>Import the GPG key and configure git commit credentials</li>
<li>Create a release using the as-built document</li>
</ul>
</li>
</ul>
<script src="https://gist.github.com/aaronparker/b5383bfb5a1fec9af372596c4051674b.js"></script>
<h2 id="concluding">Concluding</h2>
<p>In this article, I’ve provided the foundations for using IntuneCD to automate the backup of your Intune tenant and create an as-built document, using GitHub for hosting the repository and GitHub Actions/Workflows to automate the process.</p>
<p>For an IT operational team, service desk or managed service, automatically performing these tasks would be far better time invested than manually creating an as-built document.</p>
<p>In the next article, I will cover the same process using Azure DevOps to host the repository and pipeline.</p>
<h3 id="github-repository-template">GitHub Repository Template</h3>
<p>Rather than having to build all of this from scratch, I have created a template repository on GitHub that you can clone or fork to start building in your own environment. Hop over to GitHub to get started: <a href="https://github.com/aaronparker/intune-backup-template">intune-backup-template</a>.</p>Aaron Parkeraaron@stealthpuppy.comAdvanced Dynamic Device Collections for Intune without ConfigMgr2022-02-18T23:21:00+00:002022-02-18T23:21:00+00:00http://stealthpuppy.com/dynamic-device-collections-without-configmgr<ul id="markdown-toc">
<li><a href="#introduction" id="markdown-toc-introduction">Introduction</a> <ul>
<li><a href="#use-cases" id="markdown-toc-use-cases">Use Cases</a></li>
</ul>
</li>
<li><a href="#dynamic-device-collections-without-configmgr" id="markdown-toc-dynamic-device-collections-without-configmgr">Dynamic Device Collections without ConfigMgr</a> <ul>
<li><a href="#components" id="markdown-toc-components">Components</a></li>
<li><a href="#advanced-hardware-and-software-inventory-with-intune" id="markdown-toc-advanced-hardware-and-software-inventory-with-intune">Advanced Hardware and Software Inventory with Intune</a></li>
<li><a href="#kql-queries" id="markdown-toc-kql-queries">KQL Queries</a></li>
<li><a href="#azure-ad-group" id="markdown-toc-azure-ad-group">Azure AD Group</a></li>
<li><a href="#azure-logic-app" id="markdown-toc-azure-logic-app">Azure Logic App</a> <ul>
<li><a href="#schedule" id="markdown-toc-schedule">Schedule</a></li>
<li><a href="#remove-existing-group-members" id="markdown-toc-remove-existing-group-members">Remove Existing Group Members</a></li>
<li><a href="#query-log-analytics" id="markdown-toc-query-log-analytics">Query Log Analytics</a></li>
<li><a href="#query-the-microsoft-graph" id="markdown-toc-query-the-microsoft-graph">Query the Microsoft Graph</a> <ul>
<li><a href="#authentication" id="markdown-toc-authentication">Authentication</a></li>
</ul>
</li>
<li><a href="#parse-the-output" id="markdown-toc-parse-the-output">Parse the Output</a></li>
<li><a href="#add-devices-to-the-group" id="markdown-toc-add-devices-to-the-group">Add Devices to the Group</a></li>
</ul>
</li>
<li><a href="#concluding" id="markdown-toc-concluding">Concluding</a> <ul>
<li><a href="#code" id="markdown-toc-code">Code</a></li>
</ul>
</li>
</ul>
</li>
</ul>
<h2 id="introduction">Introduction</h2>
<p><a href="https://docs.microsoft.com/en-us/mem/configmgr/comanage/overview">Co-management</a> with Microsoft Endpoint Configuration Manager extends ConfigMgr capabilities into Microsoft Intune. Deploying co-management provides enterprise features for cloud-first device management. This includes device collections that dynamically populate based on device property queries via WMI. <a href="https://docs.microsoft.com/en-us/mem/configmgr/core/clients/manage/collections/create-collections#bkmk_aadcollsync">Synchronise those collections to Azure AD groups</a>, and you’re able to extend capabilities that are not natively available in Intune.</p>
<p class="lead">What are your options if your organisation has gone cloud-only with Microsoft Intune, but without ConfigMgr?</p>
<p>Azure AD supports <a href="https://docs.microsoft.com/en-us/azure/active-directory/enterprise-users/groups-dynamic-membership#rules-for-devices">dynamic device groups</a> that are populated based on device hardware capabilities. However, an Azure AD device object stores limited hardware information, so those queries are also limited. This in turn, limits the uses where Azure AD dynamic device groups can be used to target policies or applications in Microsoft Intune.</p>
<p>Microsoft has introduced <a href="https://docs.microsoft.com/en-us/mem/intune/fundamentals/filters">Filters into Intune</a> that are more flexible that Azure AD dynamic device groups, but these too have limited device properties to query, also limiting their use for advanced scenarios.</p>
<h3 id="use-cases">Use Cases</h3>
<p>Here’s a few use cases that can be solved with custom solution to dynamic device collections for Intune:</p>
<ul>
<li>Target Windows Hello policies only to a collection of devices that have biometric hardware capabilities</li>
<li>Create collections for Windows 10 or Windows 11 devices for targetting Feature update policies. Dynamic device groups and Intune filters make this challenging today</li>
<li>Create a collection of devices that don’t have a TPM enabled or only have a TPM v1.2</li>
<li>Create a collection of Lenovo PCs that have a specific BIOS version</li>
</ul>
<h2 id="dynamic-device-collections-without-configmgr">Dynamic Device Collections without ConfigMgr</h2>
<p>Until Microsoft expands on device hardware inventory capabilities in Intune and exposes additional hardware properties to Filters, we need to build our own. Here’s how to build a cloud-only solution for advanced dynamic device collections using <a href="https://docs.microsoft.com/en-us/mem/analytics/proactive-remediations">Proactive Remediations</a>, <a href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-analytics-tutorial">Azure Log Analytics</a>, and <a href="https://docs.microsoft.com/en-us/azure/logic-apps/logic-apps-overview">Azure Logic Apps</a> providing advanced targeting capabilities for policies and apps in Microsoft Intune, all without ConfigMgr.</p>
<p class="note" title="Attention">This solution is currently a Proof of Concept. There are specific considerations for performance and security that I will highlight in this article.</p>
<h3 id="components">Components</h3>
<p>The approach to building a dynamic device collection for Microsoft Intune builds on several components:</p>
<ul>
<li><strong>Microsoft Intune Endpoint Analytics Proactive Remediations</strong> - collects hardware and software inventory from manage devices</li>
<li><strong>Azure Log Analytics</strong> - stores hardware and software inventory data</li>
<li><strong>Azure Logic App</strong> - queries Log Analytics for specific hardware or software values, and populates the Azure AD group based on devices returned from the query</li>
<li><strong>Azure AD group</strong> - this is the device collection that can be used as a target in Intune</li>
</ul>
<h3 id="advanced-hardware-and-software-inventory-with-intune">Advanced Hardware and Software Inventory with Intune</h3>
<p>Microsoft Intune collects limited hardware and software inventory data, so input into the dynamic device collection will also be limited. The team at MSEndpointMgr has created a solution to collect that missing detail for both hardware and software inventory based on Proactive Remediations and Log Analytics - <a href="https://msendpointmgr.com/2021/04/12/enhance-intune-inventory-data-with-proactive-remediations-and-log-analytics/">Enhance Intune Inventory data with Proactive Remediations and Log Analytics</a>.</p>
<p>That solution provides a collection tool in extensible PowerShell scripts run by Proactive Remediations that push data into Azure Log Analytics and viewable in an Azure Workbook.</p>
<p><a href="/media/2022/02/HardwareReport.png"><img src="/media/2022/02/HardwareReport.png" alt="Hardware inventory report" /></a></p>
<p class="figcaption">An Azure Workbook displaying the hardware inventory report</p>
<p>With this inventory solution sending data to Log Analytics, we have a source data set to query for our dynamic device collections.</p>
<p class="note" title="Note">The smallest window that a Protective Remediation script can be run is one per hour. Thus we are only going to see hardware changes reflected in the inventory data within the previous 1-2 hours, depending on hardware changes and when the script executes on the device.</p>
<h3 id="kql-queries">KQL Queries</h3>
<p>Before building the Logic App that will populate the Azure AD group, let’s look at a KQL query to return devices based on hardware properties. For this example, the query below will return a list of devices, discovered over the previous 3 days, that have 16GB of RAM or less.</p>
<pre><code class="language-kusto">HardwareInventory_CL
| where TimeGenerated > ago(3d)
| where Memory_d <= 16
| summarize arg_max (TimeGenerated, *) by ComputerName_s
| project ComputerName_s
</code></pre>
<p>As long as the Proactive Remediation script is sending the required hardware or software properties to Log Analytics, we can query for those properties in the Logic App. Here’s an example query that returns devices that have <strong>Adobe Acrobat DC (64-bit)</strong> installed where the version less than the required version of <code class="language-plaintext highlighter-rouge">22.011.20039</code>:</p>
<pre><code class="language-kusto">SoftwareInventory_CL
| where TimeGenerated > ago(3d)
| where AppName_s == "Adobe Acrobat DC (64-bit)"
| where parse_version(AppVersion_s) < parse_version("22.011.20039")
| summarize arg_max (TimeGenerated, *) by ComputerName_s
| project ComputerName_s
</code></pre>
<p><a href="https://docs.microsoft.com/en-us/windows/deployment/update/update-compliance-get-started">Windows Update Compliance</a> tables can also be queried. The <a href="https://docs.microsoft.com/en-us/azure/azure-monitor/reference/tables/ucclient">UCClient</a> table provides plenty of useful data to filter on. For example, let’s find all devices that are running Windows 11 21H2 or higher:</p>
<pre><code class="language-kusto">UCClient
| where TimeGenerated > ago(3d)
| where parse_version(OSBuild) >= parse_version("10.0.22000.0")
| summarize arg_max (TimeGenerated, *) by DeviceName
| project DeviceName
</code></pre>
<h3 id="azure-ad-group">Azure AD Group</h3>
<p>In our cloud-native device management solution, the device collection is an <a href="https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-groups-create-azure-portal">Azure AD group</a>. The group can be used to target application and policies in Microsoft Intune.</p>
<p><a href="/media/2022/02/DeviceCollection-DynamicDevice.png"><img src="/media/2022/02/DeviceCollection-DynamicDevice.png" alt="Azure AD group" /></a></p>
<p class="figcaption">Configure the frequency for running the Logic App.</p>
<p>We need the <code class="language-plaintext highlighter-rouge">Object Id</code> property from the target group to reference in the Logic App.</p>
<h3 id="azure-logic-app">Azure Logic App</h3>
<p>Let’s build the Azure Logic App that will manage the membership of the Azure AD group. Here’s the basic flow of the Logic App:</p>
<ol>
<li><strong>Run the Logic App on a schedule</strong>. Remember that the Proactive Remediation script can run at most once per hour, thus the Logic Should not need to run any less than once per hour as well. The Logic App makes calls to the Microsoft Graph API, so we need to consider how scale could affect performance</li>
<li><strong>Retrieve existing group members from the target Azure AD group</strong></li>
<li><strong>Remove the existing members from the Azure AD group</strong>. Validation before moving group members would be prudent</li>
<li><strong>Query Log Analytics for matching devices</strong>, returning the list of device display names</li>
<li><strong>Query the Microsoft Graph for the <code class="language-plaintext highlighter-rouge">Object Id</code> property for each device</strong>. This currently makes a call to the API per device, thus performance may degrade as the number of devices increases. This could be resolved by updating the hardware inventory collection script to return the <code class="language-plaintext highlighter-rouge">Object Id</code> property and store that in Log Analytics</li>
<li><strong>Add the list of devices (via the <code class="language-plaintext highlighter-rouge">Object Id</code> property) to the target Azure AD group</strong></li>
</ol>
<p class="note" title="Note">To ensure the use of a Logic App is cost effective, use the <a href="https://docs.microsoft.com/en-us/azure/logic-apps/logic-apps-pricing#consumption-pricing">Consumption pricing model</a>. The simplest approach to implementing this solution will be to create a Logic App per device collection. The Consumption pricing model will keep Azure costs down.</p>
<p>Let’s take a look at these steps in more detail.</p>
<h4 id="schedule">Schedule</h4>
<p>The Logic App runs on a schedule - pick the frequency that you update the Azure AD group membership.</p>
<p><a href="/media/2022/02/LogicAppSchedule.png"><img src="/media/2022/02/LogicAppSchedule.png" alt="Logic App schedule" /></a></p>
<p class="figcaption">The dynamic device collection is managed via an Azure AD group membership.</p>
<h4 id="remove-existing-group-members">Remove Existing Group Members</h4>
<p>To ensure the group contains only valid members, let’s first remove the existing group members. This action is performed by returning the existing group members (i.e. device accounts), then removing each member from the group.</p>
<p><a href="/media/2022/02/RemoveGroupMembers.png"><img src="/media/2022/02/RemoveGroupMembers.png" alt="Remove Existing Group Members" /></a></p>
<p class="figcaption">Return the existing group members, the for each member, remove the object from the group.</p>
<h4 id="query-log-analytics">Query Log Analytics</h4>
<p>Querying the data from a Log Analytics workspace will return the required device names. Using the sample KQL query above will return a single array of device display names, that will be passed to the next step.</p>
<p><a href="/media/2022/02/QueryLogAnalytics.png"><img src="/media/2022/02/QueryLogAnalytics.png" alt="Query Log Analytics" /></a></p>
<p class="figcaption">The step to query Azure Log Analytics and return a list of devices to add to the Azure AD group.</p>
<h4 id="query-the-microsoft-graph">Query the Microsoft Graph</h4>
<p>To obtain the <code class="language-plaintext highlighter-rouge">Object Id</code> property for each device, a query must be sent to the Microsoft Graph API. This is wrapped in a For loop that will step through each device, passing its display name to return JSON output that includes the Id in the form of a GUID.</p>
<p>We use the devices API to return details of each device - <a href="https://docs.microsoft.com/en-us/graph/api/device-list?view=graph-rest-1.0&tabs=http">https://graph.microsoft.com/v1.0/devices</a>. This will include a <a href="https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter">filter</a> and the <a href="https://docs.microsoft.com/en-us/graph/query-parameters#select-parameter">select</a> parameter to narrow the query, and return the Id.</p>
<pre><code class="language-url">https://graph.microsoft.com/v1.0/devices?$filter=operatingSystem eq 'Windows' and displayName eq '@{items('For_each_device')?['ComputerName_s']}'&$select=id
</code></pre>
<p class="note" title="Important">Notice that the Logic App is sending a query to the Graph API per device, which is not very efficient. In its current form, there may be issues with scaling this solution to 100’s or 1000’s of devices.</p>
<p><a href="/media/2022/02/GrahpAPICall.png"><img src="/media/2022/02/GrahpAPICall.png" alt="Microsoft Graph API call" /></a></p>
<p class="figcaption">Using the HTTP action to send a query to the Microsoft Graph API to return the device Object Id.</p>
<h5 id="authentication">Authentication</h5>
<p>The HTTP request to the Graph API requires authentication. Here we use an Azure AD app registration, which is granted rights to query the devices API, enabling the HTTP call to authenticate to the API and return data.</p>
<p>The Graph API Explorer is useful to <a href="https://developer.microsoft.com/en-us/graph/graph-explorer?request=devices&method=GET&version=v1.0&GraphUrl=https://graph.microsoft.com">validate the access to the API</a> and ensure that we return expected output. In the image below, I’m using the same query that the Logic App will send to the API, and returning the <code class="language-plaintext highlighter-rouge">Object Id</code> for a specific device, which outputs in JSON.</p>
<p><a href="/media/2022/02/DevicesAPI.jpeg"><img src="/media/2022/02/DevicesAPI.jpeg" alt="Using the Graph API Explorer to validate the output from the Devices API" /></a></p>
<p class="figcaption">Using the Graph API Explorer to validate the output from the Devices API.</p>
<p>The app registration should need only the <a href="https://docs.microsoft.com/en-us/graph/permissions-reference#device-permissions">Devices.Read.All</a> permission to query device properties; however, in practice, the Logic App would still fail with an authentication error with only this permission.</p>
<p class="note" title="Important">Validate permissions in your tenant before enabling this solution in a production environment. <strong>Devices.Read.All</strong> should be the only permissions that the app registration requires to return device data.</p>
<h4 id="parse-the-output">Parse the Output</h4>
<p>The Graph API will return JSON output similar to the following which must be parsed so that the next step, which will add devices to the group, will receive the <code class="language-plaintext highlighter-rouge">id</code> property:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
</span><span class="nl">"@odata.context"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://graph.microsoft.com/v1.0/$metadata#devices(id)"</span><span class="p">,</span><span class="w">
</span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="p">{</span><span class="w">
</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"450f56e3-b3eb-4a5d-a753-3d18e96d4658"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>The Parse JSON step will take the output from the Graph API and extract the <code class="language-plaintext highlighter-rouge">id</code> property to pass to the next step. Use the sample output from the Graph API Explorer as sample payload to generate the schema required by this step.</p>
<p><a href="/media/2022/02/ParseJson.png"><img src="/media/2022/02/ParseJson.png" alt="Passing the JSON output from the Graph API" /></a></p>
<p class="figcaption">Passing the JSON output from the Graph API.</p>
<h4 id="add-devices-to-the-group">Add Devices to the Group</h4>
<p>As each query to the Graph API returns an <code class="language-plaintext highlighter-rouge">id</code> object, the output is parsed, and then passed to the Azure AD ‘Add user to group` action, which adds the device to the group via its id.</p>
<p><a href="/media/2022/02/AddGroupMembers.png"><img src="/media/2022/02/AddGroupMembers.png" alt="Passing the JSON output from the Graph API" /></a></p>
<p class="figcaption">Adding the device object to the Azure AD group.</p>
<h3 id="concluding">Concluding</h3>
<p>In this article, I’ve described an approach to a cloud-native solution to managing advanced device collections, using queries to hardware or software inventory that is not natively available in Microsoft Intune. The solution relies on managing both the hardware or software inventory solution via Proactive Remediations and Log Analytics, and the device collection solution via an Azure Logic App, but it doesn’t require the overhead of Configuration Manager.</p>
<p>For environments that prefer a cloud-native approach and are looking to reduce services that require Windows virtual machines, this helps to provide features that a Configuration Manager administrator would take for granted.</p>
<p><a href="/media/2022/02/LogicAppView.gif"><img src="/media/2022/02/LogicAppView.gif" alt="A view of the Logic App" /></a></p>
<p class="figcaption">The Logic App in Designer view.</p>
<p>This is the first version of this solution and there are some improvements to be had, including:</p>
<ul>
<li>Validating that a query to the Log Analytics workspace is successful before removing existing devices from the Azure AD group</li>
<li>Batch queries to the Microsoft Graph API rather than a single call per device. <a href="https://docs.microsoft.com/en-us/graph/throttling">Throttling</a> is unlikely; however, performance of the Logic App could be affected with a large number of devices</li>
<li>Alternatively, determine another way to obtain the device object Id and store that in the Log Analytics Workspace</li>
</ul>
<h4 id="code">Code</h4>
<p>So that you don’t have to manually build the Azure Logic App, here’s a code listing of the Logic App that you can import into your own tenant. This will require updating tenant specific information including subscription, App Registration details, and the Azure AD group Id.</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
</span><span class="nl">"definition"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"$schema"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#"</span><span class="p">,</span><span class="w">
</span><span class="nl">"actions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"For_each_device"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"actions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"For_each_device_Id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"actions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Add_device_to_device_collection_group"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"@@odata.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@items('For_each_device_Id')?['id']"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"connection"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@parameters('$connections')['azuread']['connectionId']"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"post"</span><span class="p">,</span><span class="w">
</span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/v1.0/groups/@{encodeURIComponent('f0a5670d-0913-4412-a434-b5fc3c23e00c')}/members/$ref"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ApiConnection"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"foreach"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@body('Parse_JSON')?['value']"</span><span class="p">,</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Parse_JSON"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"Succeeded"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Foreach"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"Parse_JSON"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@body('Query_the_Microsoft_Graph_for_the_device_Id')"</span><span class="p">,</span><span class="w">
</span><span class="nl">"schema"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"@@odata.context"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"items"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"required"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"id"</span><span class="w">
</span><span class="p">],</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"object"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"array"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"object"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Query_the_Microsoft_Graph_for_the_device_Id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"Succeeded"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ParseJson"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"Query_the_Microsoft_Graph_for_the_device_Id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"authentication"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"audience"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://graph.microsoft.com"</span><span class="p">,</span><span class="w">
</span><span class="nl">"clientId"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
</span><span class="nl">"secret"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
</span><span class="nl">"tenant"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ActiveDirectoryOAuth"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"headers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"ContentType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GET"</span><span class="p">,</span><span class="w">
</span><span class="nl">"uri"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://graph.microsoft.com/v1.0/devices?$filter=operatingSystem eq 'Windows' and displayName eq '@{items('For_each_device')?['ComputerName_s']}'&$select=id"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Http"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"foreach"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@body('Query_Log_Analytics_for_matching_devices')?['value']"</span><span class="p">,</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Query_Log_Analytics_for_matching_devices"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"Succeeded"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Foreach"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"For_each_member"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"actions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Remove_group_member"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"connection"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@parameters('$connections')['azuread']['connectionId']"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"delete"</span><span class="p">,</span><span class="w">
</span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/v1.0/groups/@{encodeURIComponent('f0a5670d-0913-4412-a434-b5fc3c23e00c')}/members/@{encodeURIComponent(items('For_each_member')?['id'])}/$ref"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ApiConnection"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"foreach"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@body('Get_device_collection_group_members')?['value']"</span><span class="p">,</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Get_device_collection_group_members"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"Succeeded"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Foreach"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"Get_device_collection_group_members"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"connection"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@parameters('$connections')['azuread']['connectionId']"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"get"</span><span class="p">,</span><span class="w">
</span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/v1.0/groups/@{encodeURIComponent('f0a5670d-0913-4412-a434-b5fc3c23e00c')}/members"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ApiConnection"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"Query_Log_Analytics_for_matching_devices"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"inputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"HardwareInventory_CL</span><span class="se">\n</span><span class="s2">| where TimeGenerated > ago(3d)</span><span class="se">\n</span><span class="s2">| where Memory_d <= 16</span><span class="se">\n</span><span class="s2">| summarize arg_max (TimeGenerated, *) by ComputerName_s</span><span class="se">\n</span><span class="s2">| project ComputerName_s"</span><span class="p">,</span><span class="w">
</span><span class="nl">"timerangetype"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"connection"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@parameters('$connections')['azuremonitorlogs']['connectionId']"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"post"</span><span class="p">,</span><span class="w">
</span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/queryDataV2"</span><span class="p">,</span><span class="w">
</span><span class="nl">"queries"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"resourcegroups"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rg-DeviceManagement-AustraliaSoutheast"</span><span class="p">,</span><span class="w">
</span><span class="nl">"resourcename"</span><span class="p">:</span><span class="w"> </span><span class="s2">"log-DeviceReports-AustraliaEast"</span><span class="p">,</span><span class="w">
</span><span class="nl">"resourcetype"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Log Analytics Workspace"</span><span class="p">,</span><span class="w">
</span><span class="nl">"subscriptions"</span><span class="p">:</span><span class="w"> </span><span class="s2">"63e8f661-f6a5-4ac6-ad4e-623268509f21"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"runAfter"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"For_each_member"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"Succeeded"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ApiConnection"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"contentVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0.0"</span><span class="p">,</span><span class="w">
</span><span class="nl">"outputs"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
</span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"$connections"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"defaultValue"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Object"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"triggers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Recurrence"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"evaluatedRecurrence"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"frequency"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Day"</span><span class="p">,</span><span class="w">
</span><span class="nl">"interval"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"recurrence"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"frequency"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Day"</span><span class="p">,</span><span class="w">
</span><span class="nl">"interval"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Recurrence"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"$connections"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"azuread"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"connectionId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/subscriptions/63e8f661-f6a5-4ac6-ad4e-623268509f21/resourceGroups/rg-DeviceManagement-AustraliaSoutheast/providers/Microsoft.Web/connections/azuread"</span><span class="p">,</span><span class="w">
</span><span class="nl">"connectionName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"azuread"</span><span class="p">,</span><span class="w">
</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/subscriptions/63e8f661-f6a5-4ac6-ad4e-623268509f21/providers/Microsoft.Web/locations/australiasoutheast/managedApis/azuread"</span><span class="w">
</span><span class="p">},</span><span class="w">
</span><span class="nl">"azuremonitorlogs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"connectionId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/subscriptions/63e8f661-f6a5-4ac6-ad4e-623268509f21/resourceGroups/rg-DeviceManagement-AustraliaSoutheast/providers/Microsoft.Web/connections/azuremonitorlogs"</span><span class="p">,</span><span class="w">
</span><span class="nl">"connectionName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"azuremonitorlogs"</span><span class="p">,</span><span class="w">
</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/subscriptions/63e8f661-f6a5-4ac6-ad4e-623268509f21/providers/Microsoft.Web/locations/australiasoutheast/managedApis/azuremonitorlogs"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>Aaron Parkeraaron@stealthpuppy.com4 Things I Learned Deploying Azure Virtual Desktop2022-01-08T01:44:00+00:002022-01-08T01:44:00+00:00http://stealthpuppy.com/azure-virtual-desktop-lessons-learned<ul id="markdown-toc">
<li><a href="#azure-virtual-desktop-is-an-azure-service-first" id="markdown-toc-azure-virtual-desktop-is-an-azure-service-first">Azure Virtual Desktop is an Azure service first</a></li>
<li><a href="#build-avd-on-a-solid-foundation" id="markdown-toc-build-avd-on-a-solid-foundation">Build AVD on a Solid Foundation</a></li>
<li><a href="#ensure-avd-service-urls-arent-blocked" id="markdown-toc-ensure-avd-service-urls-arent-blocked">Ensure AVD Service URLs Aren’t Blocked</a></li>
<li><a href="#rdp-can-perform-better-than-hdx" id="markdown-toc-rdp-can-perform-better-than-hdx">RDP can perform better than HDX</a></li>
<li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping Up</a></li>
</ul>
<p class="note">This article has been sitting in my drafts folder for almost 2 years. This article was originally written in January 2020, but still applies to Azure Virtual Desktop today. Other than updating for the new naming, I’m posting it as-is.</p>
<p>This year, I’ve spent a number of months head down in Azure Virtual Desktop projects and have come across several common lessons learned from these projects. In this article, I’ll cover 4 things I learned deploying Azure Virtual Desktop.</p>
<h2 id="azure-virtual-desktop-is-an-azure-service-first">Azure Virtual Desktop is an Azure service first</h2>
<p>AVD is a VDI solution built on top of a public cloud service, rather than a product that has added support to public clouds for virtual desktops. If you were to compare AVD to traditional VDI solutions, you’ll quickly see that AVD is very different.</p>
<p>Microsoft’s approach to building AVD has been to build a virtual desktop solution that adds a desktop broker service on top of existing Azure services. This means that you can apply the same cloud adoption framework and automation principles to AVD as with the rest of Azure.</p>
<h2 id="build-avd-on-a-solid-foundation">Build AVD on a Solid Foundation</h2>
<p>Being successful requires planning your AVD deployment by building upon an <a href="https://azure.microsoft.com/en-us/cloud-adoption-framework/">Azure adoption framework</a>. There are many Azure components that should be planned and deployed correctly, including identity, regions, networks, management, and security frameworks that are key to success when deploying any workload into Azure.</p>
<p>An Azure Virtual Desktop deployment will rely primarily on three things — choosing the right region for your desktops, configuring your virtual networks for access to applications and data, and storage to ensure good performance. All of these things you will have had to consider when deploying services into Azure before starting on AVD.</p>
<h2 id="ensure-avd-service-urls-arent-blocked">Ensure AVD Service URLs Aren’t Blocked</h2>
<p>AVD has <a href="https://docs.microsoft.com/en-us/azure/virtual-desktop/overview#requirements">a set of URLs</a> that are required for correct operation. Enterprise customers will often force all internet connections through a proxy server via Group Policy or <a href="https://en.wikipedia.org/wiki/Web_Proxy_Auto-Discovery_Protocol">proxy auto-discovery</a>.</p>
<p>If you’re deploying AVD into an isolated virtual network, especially for a POC, it’s unlikely you’ll have issues with enabling access to a virtual desktop. However, pay careful attention to your networking configuration when integrating proxy servers and firewalls.</p>
<p>If traffic to these URLs is blocked or routed incorrectly, AVD virtual machines may fail to talk to the AVD control plane or perform poorly. Ideally those destinations should be excluded from your proxy server and routed directly out via Azure.</p>
<h2 id="rdp-can-perform-better-than-hdx">RDP can perform better than HDX</h2>
<p>OK, some caution here as I need to perform some additional testing, so put this one down as anecdotal - RDP on AVD can perform better than Citrix HDX.</p>
<p>I’ve performed some basic testing of accessing a virtual desktop via AVD and another desktop in the same Azure region (and network) via Citrix Cloud. The virtual desktop provided by AVD over RDP outperformed a desktop over HDX.</p>
<p>The Azure virtual network and the virtual desktops, along with Citrix Cloud Gateway were all in the same region; however, there is a difference in how the client accesses a desktop. With Citrix Cloud the connection was over the public internet to Citrix Gateway. I suspect that the connection to AVD was via a local ingress point and then routed over the Microsoft back plane.</p>
<p>As I’ve said, more testing is required here and I’m hoping that I can share results of testing in a future article.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>This article has only touched on a few lessons learned in deploying Azure Virtual Desktop. There’s plenty more to share, so keep an eye out for my next article on AVD.</p>Aaron Parkeraaron@stealthpuppy.com