FeedBurner makes it easy to receive content updates in My Yahoo!, Newsgator, Bloglines, and other news readers.
Learn more about syndication and FeedBurner...
And I’ll tell you why. But first, the TLDR takeaway: If you have a storage array of anything over a terabyte or two DO NOT use RAID-5, use RAID-6 or something with superior fault tolerance. If you remember anything from this post remember that or you may find yourself in the position I’m in currently, lost data and all.
It really comes down to one little performance specification that until now I hadn’t ever really noticed: “Unrecoverable read errors”. For the latest Seagate home drive that statistic is 1 in 1014, for a standard SATA Enterprise class drive that number is an order of magnitude better, 1 in 1015. Remember those numbers and now read this excellent article as well as one of the referenced white papers from NetApp, and perhaps finish off with this StorageMojo post.
See the problem now? My backup server sure did when it ran afoul of a double drive failure. Perhaps it was just bad jujus, but my 6x1TB drive array (with hot spare) had a drive fail – and hit “Unrecoverable read error” while reconstructing the data to the hot spare. So now I’m working at evacuating the existing data and restoring from backups. To save yourself that trouble it’s worth remembering:
For large arrays, RAID-5 is the devil.
So we have spent the past few months sizing, installing, adjusting, and growing a proof-of-concept implementation of VMWare’s virtual desktop product, View 4.5. One question that was kind of fun to develop an easily digestible solution for was, “How can we keep track of and trend how the VDI solution is being used?” Naturally, I turned to my technical multitool of choice, PowerShell, for a solution.
As a simple logging and review tool we decided that we would like to know how many Remote Sessions were currently active, what the average CPU % utilization was, and the average % RAM utilization was. I added Disk and Network stats to the logs as well. For the Remote Session stats we have to use the ‘View PowerCLI’ PowerShell Snap-in, “VMware.View.Broker”. The last time I checked it was not available as a standalone installer nor was part of the standard VMWare PowerCLI distributable. Because of this we either have to run our powershell script locally on our view manager server, or use PowerShell sessions and remoting. In the interest of simplicity I chose to run the script locally on our View Manager server. For the Host(s) CPU, RAM, Disk, & Network utilization we’ll use the standard PowerCLI distributable. For historical purposes I log the information to a simple CSV file, and we use PowerGadgets for a “live” view of the statistics.
Okay, let’s check the script out. First things first, we make sure that the required snap-ins are available.
# Add the necessary snapins, throwing terminating errors # should they not be available on the machine if (-not(get-pssnapin | ? { $_.name -eq 'VMware.View.Broker' })) { add-pssnapin VMware.View.Broker -ErrorAction Stop } if (-not(get-pssnapin | ? { $_.name -eq 'VMware.VimAutomation.Core' })) { Add-PSSnapin VMware.VimAutomation.Core -ErrorAction Stop }
Easy enough. Next let’s get some script variables setup with some explanations along the way.
$today = Get-Date $logPath = "C:\ViewStatLogs" $logName = "{0}.log" -f $today.ToString("yyyy-MM-dd") $logexists = test-path $logPath\$logname
Here we’re just setting up our logfiles. For the script to function, we’ll need to make sure that the $logpath already exists. For the sake of space I setup the directory as compressed as well. Next we define the name of the logfile which will define how our logs are broken up. Currently mine are broken up by the day, though I’m thinking that it may be better to do them some other way…time will tell. $logexists should be pretty self-explanatory. Now for some variables that are a bit more interesting.
$dnsDomain = "mydomain.net" $vCenterServer = "vCenter" $vSphereHosts = @("ESXi03") $statsToGet = @( "cpu.usage.average", "mem.usage.average", "disk.usage.average", "net.usage.average" )
So here we have $dnsDomain, which I use because I’m lazy and hate typing FQDNs all the time. Next is the vCenter Server that is used to manage the host/hosts that compose our View cluster. We’ll connect to this server to get the CPU/RAM/Disk/Network statistics. We’re still in “growing proof-of-concept” phase so we only have a single vSphere host running in our View cluster which is specified in $vSphereHosts. Next is an array of the statistics that we want to gather for each vSphere Host, in this case the average usage for cpu, mem, disk, and net(work). For more information on the statistics that are available, consult the help for the Get-Stat & Get-StatType cmdlets for the VMware.VimAutomation.Core snap-in. Next let’s setup the header for our CSV logfile, and create the file if it doesn’t already exist.
# Define the log header $logHeader = "Time,SessionCount" # Alter the header for each host/stat combo foreach ($vsh in $vSphereHosts) { foreach ($stat in $statsToGet) { $logHeader += ",{0}.{1}" -f $vsh,$stat } } # Create the log file if it doesn't exist if (-not $logexists) { Out-File -FilePath $logPath\$logName ` -inputobject $logHeader -force -encoding ASCII }
Pretty straightforward stuff here as well. We setup the header with timestamp & sessioncount, then append each chosen statistic for each vSphere host. Now we’ll get our stats and write the information out.
$rSessions = @(get-remotesession) $outString = "{0:HH:mm},{1}" -f (get-date),$rSessions.count Connect-VIServer $vCenterServer foreach ($vsh in $vSphereHosts) { foreach ($stat in $statsToGet) { [int]$statVal = Get-Stat -Entity "$vsh.$dnsDomain" ` -Start $today.ToShortDateString() -IntervalMins 5 -Stat $stat | ` Select-Object -ExpandProperty value -First 1 $outString += ",$statVal" } } Out-File -FilePath $logPath\$logName ` -inputobject $outString -append -encoding ASCII # Close all vSphere PowerCLI server connections Disconnect-VIServer -Force -Confirm:$false
Code very similar to setting up the header for the log. We grab all the remote sessions our View manager is tracking into an array, then use the count (length) of the array for the information we log. Next we connect to our vCenter server and for each vSphere Host we gather the last recorded 5 minute interval value of each chosen statistic. There are only so many interval values available by default, and you can find our what you have available by running the Get-StatInterval cmdlet. Next we write out the completed string to the logfile. Lastly we disconnect the VIServer session.
The last bit in this formula is setting up the schedule. You’ll get the most detailed information by setting up a scheduled task that runs on 5 minute intervals that match the intervals that you see when running Get-StatInterval and Get-Stat from the console. That said, I only run mine every 10 minutes – we’re really just looking for an overview. One thing to make sure is that the task is setup to run as a user that has the correct permissions to connect and gather statistics from your vCenter server, otherwise the whole script will just hang!
So, great – we can log, but how to make a nice pretty picture out of it? That’s a question with lots of different answers. There’s a lot of different ways to visualize statistic data, you probably have your favorite, and the details will depend on your hosts and the statistics you want to track, so I’ll just share a quick pic of what we use to keep an eye on things using PowerGadgets.
Hope that can help or inspire somebody out there, Happy Friday!
Once again, I’ve gone what seems like ages without taking the time to share. We’ve been so busy with implementing Microsoft’s Live@Edu service as well as piloting VMWare’s View I just haven’t taken the time. In any case, here’s a little form I put together in Primalforms that connects to Live@Edu, and allows you to search for your students and change their passwords easily. You can embed credentials, but by default the script will prompt for credentials each time the script is run. The code is below, I think I've decided to go with poshcode.org for pasting code and what not.
function Test-Mystery { [CmdletBinding()] param ( [ValidateNotNullOrEmpty()] $paramOne ) $paramOne $paramOne.length }Pretty straightforward advanced function stuff, we have a parameter that we need to validate. It can be anything it wants as long as it’s not null, and is not an empty string. If we test this, we’ll find that it works. Mostly.
So from the ‘oh my god I can’t believe I hadn’t found this earlier’ corner comes this: PowerShell 2.0 ships with a new cmdlet called Send-MailMessage. Why is this kinda sorta a big deal (at least to me, haha)? PSCX will be deprecating the Send-SmtpMail cmdlet (that you may or may not have been using profusely, I know I have) in the next major revision. So it’s time to start going through scripts and updating as needed. Thankfully, the syntax is very similar. The only changes that I found I needed to make were:
Moving a mailbox or two between Exchange databases and/or servers isn’t too hard to keep track of, but what if you’re moving 200? Or 2000? I came up with a little “one-liner” (can we really call a do loop a one-liner?) while migrating the bulk of our mailboxes from Exchange 2003 to 2010. Hopefully you can use it to help you keep an eye on things too. Here’s the script, first in a more traditional form, then in one-liner form for easy copy and paste into a shell session. It’s a do {} until ($false) loop so you have to Ctrl-C to break out of it.
do
{
Clear-Host
$mr = Get-MoveRequest
$mr | Group –Property Status | Select Name,Count | Format-Table –auto
$mr | Where { $_.status –eq “InProgress” } | Select DisplayName,TotalMailboxSize,PercentComplete |
Format-Table –auto
Start-Sleep –seconds 60
} until ($false)
do { cls ; $mr = Get-MoveRequest ; $mr | group status | select Name,Count | ft –auto ; $mr | ? { $_.status –eq “InProgress” } | select DisplayName,TotalMailboxSize,PercentComplete | ft –auto ; sleep –seconds 60 } until ($false)
Hope you find that helpful!
In this final entry in the ‘Things I learned from the Scripting Games’ series, we’ll go over two different yet related bits of information. The first is displaying balloon tips in the notification area (System Tray). The second is displaying log-like feedback in a form generated in PowerShell. Because they both relate to presenting information or feedback to the user, and because it’s been awhile since I posted anything…and because I promised someone I would try real hard to update my PDFCreator post this week, both subjects fit in one post today.
Often times when we need to present the user with information in a graphical way, the messagebox is sufficient. However, because the messagebox is synchronous by default (that is, the script pauses execution until the messagebox is closed) and there isn’t an easy way to create an asynchronous messagebox, they don’t fit every scenario. Sometimes we just want to display some information to the user and move on. Enter balloon tips.
For me, the most challenging part of making a balloon tip function wherever the script is running is the fact that it requires an icon. How, exactly, is one to deliver an icon when (especially during the scripting games) all we have at our disposal is our PowerShell script file? Let’s get our object setup and then we’ll answer that question. First things first, load the forms assemblies:
[void][reflection.assembly]::Load("System.Windows.Forms, `
Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089")
Now we can create a new NotifyIcon object, and setup the text for the balloon tip:
$notifyIcon = New-Object System.Windows.Forms.NotifyIcon
$notifyIcon.BalloonTipTitle = "Balloon Title"
$notifyIcon.BalloonTipText = "Balloon Text"
So, about that icon…turns out we can extract an icon from a file to use for the NotifyIcon object. What file with icons should every relevant Windows system have? Explorer.exe!
$notifyIcon.Icon = [System.drawing.icon]::ExtractAssociatedIcon(`
"$env:windir\explorer.exe")
OK, so now we have everything setup, let’s display that balloon tip. First we have to make the NotifyIcon visible, then we can display the balloon tip for the number of milliseconds we specify:
$notifyIcon.Visible = $true
$notifyIcon.ShowBalloonTip(3000)
There we go. Presenting information to the user in a way that doesn’t pause script execution, done.
Coming up with a “how many times would I actually use this” example for this particular technique has proven difficult. Maybe it’s because I don’t have much experience creating GUIs designed for end users. But that said, a wall (or box) of scrolling text seems so deceptively simple that it took me a while to figure out how to accomplish the task.
In advanced event 10 of the scripting games, one element was to present the user with progress of files being moved. Instantly the picture of the nullsoft installer with it’s wall of text during the install sprung into my mind. Achieving a similar effect isn’t too involved. Here’s how we do it.
First we’re going to setup 3 objects: a form, a textbox, and a button.
$form = new-object windows.forms.form
$form.Size = New-Object System.Drawing.Size(270,125)
$txtBox = new-object windows.forms.textbox
$txtbox.Multiline = $true
$txtBox.WordWrap = $false
$txtBox.size = New-Object System.Drawing.Size(250,60)
$txtBox.ScrollBars = [System.Windows.Forms.ScrollBars]::Vertical
$btn = new-object windows.forms.button
$btn.Location = new-object system.drawing.point(0,60)
$btn.Text = "Go!"
$form.Controls.Add($txtBox)
$form.Controls.Add($btn)
So we’ve assigned some size and location information to the controls and added the controls to the form, which is standard business so far. Two properties of the textbox control are important for this particualr task: Multiline & ScrollBars. We make sure to set the multiline property to true, and assign vertical scrollbars to the textbox. I also like to disable wordwrap, but that’s just me.
Next let’s setup the scriptblock that will run when we click the button:
$button_OnClick = {
1..10 | % {
$txtBox.Lines += $_
$txtBox.Select($txtBox.Text.Length, 0)
$txtBox.ScrollToCaret()
$form.Update()
start-sleep 1
}
}
$btn.add_Click($button_OnClick)
So this is pretty simple. We create a variable ($button_OnClick) and assign it a scriptblock in which we have a small loop. We take the array created by the 1..10 range, and for each number we:
We add that scriptblock to the click event of the button by using the hidden method add_Click($button_onClick). As a side note in case you’re wondering, you use the –Force parameter of Get-Method to display hidden members. Lastly let’s show the form:
$form.showdialog()
Give that button a push! Sure it’s a simplistic example, but it can be used to pretty good effect. The $form.update() method caught me by surprise, probably because I’m pretty green with this GUI business. To see what happens without calling the update method, comment out or delete the $form.Update() line and run $form.showdialog() again. It’s like the whole form freezes while the loop runs! Bad jujus.
To see a more complete example you can check out my submission for Event 10 of the Scripting Games here.
Important: Background jobs that are started by using Start-Job or the AsJob parameter of Invoke-Command rely on the Windows PowerShell remoting infrastructure. To use these features, Windows PowerShell must be configured for remoting, even if the background job is run only on the local computer. For more information, see about_Remote_Requirements.The second from Get-Help about_scopes:
Sessions: A session is an environment in which Windows PowerShell runs. When you create a session on a remote computer, Windows PowerShell establishes a persistent connection to the remote computer. The persistent connection lets you use the session for multiple related commands.Aha! The variable scope is separate and distinct inside of the scriptblock that we pass to Start-Job. So we use the ArgumentList parameter to get the information that we need to the scriptblock. Let’s see that in action:
Because a session is a contained environment, it has its own scope, but a session is not a child scope of the session in which is was created. The session starts with its own global scope. This scope is independent of the global scope of the session. You can create child scopes in the session. For example, you can run a script to create a child scope in a session.
function Test-ModuleAvailability([string]$modulename,[switch]$Import) { # Check to see if our module is available [System.Management.Automation.PSModuleInfo]$results = ` Get-Module -ListAvailable | ? { $_.name -ieq $modulename } if ($results) { if ($Import -and $results.sessionState -eq $null) { #Import the module Import-Module $results } #Return true, the module is available $true } else { #Return false, the module is not available $false } }
if (-not $(Test-ModuleAvailability FileSystem -Import)) { Throw "Could not find or load required Module FileSystem" }
# Setup the objects for titlebar manipulation # Append a #1,#2,#(n-1) to the process name if there are n pre-existing processes with the same # name, as this is how the Performance Counter system references the instances. $psProcess = gps -id $PID $psInstances = (gps -Name $psProcess.name).count if ($psInstances -gt 1) { $psName = "{0}#{1}" -f $psProcess.name,$($psInstances - 1) } else { $psName = $psProcess.name } # Create the Performance Counter Object to track our sessions CPU usage $Global:psPerfCPU = new-object System.Diagnostics.PerformanceCounter( "Process","% Processor Time", $psName ) # Get the first 'NextValue', which will be zero $psPerfCPU.NextValue() | Out-Null # Create a timer object and set the interval to 1 second $Global:psTimer = New-Object System.Timers.Timer $psTimer.Interval = 1000 # Update the window's title bar every time the timer object raises the # elapsed event Register-ObjectEvent -InputObject $psTimer -EventName Elapsed -Action { $psInfo = Get-Process -id $pid [int]$ws = $psInfo.workingset/1MB [int]$cpu = $psPerfCPU.NextValue() / $env:NUMBER_OF_PROCESSORS $Host.ui.rawui.WindowTitle = "$($CurrentUser.Name) $($Host.Name) $($Host.Version) | $((get-location).ProviderPath) | RAM: $ws MB CPU: $cpu%" } | Out-Null $psTimer.start()
001
002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 |
try
{ try { # Command that uses -erroraction stop or $erroractionpreference = 'Stop' } # Here we catch the Exception generated by the ErrorAction "Stop" # Only necessary if there is any processing we want to do if the # exception is of type ActionPreferenceStopExecution, # otherwise this block can be deleted catch [System.Management.Automation.ActionPreferenceStopException] { # Error Handling specific to ActionPreferenceStopException goes here # Rethrow the "real" exception as a terminating error Throw $_.exception } # All errors are caught and rethrown to the outer try/catch block # as terminating errors to be handled. catch { Throw $_.exception } } # Here we can resume exception type handling as usual catch #[System.Management.Automation.ItemNotFoundException] { "Got it!" } |
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 | <# .SYNOPSIS A brief description of the function or script. This keyword can be used only once in each topic. .DESCRIPTION A detailed description of the function or script. This keyword can be used only once in each topic. .PARAMETER The description of a parameter. You can include a Parameter keyword for each parameter in the function or script syntax. .EXAMPLE A sample command that uses the function or script, optionally followed by sample output and a description. Repeat this keyword for each example. .INPUTS The Microsoft .NET Framework types of objects that can be piped to the function or script. You can also include a description of the input objects. .OUTPUTS The .NET Framework type of the objects that the cmdlet returns. You can also include a description of the returned objects. .NOTES Additional information about the function or script. .LINK The name of a related topic. Repeat this keyword for each related topic. .COMPONENT The technology or feature that the function or script uses, or to which it is related. This content appears when the Get-Help command includes the Component parameter of Get-Help. .ROLE The user role for the Help topic. This content appears when the Get-Help command includes the Role parameter of Get-Help. .FUNCTIONALITY The intended use of the function. This content appears when the Get-Help command includes the Functionality parameter of Get-Help. .FORWARDHELPTARGETNAME Redirects to the Help topic for the specified command. You can redirect users to any Help topic, including Help topics for a function, script, cmdlet, or provider. .FORWARDHELPCATEGORY Specifies the Help category of the item in ForwardHelpTargetName. Valid values are Alias, Cmdlet, HelpFile, Function, Provider, General, FAQ, Glossary, ScriptCommand, ExternalScript, Filter, or All. Use this keyword to avoid conflicts when there are commands with the same name. .REMOTEHELPRUNSPACE Specifies a session that contains the Help topic. Enter a variable that contains a PSSession. This keyword is used by the Export-PSSession cmdlet to find the Help topics for the exported commands. .EXTERNALHELP Specifies the path to an XML-based Help file for the script or function. For more information about the cmdlet Help XML-based Help file format, see "How to Create Cmdlet Help" in the MSDN (Microsoft Developer Network) library at http://go.microsoft.com/fwlink/?LinkID=123415. #> |
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 | [parameter( #Mandatory=$false, #Position=0, #ParameterSetName="SetName", #ValueFromPipeline=$false, #ValueFromPipelineByPropertyName=$false, #ValueFromRemainingArguments=$false, #HelpMessage="Help goes here" )] #[Alias("Alias"] #[AllowNull()] #[AllowEmptyString()] #[AllowEmptyCollection()] #[ValidateCount(min,max)] #[ValidateLength(min,max)] #[ValidatePattern("RegEx Goes Here")] #[ValidateRange(min,max)] #[ValidateScript({ $_ -eq "something" })] #[ValidateSet("One","Two","Three")] #[ValidateNotNull()] #[ValidateNotNullOrEmpty()] [System.Object] $parameterName = "DefaultValue" |
001 002 003 004 005 006 007 | Get-EventLog -Before '01/26/2010' -after '01/25/2010' -ComputerName computername -LogName Security | ` where-object ` { ($_.username -notmatch '^NT AUTHORITY\\(SYSTEM|NETWORK SERVICE|LOCAL SERVICE|ANONYMOUS LOGON)$') ` -and ($_.category -eq "Logon/Logoff") } | ` select-object timegenerated,username,category,message | sort timegenerated | format-table -auto |
I’m a little behind the curve here, as the final build was posted on the 11th of January. Oh well. For the security conscious administrator, the backtrack suite makes a great tool. If you don’t know what it is, or taken the time to at least check it out, cut some time out to do so soon. It will open your eyes a bit, I promise :)
The final build, available in either .ISO or VM-ware forms, can be downloaded from http://www.backtrack-linux.org/downloads/
If you can, use the torrent links. Your download will most likely be much faster.
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 102 103 | filter Get-CGPOReportExtensionData { <# .SYNOPSIS Queries XML Reports generated by Get-GPOReport for specific Extension information .DESCRIPTION Finds the extension specified by the parameter ExtensionName in the Report or Reports (i.e. Files, Registry, Software Installation). Tacks on the namespace information necessary to query the extension onto the report as a custom PSObject and writes that information to output. .PARAMETER gpoReport A report generated by Get-GPOReport -reportType XML. .PARAMETER namespaceMgr If no namespace manager is assigned to this value, the default namespaces for group policy XML reports are used. .PARAMETER extensionName The name of the group policy extension that you wish to find in the reports. Valid names I am currently aware of: Security,Public Key,Registry,Remote Installation,Internet Explorer Maintenance, Software Installation,Scripts,Folder Redirection,Printers,Windows Firewall, Software Restriction,Drive Maps,Shortcuts,Folders,Files,Windows Registry, Environment Variables,WLanSvc Networks,Folder Options,Start Menu, Deployed Printer Connections Policy,Ini Files .EXAMPLE Get-GPOReport -All -ReportType XML | Get-CGPOReportExtension Data -ExtensionName "Drive Maps" .INPUTS [System.XML.XMLDocument] .OUTPUTS [System.Management.Automation.PSCustomObject] .NOTES Todo. .FUNCTIONALITY Todo. #> #region cmdletbinding [CmdletBinding()] #endregion #region parameters param ( [parameter( Mandatory=$true, ValueFromPipeline=$true )] [Xml.XmlDocument] $gpoReport, [parameter( Mandatory=$false )] [Xml.XmlNamespaceManager] $namespaceMgr, [parameter( Mandatory=$true )] [String] $extensionName ) #endregion process { # Build a namespace manager if we don't have one if (-not $namespaceMgr) { # Create a namespace manager from our navigator object's nametable $namespaceMgr = New-Object System.Xml.XmlNamespaceManager $gpoReport.CreateNavigator().NameTable $namespaceMgr.AddNamespace( "e", "http://www.microsoft.com/GroupPolicy/Settings" ) } # We're going to cheat and use Posh's dotted notation to get the GPO name # there is only one GPO node (it's the root), and only one Name element $GPOName = $gpoReport.GPO.Name # Gather the Extensions that match the our queryCSE $extensions = $gpoReport.selectnodes("/e:GPO//e:ExtensionData[e:Name = '$extensionName']/e:Extension", $namespaceMgr) foreach ($extension in $extensions) { # Init the extension specific namespace variable $extensionNamespaceName = $null $extensionNamespaceURI = $null # We need the Extension child element of this ExtensionData element # and have to extract the specific namespaces assigned for Extension # by the 'Get-GPOReport' cmdlet $eNavigator = $extension.CreateNavigator() $eNamespace = $eNavigator.GetNamespacesInScope('All') foreach ($key in $eNamespace.keys) { # The namespace assignments we're looking for are always named 'q1', 'q2', ... 'q99' # If there's more than a hundred, someone needs to start splitting up their GPOs, :) if ($key -match '^q\d{1,2}$') { # Now we have the namespace assignment that is valid to query this element $gpoExtInfo = New-Object PSObject -Property @{ GPOName = $GPOName fullReport = $gpoReport extensionData = $extension extensionNamespaceName = $key extensionNamespaceURI = $eNamespace.$key } } } Write-Output $gpoExtInfo } } } Export-ModuleMember Get-CGPOReportExtensionData |