Last active
August 11, 2017 10:58
-
-
Save jhochwald/94ee0ce9a2b7ecbda744a8f09fd4de50 to your computer and use it in GitHub Desktop.
Install Office 365 PowerShell Requirements
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"Name": "Windows Azure Active Directory module", | |
"URL": "https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi" | |
}, | |
{ | |
"Name": "Sign in Assistant (SIA)", | |
"URL": "http://download.microsoft.com/download/5/0/1/5017D39B-8E29-48C8-91A8-8D0E4968E6D4/en/msoidcli_64.msi" | |
}, | |
{ | |
"Name": "Skype for Business Module", | |
"URL": "https://download.microsoft.com/download/2/0/5/2050B39B-4DA5-48E0-B768-583533B42C3B/SkypeOnlinePowershell.exe" | |
}, | |
{ | |
"Name": "SharePoint Module", | |
"URL": "https://download.microsoft.com/download/0/2/E/02E7E5BA-2190-44A8-B407-BC73CA0D6B87/sharepointonlinemanagementshell_6112-1200_x64_en-us.msi" | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#requires -Version 4.0 -RunAsAdministrator | |
<# | |
.SYNOPSIS | |
Install Office 365 PowerShell Requirements | |
.DESCRIPTION | |
Install all Office 365 PowerShell Requirements | |
.PARAMETER DownloadDirectory | |
The target Directory for all Downloads | |
.PARAMETER ConfigFile | |
The JSON file that contain the URLs to process | |
.EXAMPLE | |
PS C:\> Get-Office365PSModules.ps1 | |
.EXAMPLE | |
PS C:\> Get-Office365PSModules.ps1 -ConfigFile 'd:\config\PSModules.json' | |
.EXAMPLE | |
PS C:\> Get-Office365PSModules.ps1 -DownloadDirectory 'd:\Downloads' | |
.EXAMPLE | |
PS C:\> Get-Office365PSModules.ps1 -DownloadDirectory 'd:\Downloads' -ConfigFile 'd:\config\PSModules.json' | |
.NOTES | |
This file was created during a customer workshop. It might need to get a bit more care, but it works. | |
We added the a minor feature to get the Hash of the downloaded file. The base function (Get-FileHash) | |
needs PowerShell 4.0, the Required statement is updated for that. If you want to use it with PowerShell 3, | |
just remove that function (Get-TargetFileHash) and it will work fine on PowerShell 3.0 again. | |
We decided to implement this for one of the future features that they would like to have: Logging | |
TODO: Transfer all the function to a basic script is the next step. Not that we must, just as part of the education ;-) | |
ISSUE: Documentation needs to be created (MAML) | |
#> | |
<# | |
Copyright (c) 2017, Joerg Hochwald | |
All rights reserved. | |
Redistribution and use in source and binary forms, with or without modification, | |
are permitted provided that the following conditions are met: | |
1. Redistributions of source code must retain the above copyright notice, this | |
list of conditions and the following disclaimer. | |
2. Redistributions in binary form must reproduce the above copyright notice, | |
this list of conditions and the following disclaimer in the documentation and/or | |
other materials provided with the distribution. | |
3. Neither the name of the copyright holder nor the names of its contributors may | |
be used to endorse or promote products derived from this software without specific | |
prior written permission. | |
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS | |
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY | |
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. | |
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, | |
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT | |
OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | |
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING | |
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF | |
THE POSSIBILITY OF SUCH DAMAGE. | |
By using the Software, you agree to the License, Terms and Conditions above! | |
#> | |
param | |
( | |
[Parameter(Position = 1)] | |
[ValidateNotNullOrEmpty()] | |
[string]$DownloadDirectory = 'C:\scripts\PowerShell\Download', | |
[Parameter(Position = 1)] | |
[string]$ConfigFile = 'Get-Office365PSModules.json' | |
) | |
#region Helpers | |
function Test-DotNETVersion | |
{ | |
<# | |
.SYNOPSIS | |
Check if a given Version is installed | |
.DESCRIPTION | |
Check if a given Version is installed on the local system. | |
.PARAMETER DotNETVersion | |
Display String for the Version. e.g. 4.5.2 | |
.PARAMETER DotNETRelease | |
The DotNET Release Number. This is the Number that we try to find in the Registry. | |
.EXAMPLE | |
PS C:\> Test-DotNETVersion -DotNETVersion '4.5.2' -DotNETRelease '379893' | |
.NOTES | |
TODO: We need to find a better way to do that. | |
You can Download it here: http://download.microsoft.com/download/E/2/1/E21644B5-2DF2-47C2-91BD-63C560427900/NDP452-KB2901907-x86-x64-AllOS-ENU.exe | |
You might want to Download the latest (e.g. 4.6.x) instead, 4.5.2 is now the minimum! | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'Display String for the Version. e.g. 4.5.2')] | |
[ValidateNotNullOrEmpty()] | |
[string]$DotNETVersion, | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 2, | |
HelpMessage = 'The DotNET Release Number. This is the Number that we try to find in the Registry.')] | |
[ValidateNotNullOrEmpty()] | |
[String]$DotNETRelease | |
) | |
PROCESS | |
{ | |
$DotNETInfo = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name 'Release') | |
if ($DotNETInfo.Release -lt $DotNETRelease) | |
{ | |
Write-Error -Message "Microsoft DotNET $DotNETVersion, or newer, needs to be installed!" -ErrorAction Stop | |
exit 1 | |
} | |
else | |
{ | |
Write-Verbose -Message "Microsoft DotNET $DotNETVersion, or newer, is installed." | |
} | |
} | |
} | |
function Test-TargetDirectory | |
{ | |
<# | |
.SYNOPSIS | |
Check if the target directory exists | |
.DESCRIPTION | |
We check if the target directory for the download exists. If nor, we just create it for more convenience. | |
.PARAMETER CheckTarget | |
The target Directory to check/create | |
.EXAMPLE | |
PS C:\> Test-TargetDirectory -CheckTarget 'C:\scripts\PowerShell\Download' | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'The target Directory to check/create')] | |
[ValidateNotNullOrEmpty()] | |
[string]$CheckTarget | |
) | |
PROCESS | |
{ | |
if (Test-Path -Path $CheckTarget) | |
{ | |
Write-Verbose -Message "$CheckTarget already exists..." | |
} | |
else | |
{ | |
try | |
{ | |
$null = (New-Item -Path $CheckTarget -ItemType Directory -Force -Confirm:$false -ErrorAction Stop -WarningAction SilentlyContinue) | |
} | |
catch | |
{ | |
Write-Error -Message "Sorrry, unable to create $CheckTarget" -ErrorAction Stop | |
exit 1 | |
} | |
} | |
} | |
} | |
function Get-TargetFile | |
{ | |
<# | |
.SYNOPSIS | |
Download a File from Microsoft | |
.DESCRIPTION | |
This function downloads a file from a given URL by using System.Net.WebClient. | |
.PARAMETER SourceFile | |
The URL for the File that you would like to download. The URL must contain the Filename! | |
.PARAMETER TargetDirectory | |
The target Directory for the Download. | |
.PARAMETER proxy | |
Enable basic proxy Support | |
.EXAMPLE | |
PS C:\> Get-TargetFile -SourceFile 'https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi' -TargetDirectory 'C:\scripts\PowerShell\Download' | |
.NOTES | |
If you do NOT like the System.Net.WebClient method, you might change this to Invoke-WebRequest or use Start-BitsTransfer (Needs the BITS PowerShell Module) | |
Proxy Support: This is really just a quick example and you might need to adopt and tweak it a bit to fit to you needs | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'The URL for the File that you would like to download.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$SourceFile, | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 2, | |
HelpMessage = 'The target Directory for the Download.')] | |
[string]$TargetDirectory, | |
[switch]$proxy | |
) | |
BEGIN | |
{ | |
# Extract the Name of the File from the URL | |
[string]$TargetFile = ($SourceFile.Substring($SourceFile.LastIndexOf('/') + 1)) | |
[string]$DLTarget = ($TargetDirectory + '\' + $TargetFile) | |
# Just a messure a bit | |
$start_time = (Get-Date) | |
} | |
PROCESS | |
{ | |
try | |
{ | |
if ($proxy) | |
{ | |
<# | |
This quick example use the system configuration (same as IE) for Proxy Support. | |
In the example we use you actual credentials to authenticate at the proxy. | |
This is really just a quick example and you might need to adopt and tweak it a bit to fit to you needs | |
#> | |
# use the setting from "Internet Option" e.g. the same as IE/Edge | |
$proxy = [Net.WebRequest]::GetSystemWebProxy() | |
# The Proxy credentials, see bellow | |
$proxy.Credentials = [Net.CredentialCache]::DefaultCredentials | |
# We need to create the object now, cause we modify it then | |
$request = New-Object -TypeName System.Net.WebCLient | |
# There are proxy credentials only, has nothing to to with download credentials | |
$request.UseDefaultCredentials = $true | |
$request.Proxy.Credentials = $request.Credentials | |
# Fire the request (via the proxy of cause) | |
$request.DownloadFile($SourceFile, $DLTarget) | |
} | |
else | |
{ | |
# No proxy needed, we do it native | |
$null = ((New-Object -TypeName System.Net.WebClient).DownloadFile($SourceFile, $DLTarget)) | |
} | |
} | |
catch | |
{ | |
Write-Error -Message "Unable to Download $TargetFile to $TargetDirectory" | |
} | |
} | |
END | |
{ | |
# Nothing fancy, just here to meassure stuff | |
Write-Verbose -Message "Time taken: $((Get-Date).Subtract($start_time).Seconds) second(s)" | |
} | |
} | |
function Test-TargetFile | |
{ | |
<# | |
.SYNOPSIS | |
Check if we have downloaded the file already | |
.DESCRIPTION | |
We check if we downloaded the file already in the past, you can force the download... just in case | |
.PARAMETER SourceFile | |
The URL for the File that you would like to check. The URL must contain the Filename! | |
.PARAMETER TargetDirectory | |
The target Directory for the Download. This is the place where we check if the source file exists. | |
.PARAMETER force | |
If you use this switch, we delete the file and enforce the download. | |
.EXAMPLE | |
PS C:\> Test-TargetFile -SourceFile 'https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi' -TargetDirectory 'C:\scripts\PowerShell\Download' | |
.EXAMPLE | |
PS C:\> Test-TargetFile -SourceFile 'https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi' -TargetDirectory 'C:\scripts\PowerShell\Download' -Force | |
.NOTES | |
The FORCE Switch deletes the existing file without any further interaction. | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'The URL for the File that you would like to check.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$SourceFile, | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 2, | |
HelpMessage = 'The target Directory for the Download.')] | |
[string]$TargetDirectory, | |
[switch]$force | |
) | |
BEGIN | |
{ | |
# Extract the Name of the File from the URL | |
[string]$TargetFile = ($SourceFile.Substring($SourceFile.LastIndexOf('/') + 1)) | |
# Build the target (Directory + Filename) | |
[string]$CheckTarget = ($TargetDirectory + '\' + $TargetFile) | |
if ($force) | |
{ | |
if (Test-Path -Path $CheckTarget) | |
{ | |
Write-Verbose -Message "Remove File: $TargetFile" | |
$null = (Remove-Item -Path $CheckTarget -Force -Confirm:$false -ErrorAction Stop -WarningAction SilentlyContinue) | |
} | |
} | |
} | |
PROCESS | |
{ | |
if (Test-Path -Path $CheckTarget) | |
{ | |
Write-Verbose -Message "File: $TargetFile exists." | |
} | |
else | |
{ | |
# Get the given File | |
try | |
{ | |
Get-TargetFile -SourceFile $SourceFile -TargetDirectory $TargetDirectory -ErrorAction Stop -WarningAction SilentlyContinue | |
} | |
catch | |
{ | |
Write-Error -Message "Unable to Download $TargetFile to $TargetDirectory." | |
} | |
} | |
} | |
} | |
function Get-TargetFileHash | |
{ | |
<# | |
.SYNOPSIS | |
Wrapper for Get-FileHash | |
.DESCRIPTION | |
Wrapper for Get-FileHash to get (and just that) get the hash info of the newly downloaded File. | |
.PARAMETER SourceFile | |
The URL for the File that you would like to check. The URL must contain the Filename! | |
.PARAMETER TargetDirectory | |
The target Directory for the Download. This is the place where we check if the source file exists. | |
.EXAMPLE | |
PS C:\> Get-TargetFileHash -SourceFile 'https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi' -TargetDirectory 'C:\scripts\PowerShell\Download' | |
.NOTES | |
Just a minor helper function | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'The URL for the File that you would like to check.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$SourceFile, | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 2, | |
HelpMessage = 'The target Directory for the Download.')] | |
[string]$TargetDirectory | |
) | |
BEGIN | |
{ | |
# Extract the Name of the File from the URL | |
[string]$TargetFile = ($SourceFile.Substring($SourceFile.LastIndexOf('/') + 1)) | |
# Build the target (Directory + Filename) | |
[string]$CheckTarget = ($TargetDirectory + '\' + $TargetFile) | |
} | |
PROCESS | |
{ | |
if (Test-Path -Path $CheckTarget) | |
{ | |
try | |
{ | |
$FileHash = ((Get-FileHash -Path $CheckTarget -ErrorAction Stop -WarningAction SilentlyContinue).Hash) | |
Write-Verbose -Message "The Hash of $TargetFile is $FileHash" | |
} catch | |
{ | |
Write-Warning -Message "Unable to get the Hash Info for $CheckTarget" | |
} | |
} | |
else | |
{ | |
Write-Warning -Message "Unable to find $CheckTarget" | |
} | |
} | |
} | |
function Read-ConfigFile | |
{ | |
<# | |
.SYNOPSIS | |
Read the JSON Config | |
.DESCRIPTION | |
Read the JSON based Configuration File that contains all the Files we would like to process. | |
.PARAMETER config | |
Fully-Qualified Name of the JSON Configuration File. | |
.EXAMPLE | |
PS C:\> Read-ConfigFile -config 'd:\config\PSModules.json' | |
.NOTES | |
You might want to use an CSV or XML File... I like JSON more! | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'Fully-Qualified Name of the JSON Configuration File.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$config | |
) | |
BEGIN | |
{ | |
if (-not (Test-Path -Path $config)) | |
{ | |
Write-Error -Message 'Unable to find the configuration File. Please check...' -ErrorAction Stop | |
exit 1 | |
} | |
} | |
PROCESS | |
{ | |
try | |
{ | |
# Read the JSON File | |
$JSONInfo = ((Get-Content -Path $config) -join "`n" | ConvertFrom-Json -ErrorAction Stop -WarningAction SilentlyContinue) | |
# Dump it | |
Write-Output -InputObject $JSONInfo -NoEnumerate | |
} | |
catch | |
{ | |
Write-Error -Message 'Unable to process the configuration File. Please check...' -ErrorAction Stop | |
exit 1 | |
} | |
} | |
} | |
function Install-TargetModule | |
{ | |
<# | |
.SYNOPSIS | |
Install the given Module | |
.DESCRIPTION | |
Install the given PowerShell Module | |
.PARAMETER SourceFile | |
A description of the SourceFile parameter. | |
.PARAMETER TargetDirectory | |
A description of the TargetDirectory parameter. | |
.EXAMPLE | |
PS C:\> Install-TargetModule -SourceFile 'https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi' -TargetDirectory 'C:\scripts\PowerShell\Download' | |
.NOTES | |
TODO: Check if installed? | |
TODO: Logging for the installation? | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'The URL for the File that you would like to process.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$SourceFile, | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 2, | |
HelpMessage = 'The target Directory for the Download.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$TargetDirectory | |
) | |
BEGIN | |
{ | |
# Extract the Name of the File from the URL | |
[string]$TargetFileName = ($SourceFile.Substring($SourceFile.LastIndexOf('/') + 1)) | |
# Build the target (Directory + Filename) | |
[string]$FQTarget = ($TargetDirectory + '\' + $TargetFileName) | |
# Helper to get the Extension | |
$Extension = [IO.Path]::GetExtension($TargetFileName) | |
} | |
PROCESS | |
{ | |
if ($Extension -eq '.exe') | |
{ | |
# Process the EXE based installer | |
try | |
{ | |
$null = (Start-Process -FilePath $FQTarget -ArgumentList '/quiet /norestart' -Wait -ErrorAction Stop -WarningAction SilentlyContinue) | |
} | |
catch | |
{ | |
Write-Error -Message "Unable to install $TargetFileName" -ErrorAction Stop | |
} | |
} | |
elseif ($Extension -eq '.msi') | |
{ | |
# Process the MSI Installer | |
try | |
{ | |
$null = (Start-Process -FilePath msiexec.exe -ArgumentList "/i $FQTarget /quiet /passive /norestart" -Wait -ErrorAction Stop -WarningAction SilentlyContinue) | |
} | |
catch | |
{ | |
Write-Error -Message "Unable to install $TargetFileName" -ErrorAction Stop | |
} | |
} | |
else | |
{ | |
# Whoops | |
Write-Error -Message "Unable to process $TargetFileName" -ErrorAction Stop | |
} | |
} | |
} | |
function Invoke-ProcessModule | |
{ | |
<# | |
.SYNOPSIS | |
Wrapper function to process each File | |
.DESCRIPTION | |
Just a wrapper to call all related functions | |
.PARAMETER SourceFile | |
The URL for the File that you would like to process. The URL must contain the Filename! | |
.PARAMETER TargetDirectory | |
The target Directory for the Download. This is the place where we check if the source file exists. | |
.EXAMPLE | |
PS C:\> Invoke-ProcessTarget -SourceFile 'https://bposast.vo.msecnd.net/MSOPMW/Current/amd64/AdministrationConfig-EN.msi' -TargetDirectory 'C:\scripts\PowerShell\Download' | |
.NOTES | |
Just a wrapper around all our functions | |
#> | |
param | |
( | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 1, | |
HelpMessage = 'The URL for the File that you would like to process.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$SourceFile, | |
[Parameter(Mandatory = $true, | |
ValueFromPipeline = $true, | |
Position = 2, | |
HelpMessage = 'The target Directory for the Download.')] | |
[ValidateNotNullOrEmpty()] | |
[string]$TargetDirectory | |
) | |
PROCESS | |
{ | |
# Test if we have the File and download it if not | |
Test-TargetFile -SourceFile $SourceFile -TargetDirectory $DownloadDirectory | |
# Get the Hash (For later logging feature) | |
Get-TargetFileHash -SourceFile $SourceFile -TargetDirectory $DownloadDirectory | |
# Install | |
Install-TargetModule -SourceFile $SourceFile -TargetDirectory $DownloadDirectory | |
} | |
} | |
#endregion Helpers | |
<# | |
Now we execute our functions | |
#> | |
# Check if we have DotNET 4.5.2 installed. Might require 4.6 soon! | |
Test-DotNETVersion -DotNETVersion '4.5.2' -DotNETRelease '379893' | |
# Check if the Target exists | |
Test-TargetDirectory -CheckTarget $DownloadDirectory | |
# Get the Module Infos and URL | |
$AllModule = (Read-ConfigFile -config $ConfigFile) | |
# Process each Module | |
foreach($Module in $AllModule) | |
{ | |
[string]$ModuleName = ($Module.Name) | |
[string]$ModuleURL = ($Module.URL) | |
Write-Output -InputObject "Processing: $ModuleName" | |
Invoke-ProcessModule -SourceFile $ModuleURL -TargetDirectory $DownloadDirectory | |
} |
We added the a minor feature to get the Hash of the downloaded file. The base function (Get-FileHash) needs PowerShell 4.0, the Required statement is updated for that. If you want to use it with PowerShell 3, just remove that function (Get-TargetFileHash) and it will work fine on PowerShell 3.0 again.
We decided to implement this for one of the future features that they would like to have: Logging
TODO: Transfer all the function to a basic script is the next step. Not that we must, just as part of the education ;-)
ISSUE: Documentation needs to be created (MAML)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Q: Why a external Config file?
A: Mainly because we can! It was one of the tasks in the Workshop!
Q: Is it working on $WindowsVersion ???
A: We tested it with Windows 10, Windows Server 2012 R2 and Windows Server 2016. It should work on Server 2008 R2 or newer. And Windows 7, or newer.
Q: Why so complex?
A: We build the script from ground up during a workshop. It is full of tasks and challenges.
Q: Can I use the script?
A: Yep! Why not?
Q: Can I adopt...
A: Yep! Do whatever you like, the license should allow nearly everything.
Q: Why this Q/A?
A: Also part of the Workshop ;-)