├── .gitignore ├── Chapter02 - Get started automating ├── Helper Scripts │ ├── Invoke-LogFileCleanup.ps1 │ └── New-TestLogFiles.ps1 ├── Listing 01 - Set-ArchiveFilePath Function.ps1 ├── Listing 02 - Deleting Archived Files.ps1 ├── Listing 03 - Putting it All Together.ps1 ├── Listing 04 - Get Top N Processes.ps1 ├── Listing 05 - New-ModuleTemplate.ps1 ├── Listing 06 - Set-ArchiveFilePath.ps1 ├── Listing 07 - Load Module Functions.ps1 ├── Listing 08 - Moving Functions to Module.ps1 ├── Listing 09 - Import Required Modules.ps1 └── Snippets.md ├── Chapter03 - Scheduling automation scripts ├── Helper Scripts │ ├── FileCleanupTools │ │ └── 1.0.0.0 │ │ │ ├── FileCleanupTools.psd1 │ │ │ ├── FileCleanupTools.psm1 │ │ │ └── Public │ │ │ ├── Remove-ArchivedFiles.ps1 │ │ │ └── Set-ArchiveFilePath.ps1 │ ├── Invoke-LogFileCleanup.ps1 │ ├── New-Chapter03Files.ps1 │ └── New-TestWatcherFiles.ps1 ├── Listing 01 - Create Scheduled Task.ps1 ├── Listing 02 - Importing a Scheduled Task.ps1 ├── Listing 03 - Importing Multiple a Scheduled Tasks.ps1 ├── Listing 04 - Watch-Folder.ps1 ├── Listing 05 - Action Script with Logging and Error Handling.ps1 └── Snippets.md ├── Chapter04 - Handling sensitive data ├── Helper Scripts │ └── SQLExpressInstall.ps1 ├── Listing 01 - Test SQL Connection information from SecretStore vault.ps1 ├── Listing 02 - Test SendGrid Connection information from KeePass vault.ps1 ├── Listing 03 - SQL health check.ps1 ├── Listing 04 - SQL health check through Jenkins.ps1 └── Snippets.md ├── Chapter05 - PowerShell remote execution ├── Listing 01 - Get-VSCodeExtensions.ps1 ├── Listing 02 - Execute local script against remote commputers using WSMan remoting.ps1 ├── Listing 03 - Execute local script against remote computers using WSMan and SSH remoting.ps1 ├── Listing 04 - Connect to all Virtual Machines from a Hyper-V Host.ps1 ├── Listing 05 - Updated find installed Visual Studio Code extensions to output results to network share.ps1 └── Snippets.md ├── Chapter06 - Making adaptable automations ├── Helper Scripts │ ├── PoshAutomate-ServerConfig.psm1 │ ├── RegistryChecks.json │ └── SecurityBaseline.json ├── Listing 01 - Creating the PoshAutomate-ServerConfig module.ps1 ├── Listing 02 - PoshAutomate-ServerConfig.psm1 ├── Listing 03 - Disable-WindowsService.ps1 ├── Listing 04 - Creating JSON.ps1 ├── Listing 05 - Add new data to JSON using PowerShell.ps1 ├── Listing 06 - Registry Test Class.ps1 ├── Listing 07 - Registry Check Class.ps1 ├── Listing 08 - Test-SecurityBaseline.ps1 ├── Listing 09 - Set-SecurityBaseline.ps1 ├── Listing 10 - Install-RequiredFeatures.ps1 ├── Listing 11 - Set-FirewallDefaults.ps1 ├── Listing 12 - Server Config Class.ps1 ├── Listing 13 - New-ServerConfig.ps1 ├── Listing 14 - Set-ServerConfig.ps1 ├── Listing 15 - Create Server Config JSON.ps1 ├── Listing 16 - Invoke-ServerConfig.ps1 └── Snippets.md ├── Chapter07 - Working with SQL ├── Helper Scripts │ ├── SQLExpressInstall.ps1 │ ├── SampleData.csv │ └── Sync-vSphereVMs.ps1 ├── Listing 01 - Create PoshAssetMgmt database.ps1 ├── Listing 02 - Create Servers table in SQL.ps1 ├── Listing 03 - Creating the PoshAssetMgmt module.ps1 ├── Listing 04 - PoshAutomate-AssetMgmt.ps1 ├── Listing 05 - Connect-PoshAssetMgmt.ps1 ├── Listing 06 - New-PoshServer.ps1 ├── Listing 07 - Get-PoshServer.ps1 ├── Listing 08 - Set-PoshServer.ps1 ├── Listing 09 - Sync from external CSV.ps1 └── Snippets.md ├── Chapter08 - Cloud-based automation ├── Helper Scripts │ ├── Create Azure Resources.ps1 │ ├── New-TestArchiveFile.ps1 │ ├── Output-Examples.ps1 │ └── Upload-ZipToBlob.ps1 ├── Listing 01 - Install Microsoft Monitoring Agent.ps1 ├── Listing 02 - Create Hybrid Runbook Worker.ps1 ├── Listing 03 - Upload ZIP files to Azure Blob.ps1 ├── Listing 04 - Upload-ZipToBlob.ps1 └── Snippets.md ├── Chapter09 - Working outside of PowerShell ├── Helper Scripts │ ├── Install-Python.ps1 │ └── timeseries.py ├── Listing 01 - New-WordTableFromObject.ps1 ├── Listing 02 - New-WordTableFromArray.ps1 ├── Listing 03 - New-TimeseriesGraph.ps1 ├── Listing 04 - System Information Documentation.ps1 └── Snippets.md ├── Chapter10 - Automation coding best practices ├── Helper Scripts │ ├── Autounattend.xml │ └── oscdimg.exe ├── Listing 01 - Extract the ISO.ps1 ├── Listing 02 - Create a Windows zero-touch ISO.ps1 ├── Listing 03 - Find the oscdimg.exe file.ps1 ├── Listing 04 - Running the oscdimg.exe.ps1 ├── Listing 05 - Create a Windows zero-touch ISO.ps1 ├── Listing 06 - Get the Path and External Switch.ps1 ├── Listing 07 - Create a VM.ps1 ├── Listing 08 - Check if the VM exists before creating it.ps1 ├── Listing 09 - Update VM settings.ps1 ├── Listing 10 - Wait for the OS install to finish.ps1 ├── Listing 11 - Add a second virtual hard disk.ps1 ├── Listing 12 - Get-IsoCredentials.ps1 ├── Listing 13 - Create new VM with zero-touch ISO.ps1 └── Snippets.md ├── Chapter11 - End-user scripts and forms ├── Listing 01 - Monitor for new site requests.ps1 ├── Listing 02 - Creating a new SharePoint site.ps1 ├── Listing 03 - git-install.ps1 ├── Listing 04 - New-ActiveSetup.ps1 ├── Listing 05 - Git install with Active Setup.ps1 └── Snippets.md ├── Chapter12 - Sharing scripts among a team ├── Helper Scripts │ └── Install-GitHubCli.ps1 ├── Listing 01 - Get-SystemInfo.ps1 ├── Listing 02 - Create PoshAutomator module.ps1 ├── Listing 03 - Get-SystemInfo.ps1 ├── Listing 04 - Test-CmdInstall.ps1 ├── Listing 05 - Install-PoshAutomator.ps1 ├── Listing 06 - PoshAutomator.psm1 ├── Listing 6 - PoshAutomator.psm1 └── Snippets.md ├── Chapter13 - Testing your scripts ├── Helper Scripts │ ├── 250bfd45-b92c-49af-b604-dbdfd15061e6.html │ ├── 3767d7ce-29db-4d75-93b7-34922d49c9e3.html │ ├── 6fcd8832-c48d-46bc-9dac-ee1ec2cdfdeb.html │ ├── 83d7bc64-ff39-4073-9d77-02102226aff6.html │ ├── 9bd3bbf6-0002-4c0b-ae52-fc21ba9d7166.html │ ├── Find-KbSupersedence.Integration.Tests.ps1 │ ├── Find-KbSupersedence.Unit.Tests.ps1 │ ├── Find-KbSupersedence.ps1 │ ├── Get-HotFixStatus.Unit.Tests.ps1 │ ├── Get-HotFixStatus.ps1 │ ├── Get-VulnerabilityStatus.Integration.Tests.ps1 │ ├── Get-VulnerabilityStatus.ps1 │ ├── KB4521858.html │ ├── KB5008295.html │ └── testResults.xml ├── Listing 01 - Get-HotFixStatus.ps1 ├── Listing 02 - Get-HotFixStatus.Unit.Tests.ps1 Local Check.ps1 ├── Listing 03 - Get-HotFixStatus.Unit.Tests.ps1 with Mocking.ps1 ├── Listing 04 - Find-KbSupersedence.ps1 ├── Listing 05 - Find-KbSupersedence.Unit.Test.ps1 Initial.ps1 ├── Listing 06 - Export HTML to file.ps1 ├── Listing 07 - Find-KbSupersedence.Unit.Tests.ps1 with Mock test files.ps1 ├── Listing 08 - Find-KbSupersedence.Unit.Tests.ps1 in-depth with Mocks.ps1 ├── Listing 09 - Get-VulnerabilityStatus.ps1 ├── Listing 10 - Get-VulnerabilityStatus.Integration.Test.ps1 ├── Listing 11 - Find-KbSupersedence.Integration.Tests.ps1 integration tests.ps1 └── Snippets.md ├── Chapter14 - Maintaining your code ├── Helper Scripts │ ├── GitHub-Setup.md │ ├── Install-GitHubCli.ps1 │ ├── PostAutomator-v1.0.0.2.zip │ ├── test.SUSE.txt │ ├── test.Ubuntu.txt │ ├── test.df.txt │ ├── test.rhel.txt │ └── test.stat.txt ├── Listing 01 - Get-SystemInfo Test before updating.ps1 ├── Listing 02 - Get-SystemInfo.ps1 ├── Listing 03 - Get-SystemInfo Test after the updates.ps1 ├── Listing 4 - Get-SystemInfo.yaml └── Snippets.md ├── LabSetup ├── AutomationServer.ps1 ├── DevelopmentMachineSetup.ps1 └── README.md ├── PoSHAutomate.code-workspace └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | *Snippets.ps1 3 | Notes/ -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Helper Scripts/Invoke-LogFileCleanup.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $true)] 3 | [string]$LogPath, 4 | 5 | [Parameter(Mandatory = $true)] 6 | [string]$ZipPath, 7 | 8 | [Parameter(Mandatory = $true)] 9 | [string]$ZipPrefix, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [double]$NumberOfDays = 30 13 | ) 14 | 15 | Function Set-ArchiveFilePath { 16 | [CmdletBinding()] 17 | [OutputType([string])] 18 | param( 19 | [Parameter(Mandatory = $true)] 20 | [string]$ZipPath, 21 | 22 | [Parameter(Mandatory = $true)] 23 | [string]$ZipPrefix, 24 | 25 | [Parameter(Mandatory = $false)] 26 | [datetime]$Date = (Get-Date) 27 | ) 28 | 29 | # check if the folder path exists and create it if it doesn't 30 | if(-not (Test-Path -Path $ZipPath)){ 31 | New-Item -Path $ZipPath -ItemType Directory | Out-Null 32 | Write-Verbose "Created folder '$ZipPath'" 33 | } 34 | 35 | # Set the full path of the zip file 36 | $ZipFile = Join-Path $ZipPath "$($ZipPrefix)$($Date.ToString('yyyyMMdd')).zip" 37 | 38 | # confirm the file doesn't already exist. Throw a terminating error if it does 39 | if(Test-Path -Path $ZipFile){ 40 | throw "The file '$ZipFile' already exists" 41 | } 42 | 43 | # Return the file path 44 | $ZipFile 45 | } 46 | 47 | Function Remove-ArchivedFiles { 48 | [CmdletBinding()] 49 | [OutputType()] 50 | param( 51 | [Parameter(Mandatory = $true)] 52 | [string]$ZipFile, 53 | 54 | [Parameter(Mandatory = $true)] 55 | [object]$FilesToDelete, 56 | 57 | [Parameter(Mandatory = $false)] 58 | [switch]$WhatIf = $false 59 | ) 60 | 61 | # Load the System.IO.Compression.FileSystem assembly so we can use dot sourcing later 62 | Add-Type -AssemblyName 'System.IO.Compression.FileSystem' | Out-Null 63 | 64 | # Get the information on the files inside the zip 65 | $ZipFileEntries = [IO.Compression.ZipFile]::OpenRead($ZipFile).Entries 66 | 67 | # Confirm each file to delete has a match in the zip file 68 | foreach($file in $FilesToDelete){ 69 | $check = $ZipFileEntries | Where-Object{ $_.Name -eq $file.Name -and $_.Length -eq $file.Length } 70 | # if $check does not equal null then you know the file was found and can be deleted 71 | if($null -ne $check){ 72 | $file | Remove-Item -Force -WhatIf:$WhatIf 73 | } 74 | else { 75 | Write-Error "Reference for file '$($file.Name)' was not find in the archive '$($ZipFile)'" 76 | } 77 | } 78 | 79 | } 80 | 81 | # Collect the old files and save them to variable 82 | $Date = (Get-Date).AddDays(-$NumberOfDays) 83 | $files = Get-ChildItem -Path $LogPath -File | 84 | Where-Object{ $_.LastWriteTime -lt $Date} 85 | 86 | # Set the zip file path 87 | $ZipFile = Set-ArchiveFilePath -ZipPath $ZipPath -ZipPrefix $ZipPrefix -Date $Date 88 | 89 | # Compress the old files 90 | $files | Compress-Archive -DestinationPath $ZipFile 91 | 92 | # Delete the old files 93 | Remove-ArchivedFiles -ZipFile $ZipFile -FilesToDelete $files -WhatIf -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Helper Scripts/New-TestLogFiles.ps1: -------------------------------------------------------------------------------- 1 | # Set directory to create logs in 2 | $Directory = "L:\Logs" 3 | # Set number of days, in the past, to create log files for 4 | $days = 90 5 | 6 | # create the folder if not found 7 | if(-not (Test-Path $Directory)){ 8 | New-Item -Path $Directory -ItemType Directory 9 | } 10 | 11 | # this function creates randomly sized files 12 | Function Set-RandomFileSize { 13 | param( [string]$FilePath ) 14 | $size = Get-Random -Minimum 1 -Maximum 50 15 | $size = $size*1024*1024 16 | $file = [System.IO.File]::Open($FilePath, 4) 17 | $file.SetLength($Size) 18 | $file.Close() 19 | Get-Item $file.Name 20 | } 21 | 22 | # loop to create a file for each day back 23 | for($i = 0; $i -lt $days; $i++) { 24 | # Get Date and create log file 25 | $Date = (Get-Date).AddDays(-$i) 26 | # create unique file name with the date in it 27 | $FileName = "u_ex$($date.ToString('yyyyMMdd')).log" 28 | # set the file path 29 | $FilePath = Join-Path -Path $Directory -ChildPath $FileName 30 | # write the date inside the file, will override existing files 31 | $Date | Out-File $FilePath 32 | # set a random file size 33 | Set-RandomFileSize -FilePath $FilePath 34 | 35 | # Set the Creation, Write, and Access time of log file to past date 36 | Get-Item $FilePath | ForEach-Object { 37 | $_.CreationTime = $date 38 | $_.LastWriteTime = $date 39 | $_.LastAccessTime = $date 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 01 - Set-ArchiveFilePath Function.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Set-ArchiveFilePath Function 2 | # Declare the function and set the required parameters. 3 | Function Set-ArchiveFilePath{ 4 | # Declare CmdletBinding and OutputType. 5 | [CmdletBinding()] 6 | [OutputType([string])] 7 | # Define the parameters. 8 | param( 9 | [Parameter(Mandatory = $true)] 10 | [string]$ZipPath, 11 | 12 | [Parameter(Mandatory = $true)] 13 | [string]$ZipPrefix, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [datetime]$Date 17 | ) 18 | 19 | # Check whether the folder path exists, and create it if it doesn't. 20 | if(-not (Test-Path -Path $ZipPath)){ 21 | New-Item -Path $ZipPath -ItemType Directory | Out-Null 22 | # Include verbose output for testing and troubleshooting. 23 | Write-Verbose "Created folder '$ZipPath'" 24 | } 25 | 26 | # Create the timestamp based on the date. 27 | $timeString = $Date.ToString('yyyyMMdd') 28 | # Create the file name. 29 | $ZipName = "$($ZipPrefix)$($timeString).zip" 30 | # Set the full path of the zip file. 31 | $ZipFile = Join-Path $ZipPath $ZipName 32 | 33 | # Confirm the file doesn't already exist. Throw a terminating error if it does. 34 | if(Test-Path -Path $ZipFile){ 35 | throw "The file '$ZipFile' already exists" 36 | } 37 | 38 | # Return the file path to the script. 39 | $ZipFile 40 | } 41 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 02 - Deleting Archived Files.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Deleting Archived Files 2 | Function Remove-ArchivedFiles { 3 | [CmdletBinding()] 4 | [OutputType()] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string]$ZipFile, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [object]$FilesToDelete, 11 | 12 | [Parameter(Mandatory = $false)] 13 | [switch]$WhatIf = $false 14 | ) 15 | # Load the System.IO.Compression.FileSystem assembly so you can use dot sourcing later 16 | $AssemblyName = 'System.IO.Compression.FileSystem' 17 | Add-Type -AssemblyName $AssemblyName | Out-Null 18 | 19 | $OpenZip = [System.IO.Compression.ZipFile]::OpenRead($ZipFile) 20 | # Get the information on the files inside the zip 21 | $ZipFileEntries = $OpenZip.Entries 22 | 23 | # Confirm each file to delete has a match in the zip file 24 | foreach($file in $FilesToDelete){ 25 | $check = $ZipFileEntries | Where-Object{ $_.Name -eq $file.Name -and 26 | $_.Length -eq $file.Length } 27 | # If $check does not equal null, you know the file was found and can be deleted 28 | if($null -ne $check){ 29 | # Add WhatIf to allow for testing without actually deleting the files 30 | $file | Remove-Item -Force -WhatIf:$WhatIf 31 | } 32 | else { 33 | Write-Error "'$($file.Name)' was not find in '$($ZipFile)'" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 03 - Putting it All Together.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Putting it All Together 2 | [CmdletBinding()] 3 | [OutputType()] 4 | param( 5 | [Parameter(Mandatory = $true)] 6 | [string]$LogPath, 7 | 8 | [Parameter(Mandatory = $true)] 9 | [string]$ZipPath, 10 | 11 | [Parameter(Mandatory = $true)] 12 | [string]$ZipPrefix, 13 | 14 | [Parameter(Mandatory = $false)] 15 | [double]$NumberOfDays = 30 16 | ) 17 | 18 | # Declare your functions before the script code 19 | Function Set-ArchiveFilePath{ 20 | [CmdletBinding()] 21 | [OutputType([string])] 22 | param( 23 | [Parameter(Mandatory = $true)] 24 | [string]$ZipPath, 25 | 26 | [Parameter(Mandatory = $true)] 27 | [string]$ZipPrefix, 28 | 29 | [Parameter(Mandatory = $false)] 30 | [datetime]$Date = (Get-Date) 31 | ) 32 | 33 | if(-not (Test-Path -Path $ZipPath)){ 34 | New-Item -Path $ZipPath -ItemType Directory | Out-Null 35 | Write-Verbose "Created folder '$ZipPath'" 36 | } 37 | 38 | $ZipName = "$($ZipPrefix)$($Date.ToString('yyyyMMdd')).zip" 39 | $ZipFile = Join-Path $ZipPath $ZipName 40 | 41 | if(Test-Path -Path $ZipFile){ 42 | throw "The file '$ZipFile' already exists" 43 | } 44 | 45 | $ZipFile 46 | } 47 | 48 | Function Remove-ArchivedFiles { 49 | [CmdletBinding()] 50 | [OutputType()] 51 | param( 52 | [Parameter(Mandatory = $true)] 53 | [string]$ZipFile, 54 | 55 | [Parameter(Mandatory = $true)] 56 | [object]$FilesToDelete, 57 | 58 | [Parameter(Mandatory = $false)] 59 | [switch]$WhatIf = $false 60 | ) 61 | 62 | $AssemblyName = 'System.IO.Compression.FileSystem' 63 | Add-Type -AssemblyName $AssemblyName | Out-Null 64 | 65 | $OpenZip = [System.IO.Compression.ZipFile]::OpenRead($ZipFile) 66 | $ZipFileEntries = $OpenZip.Entries 67 | 68 | foreach($file in $FilesToDelete){ 69 | $check = $ZipFileEntries | Where-Object{ $_.Name -eq $file.Name -and 70 | $_.Length -eq $file.Length } 71 | if($null -ne $check){ 72 | $file | Remove-Item -Force -WhatIf:$WhatIf 73 | } 74 | else { 75 | Write-Error "'$($file.Name)' was not find in '$($ZipFile)'" 76 | } 77 | } 78 | } 79 | 80 | # Set the date filter based on the number of days in the past 81 | $Date = (Get-Date).AddDays(-$NumberOfDays) 82 | # Get the files to archive based on the date filter 83 | $files = Get-ChildItem -Path $LogPath -File | 84 | Where-Object{ $_.LastWriteTime -lt $Date} 85 | 86 | $ZipParameters = @{ 87 | ZipPath = $ZipPath 88 | ZipPrefix = $ZipPrefix 89 | Date = $Date 90 | } 91 | # Get the archive file path 92 | $ZipFile = Set-ArchiveFilePath @ZipParameters 93 | 94 | # Add the files to the archive file 95 | $files | Compress-Archive -DestinationPath $ZipFile 96 | 97 | $RemoveFiles = @{ 98 | ZipFile = $ZipFile 99 | FilesToDelete = $files 100 | } 101 | # confirm files are in the archive and delete 102 | Remove-ArchivedFiles @RemoveFiles 103 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 04 - Get Top N Processes.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Get Top N Processes 2 | # Declare your function 3 | Function Get-TopProcess{ 4 | # Define the parameters 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [int]$TopN 8 | ) 9 | # Run the command 10 | Get-Process | Sort-Object CPU -Descending | 11 | Select-Object -First $TopN -Property ID, 12 | ProcessName, @{l='CPU';e={'{0:N}' -f $_.CPU}} 13 | } 14 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 05 - New-ModuleTemplate.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - New-ModuleTemplate 2 | Function New-ModuleTemplate { 3 | [CmdletBinding()] 4 | [OutputType()] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string]$ModuleName, 8 | [Parameter(Mandatory = $true)] 9 | [string]$ModuleVersion, 10 | [Parameter(Mandatory = $true)] 11 | [string]$Author, 12 | [Parameter(Mandatory = $true)] 13 | [string]$PSVersion, 14 | [Parameter(Mandatory = $false)] 15 | [string[]]$Functions 16 | ) 17 | $ModulePath = Join-Path .\ "$($ModuleName)\$($ModuleVersion)" 18 | # Create a folder with the same name as the module 19 | New-Item -Path $ModulePath -ItemType Directory 20 | Set-Location $ModulePath 21 | # Create the public folder to store your ps1 scripts 22 | New-Item -Path .\Public -ItemType Directory 23 | 24 | $ManifestParameters = @{ 25 | ModuleVersion = $ModuleVersion 26 | Author = $Author 27 | # Set the path to the psd1 file 28 | Path = ".\$($ModuleName).psd1" 29 | # Set the path to the psm1 file 30 | RootModule = ".\$($ModuleName).psm1" 31 | PowerShellVersion = $PSVersion 32 | } 33 | # Create the module manifest psd1 file with the settings supplied in the parameters 34 | New-ModuleManifest @ManifestParameters 35 | 36 | # Create a blank psm1 file 37 | $File = @{ 38 | Path = ".\$($ModuleName).psm1" 39 | Encoding = 'utf8' 40 | } 41 | Out-File @File 42 | 43 | # Create a blank ps1 for each function 44 | $Functions | ForEach-Object { 45 | Out-File -Path ".\Public\$($_).ps1" -Encoding utf8 46 | } 47 | } 48 | 49 | # Set the parameters to pass to the function 50 | $module = @{ 51 | # The name of your module 52 | ModuleName = 'FileCleanupTools' 53 | # The version of your module 54 | ModuleVersion = "1.0.0.0" 55 | # Your name 56 | Author = "YourNameHere" 57 | # The minimum PowerShell version this module supports 58 | PSVersion = '7.0' 59 | # The functions to create blank files for in the Public folder 60 | Functions = 'Remove-ArchivedFiles', 61 | 'Set-ArchiveFilePath' 62 | } 63 | # Execute the function to create the new module 64 | New-ModuleTemplate @module -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 06 - Set-ArchiveFilePath.ps1: -------------------------------------------------------------------------------- 1 | # Listing 6 - Set-ArchiveFilePath.ps1 2 | Function Set-ArchiveFilePath{ 3 | [CmdletBinding()] 4 | [OutputType([string])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string]$ZipPath, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [string]$ZipPrefix, 11 | 12 | [Parameter(Mandatory = $false)] 13 | [datetime]$Date = (Get-Date) 14 | ) 15 | 16 | if(-not (Test-Path -Path $ZipPath)){ 17 | New-Item -Path $ZipPath -ItemType Directory | Out-Null 18 | Write-Verbose "Created folder '$ZipPath'" 19 | } 20 | 21 | $ZipName = "$($ZipPrefix)$($Date.ToString('yyyyMMdd')).zip" 22 | $ZipFile = Join-Path $ZipPath $ZipName 23 | 24 | if(Test-Path -Path $ZipFile){ 25 | throw "The file '$ZipFile' already exists" 26 | } 27 | 28 | $ZipFile 29 | } 30 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 07 - Load Module Functions.ps1: -------------------------------------------------------------------------------- 1 | # Listing 7 - Load Module Functions 2 | $Path = Join-Path $PSScriptRoot 'Public' 3 | # Get all the ps1 files in the Public folder 4 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 5 | 6 | # Loop through each ps1 file 7 | Foreach ($import in $Functions) { 8 | Try { 9 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 10 | # Execute each ps1 file to load the function into memory 11 | . $import.fullname 12 | } 13 | Catch { 14 | Write-Error -Message "Failed to import function $($import.name)" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 08 - Moving Functions to Module.ps1: -------------------------------------------------------------------------------- 1 | # Listing 8 - Moving Functions to Module 2 | param( 3 | [Parameter(Mandatory = $true)] 4 | [string]$LogPath, 5 | 6 | [Parameter(Mandatory = $true)] 7 | [string]$ZipPath, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [string]$ZipPrefix, 11 | 12 | [Parameter(Mandatory = $false)] 13 | [double]$NumberOfDays = 30 14 | ) 15 | 16 | # Replaced functions with the command to load the FileCleanupTools module 17 | Import-Module FileCleanupTools 18 | 19 | $Date = (Get-Date).AddDays(-$NumberOfDays) 20 | $files = Get-ChildItem -Path $LogPath -File | 21 | Where-Object{ $_.LastWriteTime -lt $Date} 22 | 23 | $ZipParameters = @{ 24 | ZipPath = $ZipPath 25 | ZipPrefix = $ZipPrefix 26 | Date = $Date 27 | } 28 | $ZipFile = Set-ArchiveFilePath @ZipParameters 29 | 30 | $files | Compress-Archive -DestinationPath $ZipFile 31 | 32 | Remove-ArchivedFiles -ZipFile $ZipFile -FilesToDelete $files 33 | -------------------------------------------------------------------------------- /Chapter02 - Get started automating/Listing 09 - Import Required Modules.ps1: -------------------------------------------------------------------------------- 1 | # Listing 9 - Import Required Modules 2 | [System.Collections.Generic.List[PSObject]]$RequiredModules = @() 3 | # Create an object for each module to check 4 | $RequiredModules.Add([pscustomobject]@{ 5 | Name = 'Pester' 6 | Version = '4.1.2' 7 | }) 8 | 9 | # Loop through each module to check 10 | foreach($module in $RequiredModules){ 11 | # Check whether the module is installed on the local machine 12 | $Check = Get-Module $module.Name -ListAvailable 13 | 14 | # If not found, throw a terminating error to stop this module from loading 15 | if(-not $check){ 16 | throw "Module $($module.Name) not found" 17 | } 18 | 19 | # If it is found, check the version 20 | $VersionCheck = $Check | 21 | Where-Object{ $_.Version -ge $module.Version } 22 | 23 | # If an older version is found, write an error, but do not stop. 24 | if(-not $VersionCheck){ 25 | Write-Error "Module $($module.Name) running older version" 26 | } 27 | 28 | # Import the module into the current session 29 | Import-Module -Name $module.Name 30 | } 31 | -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Helper Scripts/FileCleanupTools/1.0.0.0/FileCleanupTools.psm1: -------------------------------------------------------------------------------- 1 | # Listing 7 - Load Module Functions 2 | $Path = Join-Path $PSScriptRoot 'Public' 3 | # Get all the ps1 files in the Public folder 4 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 5 | 6 | # Loop through each ps1 file 7 | Foreach ($import in $Functions) { 8 | Try { 9 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 10 | # Execute each ps1 file to load the function into memory 11 | . $import.fullname 12 | } 13 | Catch { 14 | Write-Error -Message "Failed to import function $($import.name)" 15 | } 16 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Helper Scripts/FileCleanupTools/1.0.0.0/Public/Remove-ArchivedFiles.ps1: -------------------------------------------------------------------------------- 1 | Function Remove-ArchivedFiles { 2 | [CmdletBinding()] 3 | [OutputType()] 4 | param( 5 | [Parameter(Mandatory = $true)] 6 | [string]$ZipFile, 7 | 8 | [Parameter(Mandatory = $true)] 9 | [object]$FilesToDelete, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [switch]$WhatIf = $false 13 | ) 14 | 15 | $AssemblyName = 'System.IO.Compression.FileSystem' 16 | Add-Type -AssemblyName $AssemblyName | Out-Null 17 | 18 | $OpenZip = [System.IO.Compression.ZipFile]::OpenRead($ZipFile) 19 | $ZipFileEntries = $OpenZip.Entries 20 | 21 | foreach($file in $FilesToDelete){ 22 | $check = $ZipFileEntries | Where-Object{ $_.Name -eq $file.Name -and 23 | $_.Length -eq $file.Length } 24 | if($null -ne $check){ 25 | $file | Remove-Item -Force -WhatIf:$WhatIf 26 | } 27 | else { 28 | Write-Error "'$($file.Name)' was not find in '$($ZipFile)'" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Helper Scripts/FileCleanupTools/1.0.0.0/Public/Set-ArchiveFilePath.ps1: -------------------------------------------------------------------------------- 1 | Function Set-ArchiveFilePath{ 2 | [CmdletBinding()] 3 | [OutputType([string])] 4 | param( 5 | [Parameter(Mandatory = $true)] 6 | [string]$ZipPath, 7 | 8 | [Parameter(Mandatory = $true)] 9 | [string]$ZipPrefix, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [datetime]$Date = (Get-Date) 13 | ) 14 | 15 | if(-not (Test-Path -Path $ZipPath)){ 16 | New-Item -Path $ZipPath -ItemType Directory | Out-Null 17 | Write-Verbose "Created folder '$ZipPath'" 18 | } 19 | 20 | $ZipName = "$($ZipPrefix)$($Date.ToString('yyyyMMdd')).zip" 21 | $ZipFile = Join-Path $ZipPath $ZipName 22 | 23 | if(Test-Path -Path $ZipFile){ 24 | throw "The file '$ZipFile' already exists" 25 | } 26 | 27 | $ZipFile 28 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Helper Scripts/Invoke-LogFileCleanup.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | [OutputType()] 3 | param( 4 | [Parameter(Mandatory = $true)] 5 | [string]$LogPath, 6 | 7 | [Parameter(Mandatory = $true)] 8 | [string]$ZipPath, 9 | 10 | [Parameter(Mandatory = $true)] 11 | [string]$ZipPrefix, 12 | 13 | [Parameter(Mandatory = $false)] 14 | [double]$NumberOfDays = 30 15 | ) 16 | 17 | # Import the FileCleanupTools module 18 | Import-Module FileCleanupTools 19 | 20 | # Set the date filter based on the number of days in the past 21 | $Date = (Get-Date).AddDays(-$NumberOfDays) 22 | # Get the files to archive based on the date filter 23 | $files = Get-ChildItem -Path $LogPath -File | 24 | Where-Object{ $_.LastWriteTime -lt $Date} 25 | 26 | $ZipParameters = @{ 27 | ZipPath = $ZipPath 28 | ZipPrefix = $ZipPrefix 29 | Date = $Date 30 | } 31 | # Get the archive file path 32 | $ZipFile = Set-ArchiveFilePath @ZipParameters 33 | 34 | # Add the files to the archive file 35 | $files | Compress-Archive -DestinationPath $ZipFile 36 | 37 | $RemoveFiles = @{ 38 | ZipFile = $ZipFile 39 | FilesToDelete = $files 40 | } 41 | # confirm files are in the archive and delete 42 | Remove-ArchivedFiles @RemoveFiles 43 | -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Helper Scripts/New-Chapter03Files.ps1: -------------------------------------------------------------------------------- 1 | # Set directory to create test files in 2 | $Path = '.\' 3 | # For the watcher test, set number of files to create 4 | $fileCount = 90 5 | 6 | Function New-ChapterFolder { 7 | param( 8 | $Path, 9 | $ChildPath 10 | ) 11 | $Directory = Join-Path -Path $Path -ChildPath $ChildPath 12 | if (-not (Test-Path $Directory)) { 13 | New-Item -Path $Directory -ItemType Directory | Out-Null 14 | } 15 | $Directory 16 | } 17 | 18 | Function New-ChapterScript { 19 | param( 20 | $Path, 21 | $ScriptName 22 | ) 23 | $ScriptPath = Join-Path -Path $Path -ChildPath $ScriptName 24 | if (-not (Test-Path $ScriptPath)) { 25 | "# $ScriptName" | Out-File -FilePath $ScriptPath -Encoding UTF8 26 | } 27 | $ScriptPath 28 | } 29 | 30 | $chFolder = New-ChapterFolder -Path $Path -ChildPath 'CH03' 31 | $Monitor = New-ChapterFolder -Path $chFolder -ChildPath 'Monitor' 32 | $Export = New-ChapterFolder -Path $Monitor -ChildPath 'Export' 33 | $Watcher = New-ChapterFolder -Path $chFolder -ChildPath 'Watcher' 34 | $Destination = New-ChapterFolder -Path $Watcher -ChildPath 'Destination' 35 | $Logs = New-ChapterFolder -Path $Watcher -ChildPath 'Logs' 36 | $Source = New-ChapterFolder -Path $Watcher -ChildPath 'Source' 37 | 38 | New-ChapterScript -Path $Monitor -ScriptName 'Export-DiskSpaceInfo.ps1' 39 | New-ChapterScript -Path $Watcher -ScriptName 'Move-WatcherFile.ps1' 40 | New-ChapterScript -Path $Watcher -ScriptName 'Watch-Folder.ps1' 41 | 42 | $FilesScript = Join-Path $PSScriptRoot 'New-TestWatcherFiles.ps1' 43 | . $FilesScript 44 | New-TestWatcherFiles -Directory $Source -fileCount $fileCount -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Helper Scripts/New-TestWatcherFiles.ps1: -------------------------------------------------------------------------------- 1 | Function New-TestWatcherFiles { 2 | <# 3 | .SYNOPSIS 4 | Creates random files for testing file watchers 5 | 6 | .DESCRIPTION 7 | Creates random files for testing file watchers 8 | 9 | .PARAMETER Directory 10 | The directory to create test files in 11 | 12 | .PARAMETER fileCount 13 | The number of files to create 14 | #> 15 | param( 16 | $Directory, 17 | $fileCount = 90 18 | ) 19 | 20 | 21 | # create the folder if not found 22 | if (-not (Test-Path $Directory)) { 23 | New-Item -Path $Directory -ItemType Directory 24 | } 25 | 26 | # this function creates randomly sized files 27 | Function Set-RandomFileSize { 28 | param( [string]$FilePath ) 29 | $size = Get-Random -Minimum 1 -Maximum 50 30 | $size = $size * 1024 * 1024 31 | $file = [System.IO.File]::Open($FilePath, 4) 32 | $file.SetLength($Size) 33 | $file.Close() 34 | Get-Item $file.Name 35 | } 36 | 37 | Function Get-RandomFileName { 38 | $len = 5..12 | Get-Random 39 | $string = '' 40 | for ($i = 0; $i -lt $len; $i++) { 41 | 0..31 | Get-Random | Format-Hex | ForEach-Object { 42 | $string += $_.HexBytes.Split()[0] 43 | } 44 | } 45 | $string 46 | } 47 | 48 | # loop to create a file for each day back 49 | for ($i = 0; $i -lt $fileCount; $i++) { 50 | $minutes = 0..720 | Get-Random 51 | # Get Date and create log file 52 | $Date = (Get-Date).AddMinutes(-$minutes) 53 | # create unique file name with the date in it 54 | $FileName = "$(Get-RandomFileName).txt" 55 | # set the file path 56 | $FilePath = Join-Path -Path $Directory -ChildPath $FileName 57 | # write the date inside the file, will override existing files 58 | $Date | Out-File $FilePath 59 | # set a random file size 60 | Set-RandomFileSize -FilePath $FilePath 61 | 62 | # Set the Creation, Write, and Access time of log file to past date 63 | Get-Item $FilePath | ForEach-Object { 64 | $_.CreationTime = $date 65 | $_.LastWriteTime = $date 66 | $_.LastAccessTime = $date 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Listing 01 - Create Scheduled Task.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Create Scheduled Task 2 | # Create a Scheduled Task trigger 3 | $Trigger = New-ScheduledTaskTrigger -Daily -At 8am 4 | 5 | # Set the Action execution path 6 | $Execute = "C:\Program Files\PowerShell\7\pwsh.exe" 7 | # Set the Action arguments 8 | $Argument = '-File ' + 9 | '"C:\Scripts\Invoke-LogFileCleanup.ps1"' + 10 | ' -LogPath "L:\Logs" -ZipPath "L:\Archives"' + 11 | ' -ZipPrefix "LogArchive-" -NumberOfDays 30' 12 | 13 | # Create the Scheduled Task Action 14 | $ScheduledTaskAction = @{ 15 | Execute = $Execute 16 | Argument = $Argument 17 | } 18 | $Action = New-ScheduledTaskAction @ScheduledTaskAction 19 | 20 | # Combine the trigger and action to create the Scheduled Task 21 | $ScheduledTask = @{ 22 | TaskName = "PoSHAutomation\LogFileCleanup" 23 | Trigger = $Trigger 24 | Action = $Action 25 | User = 'NT AUTHORITY\SYSTEM' 26 | } 27 | Register-ScheduledTask @ScheduledTask -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Listing 02 - Importing a Scheduled Task.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Importing a Scheduled Task 2 | $FilePath = ".\CH03\Monitor\Export\LogFileCleanup.xml" 3 | # Import the contents of the XML file to a string 4 | $xml = Get-Content $FilePath -Raw 5 | # Convert the XML string to an XML object 6 | [xml]$xmlObject = $xml 7 | # Set the task name based on the value in the XML 8 | $TaskName = $xmlObject.Task.RegistrationInfo.URI 9 | # Import the scheduled task 10 | Register-ScheduledTask -Xml $xml -TaskName $TaskName -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Listing 03 - Importing Multiple a Scheduled Tasks.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Importing Multiple a Scheduled Tasks 2 | $Share = "\\srv01\PoSHAutomation\" 3 | # Get all the XML files in the folder path 4 | $TaskFiles = Get-ChildItem -Path $Share -Filter "*.xml" 5 | 6 | # parse through each file and import the job 7 | foreach ($FilePath in $TaskFiles) { 8 | $xml = Get-Content $FilePath -Raw 9 | [xml]$xmlObject = $xml 10 | $TaskName = $xmlObject.Task.RegistrationInfo.URI 11 | Register-ScheduledTask -Xml $xml -TaskName $TaskName 12 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Listing 04 - Watch-Folder.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Watch-Folder.ps1 2 | param( 3 | [Parameter(Mandatory = $true)] 4 | [string]$Source, 5 | 6 | [Parameter(Mandatory = $true)] 7 | [string]$Destination, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [string]$ActionScript, 11 | 12 | [Parameter(Mandatory = $true)] 13 | [int]$ConcurrentJobs, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [string]$WatcherLog, 17 | 18 | [Parameter(Mandatory = $true)] 19 | [int]$TimeLimit 20 | ) 21 | 22 | # Start Stopwatch timer 23 | $Timer = [system.diagnostics.stopwatch]::StartNew() 24 | 25 | # Check whether the log file exists, and set the filter date if it does. 26 | if (Test-Path $WatcherLog) { 27 | $logDate = Get-Content $WatcherLog -Raw 28 | try { 29 | $LastCreationTime = Get-Date $logDate -ErrorAction Stop 30 | } 31 | catch { 32 | $LastCreationTime = Get-Date 1970-01-01 33 | } 34 | } 35 | else { 36 | # Default time if no log file is found 37 | $LastCreationTime = Get-Date 1970-01-01 38 | } 39 | 40 | # Get all the files in the folder 41 | $files = Get-ChildItem -Path $Source | 42 | Where-Object { $_.CreationTimeUtc -gt $LastCreationTime } 43 | # Sort the files based on creation time 44 | $sorted = $files | Sort-Object -Property CreationTime 45 | 46 | # Create an array to hold the process IDs of the action scripts 47 | [int[]]$Pids = @() 48 | foreach ($file in $sorted) { 49 | # Record the files time to the log 50 | Get-Date $file.CreationTimeUtc -Format o | 51 | Out-File $WatcherLog 52 | 53 | # Set the arguments from the action script 54 | $Arguments = "-file ""$ActionScript""", 55 | "-FilePath ""$($file.FullName)""", 56 | "-Destination ""$($Destination)""", 57 | "-LogPath ""$($ActionLog)""" 58 | $jobParams = @{ 59 | FilePath = 'pwsh' 60 | ArgumentList = $Arguments 61 | NoNewWindow = $true 62 | } 63 | # Invoke the action script with the PassThruswitch to pass the process id to a variable... 64 | $job = Start-Process @jobParams -PassThru 65 | # ... and the id to the array 66 | $Pids += $job.Id 67 | 68 | # If the number of process ids is greater than or equal to the number of current jobs, loop until it drops. 69 | while ($Pids.Count -ge $ConcurrentJobs) { 70 | Write-Host "Pausing PID count : $($Pids.Count)" 71 | Start-Sleep -Seconds 1 72 | $Pids = @(Get-Process -Id $Pids -ErrorAction SilentlyContinue | 73 | # Get-Process will only return running processes, so execute it to find the total number running. 74 | Select-Object -ExpandProperty Id) 75 | } 76 | 77 | # Check whether the total execution time is greater than the time limit 78 | if ($Timer.Elapsed.TotalSeconds -gt $TimeLimit) { 79 | Write-Host "Graceful terminating after $TimeLimit seconds" 80 | # The break command is used to exit the foreach loop, stopping the script since there is nothing after the loop 81 | break 82 | } 83 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Listing 05 - Action Script with Logging and Error Handling.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Action Script with Logging and Error Handling 2 | param( 3 | [Parameter(Mandatory = $true)] 4 | [string]$FilePath, 5 | [Parameter(Mandatory = $true)] 6 | [string]$Destination, 7 | [Parameter(Mandatory = $true)] 8 | [string]$LogPath 9 | ) 10 | 11 | # Add a new function to perform file checks when a duplicate is found 12 | Function Move-ItemAdvanced { 13 | [CmdletBinding()] 14 | [OutputType()] 15 | param( 16 | [Parameter(Mandatory = $true)] 17 | [object]$File, 18 | [Parameter(Mandatory = $true)] 19 | [string]$Destination 20 | ) 21 | 22 | $DestinationFile = Join-Path -Path $Destination -ChildPath $File.Name 23 | 24 | # Check whether the file exists 25 | if (Test-Path $DestinationFile) { 26 | $FileMatch = $true 27 | # Get the matching file 28 | $check = Get-Item $DestinationFile 29 | if ($check.Length -ne $file.Length) { 30 | # Check whether they have the same length 31 | $FileMatch = $false 32 | } 33 | if ($check.LastWriteTime -ne $file.LastWriteTime) { 34 | # Check whether they have the same last write time 35 | $FileMatch = $false 36 | } 37 | # Check whether they have the same hash 38 | $SrcHash = Get-FileHash -Path $file.FullName 39 | $DstHash = Get-FileHash -Path $check.FullName 40 | if ($DstHash.Hash -ne $SrcHash.Hash) { 41 | $FileMatch = $false 42 | } 43 | 44 | # If they don't all match, create a unique filename with the timestamp 45 | if ($FileMatch -eq $false) { 46 | $ts = (Get-Date).ToFileTimeUtc() 47 | $name = $file.BaseName + "_" + $ts + $file.Extension 48 | $DestinationFile = Join-Path -Path $Destination -ChildPath $name 49 | Write-Verbose "File will be renamed '$($name)'" 50 | } 51 | else { 52 | Write-Verbose "File will be overwritten" 53 | } 54 | } 55 | else { 56 | $FileMatch = $false 57 | } 58 | 59 | $moveParams = @{ 60 | Path = $file.FullName 61 | Destination = $DestinationFile 62 | } 63 | # If the two files matched, force an overwrite on the move 64 | if ($FileMatch -eq $true) { 65 | $moveParams.Add('Force', $true) 66 | } 67 | Move-Item @moveParams -PassThru 68 | } 69 | 70 | # Test that the file is found. If not, write to log and stop processing 71 | if (-not (Test-Path $FilePath)) { 72 | "$(Get-Date) : File not found" | Out-File $LogPath -Append 73 | break 74 | } 75 | 76 | # Get the file object 77 | $file = Get-Item $FilePath 78 | 79 | $Arguments = @{ 80 | File = $file 81 | Destination = $Destination 82 | } 83 | 84 | # Wrap the move command in a try/catch with an error action set to stop 85 | try { 86 | $moved = Move-ItemAdvanced @Arguments -ErrorAction Stop 87 | $message = "Moved '$($FilePath)' to '$($moved.FullName)'" 88 | } 89 | # Catch will only run if an error is returned from within the try block 90 | catch { 91 | # Create a custom message that includes the file path and the failure reason captured as $_ 92 | $message = "Error moving '$($FilePath)' : $($_)" 93 | } 94 | # write to the log file using the finally block 95 | finally { 96 | "$(Get-Date) : $message" | Out-File $LogPath -Append 97 | } -------------------------------------------------------------------------------- /Chapter03 - Scheduling automation scripts/Snippets.md: -------------------------------------------------------------------------------- 1 | # Snippet 1 - Scheduled Task arguments example 2 | ```powershell 3 | -File "C:\Scripts\Invoke-LogFileCleanup.ps1" -LogPath "L:\Logs\" -ZipPath "L:\Archives\" -ZipPrefix "LogArchive-" -NumberOfDays 30 4 | ``` 5 | 6 | # Snippet 2 - Set Scheduled Task arguments 7 | ```powershell 8 | $Argument = '-File ' + 9 | '"C:\Scripts\Invoke-LogFileCleanup.ps1"' + 10 | ' -LogPath "L:\Logs\" -ZipPath "L:\Archives\"' + 11 | ' -ZipPrefix "LogArchive-" -NumberOfDays 30' 12 | $Argument 13 | ``` 14 | ``` 15 | -File "C:\Scripts\Invoke-LogFileCleanup.ps1" -LogPath "L:\Logs\" -ZipPath "L:\Archives\" -ZipPrefix "LogArchive-" -NumberOfDays 30 16 | ``` 17 | 18 | # Snippet 3 - Export Scheduled Task 19 | ```powershell 20 | $ScheduledTask = @{ 21 | TaskName = "LogFileCleanup" 22 | TaskPath = "\PoSHAutomation\" 23 | } 24 | $export = Export-ScheduledTask @ScheduledTask 25 | $export | Out-File "\\srv01\PoSHAutomation\LogFileCleanup.xml" 26 | ``` 27 | 28 | # Snippet 4 - Run ps1 from Linux terminal 29 | ```shell 30 | /snap/powershell/160/opt/powershell/pwsh -File "/home/posh/Invoke-LogFileCleanup.ps1" -LogPath "/etc/poshtest/Logs" -ZipPath "/etc/poshtest/Logs/Archives" -ZipPrefix "LogArchive-" -NumberOfDays 30 31 | ``` 32 | 33 | # Snippet 5 - Open CronTab file as a different user 34 | ```shell 35 | crontab -u username -e 36 | ``` 37 | 38 | # Snippet 6 - Schedule script in Cron 39 | ```shell 40 | * 8 * * * /snap/powershell/160/opt/powershell/pwsh -File "/home/posh/Invoke-LogFileCleanup.ps1" -LogPath "/etc/poshtest/Logs" -ZipPath "/etc/poshtest/Logs/Archives" -ZipPrefix "LogArchive-" -NumberOfDays 30 41 | ``` 42 | 43 | # Snippet 7 - Subsitute parameters for Jenkins environment variables 44 | ```powershell 45 | $LogPath = $env:logpath 46 | $ZipPath = $env:zippath 47 | $ZipPrefix = $env:zipprefix 48 | $NumberOfDays = $env:numberofdays 49 | ``` 50 | 51 | # Snippet 8 - Stopwatch example 52 | ```powershell 53 | $Timer = [system.diagnostics.stopwatch]::StartNew() 54 | Start-Sleep -Seconds 3 55 | $Timer.Elapsed 56 | $Timer.Stop() 57 | ``` 58 | ``` 59 | Days : 0 60 | Hours : 0 61 | Minutes : 0 62 | Seconds : 2 63 | Milliseconds : 636 64 | Ticks : 26362390 65 | TotalDays : 3.0512025462963E-05 66 | TotalHours : 0.000732288611111111 67 | TotalMinutes : 0.0439373166666667 68 | TotalSeconds : 2.636239 69 | TotalMilliseconds : 2636.239 70 | ``` 71 | 72 | # Snippet 9 - Test Watch-Folder.ps1 execution times 73 | ```powershell 74 | $Argument = '-File ' + 75 | '"C:\Scripts\Invoke-LogFileCleanup.ps1"' + 76 | ' -LogPath "L:\Logs\" -ZipPath "L:\Archives\"' + 77 | ' -ZipPrefix "LogArchive-" -NumberOfDays 30' 78 | $jobParams = @{ 79 | FilePath = "C:\Program Files\PowerShell\7\pwsh.exe" 80 | ArgumentList = $Argument 81 | NoNewWindow = $true 82 | } 83 | Measure-Command -Expression { 84 | $job = Start-Process @jobParams -Wait} 85 | ``` 86 | ``` 87 | Days : 0 88 | Hours : 0 89 | Minutes : 0 90 | Seconds : 2 91 | Milliseconds : 17 92 | Ticks : 20173926 93 | TotalDays : 2.33494513888889E-05 94 | TotalHours : 0.000560386833333333 95 | TotalMinutes : 0.03362321 96 | TotalSeconds : 2.0173926 97 | TotalMilliseconds : 2017.3926 98 | ``` 99 | 100 | -------------------------------------------------------------------------------- /Chapter04 - Handling sensitive data/Listing 01 - Test SQL Connection information from SecretStore vault.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Test SQL Connection information from SecretStore vault 2 | # Retrieve credentials for the SQL server connection 3 | $Secret = @{ 4 | Name = 'TestSQLCredential' 5 | Vault = 'SQLHealthCheck' 6 | } 7 | $SqlCredential = Get-Secret @Secret 8 | # Retrieve the SQL server name and convert it to plain text 9 | $Secret = @{ 10 | Name = 'TestSQL' 11 | Vault = 'SQLHealthCheck' 12 | } 13 | $SQLServer = Get-Secret @Secret -AsPlainText 14 | 15 | # Execute a diagnostic query against SQL to test the connection information from the SecretStore vault 16 | $DbaDiagnosticQuery = @{ 17 | SqlInstance = $SQLServer 18 | SqlCredential = $SqlCredential 19 | QueryName = 'Database Properties' 20 | } 21 | Invoke-DbaDiagnosticQuery @DbaDiagnosticQuery -Verbose -------------------------------------------------------------------------------- /Chapter04 - Handling sensitive data/Listing 02 - Test SendGrid Connection information from KeePass vault.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Test SendGrid Connection information from KeePass vault 2 | # Get the email address for the "send from" in plain text 3 | $Secret = @{ 4 | Name = 'SendGrid' 5 | Vault = 'SmtpKeePass' 6 | } 7 | the “send from” in 8 | $From = Get-Secret @Secret -AsPlainText 9 | # Get the API key for SendGrid 10 | $Secret = @{ 11 | Name = 'SendGridKey' 12 | Vault = 'SmtpKeePass' 13 | } 14 | $EmailCredentials = Get-Secret @Secret 15 | 16 | # Send a test email with the SendGrid connection information from the KeePass vault 17 | $EmailMessage = @{ 18 | From = $From 19 | To = $From 20 | Credential = $EmailCredentials 21 | Body = 'This is a test of the SendGrid API' 22 | Priority = 'High' 23 | Subject = "Test SendGrid" 24 | SendGrid = $true 25 | } 26 | Send-EmailMessage @EmailMessage -------------------------------------------------------------------------------- /Chapter04 - Handling sensitive data/Listing 03 - SQL health check.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - SQL health check 2 | param( 3 | [string]$SQLVault, 4 | [string]$SQLInstance, 5 | [string]$SmtpVault, 6 | [string]$FromSecret, 7 | [string]$SendTo 8 | ) 9 | # Retrieve the credentials for the SQL server connection 10 | $Secret = @{ 11 | Name = "$($SQLInstance)Credential" 12 | Vault = $SQLVault 13 | } 14 | $SqlCredential = Get-Secret @Secret 15 | # Retrieve the SQL server name and convert it to plain text. 16 | $Secret = @{ 17 | Name = $SQLInstance 18 | Vault = $SQLVault 19 | } 20 | $SQLServer = Get-Secret @Secret -AsPlainText 21 | 22 | # Execute the Database Properties diagnostic query against SQL 23 | $DbaDiagnosticQuery = @{ 24 | SqlInstance = $SQLServer 25 | SqlCredential = $SqlCredential 26 | QueryName = 'Database Properties' 27 | } 28 | $HealthCheck = Invoke-DbaDiagnosticQuery @DbaDiagnosticQuery 29 | $failedCheck = $HealthCheck.Result | 30 | Where-Object { $_.'Recovery Model' -ne 'SIMPLE' } 31 | 32 | if ($failedCheck) { 33 | # Get the email address for the "send from" in plain text 34 | $Secret = @{ 35 | Name = $FromSecret 36 | Vault = $SmtpVault 37 | } 38 | $From = Get-Secret @Secret -AsPlainText 39 | # Get the API key for SendGrid 40 | $Secret = @{ 41 | Name = "$($FromSecret)Key" 42 | Vault = $SmtpVault 43 | } 44 | $EmailCredentials = Get-Secret @Secret 45 | 46 | # Create the email body by converting failed check results to an HTML table 47 | $Body = $failedCheck | ConvertTo-Html -As List | 48 | Out-String 49 | 50 | # Send a failure email notification 51 | $EmailMessage = @{ 52 | From = $From 53 | To = $SendTo 54 | Credential = $EmailCredentials 55 | Body = $Body 56 | Priority = 'High' 57 | Subject = "SQL Health Check Failed for $($SQLServer)" 58 | SendGrid = $true 59 | } 60 | Send-EmailMessage @EmailMessage 61 | } -------------------------------------------------------------------------------- /Chapter04 - Handling sensitive data/Listing 04 - SQL health check through Jenkins.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - SQL health check through Jenkins 2 | # Replace the Get-Secret call with Jenkins environment variables and recreate the credential object 3 | $secure = @{ 4 | String = $ENV:sqlpassword 5 | AsPlainText = $true 6 | Force = $true 7 | } 8 | $Password = ConvertTo-SecureString @secure 9 | $SqlCredential = New-Object System.Management.Automation.PSCredential ` 10 | ($ENV:sqlusername, $Password) 11 | 12 | # Replace the Get-Secret call with Jenkins environment variables 13 | $SQLServer = $ENV:sqlserver 14 | 15 | $DbaDiagnosticQuery = @{ 16 | SqlInstance = $SQLServer 17 | SqlCredential = $SqlCredential 18 | QueryName = 'DatabaseProperties' 19 | } 20 | $HealthCheck = Invoke-DbaDiagnosticQuery @DbaDiagnosticQuery 21 | $failedCheck = $HealthCheck.Result | 22 | Where-Object { $_.'Recovery Model' -ne 'SIMPLE' } 23 | 24 | if ($failedCheck) { 25 | # Replace the Get-Secret call with Jenkins environment variables 26 | $From = $ENV:sendgrid 27 | # Replace the Get-Secret call with Jenkins environment variables and recreate the credential object 28 | $secure = @{ 29 | String = $ENV:sendgridusername 30 | AsPlainText = $true 31 | Force = $true 32 | } 33 | $Password = ConvertTo-SecureString @secure 34 | $Credential = New-Object System.Management.Automation.PSCredential ` 35 | ($ENV:sendgridpassword, $Password) 36 | 37 | $Body = $failedCheck | ConvertTo-Html -As List | 38 | Out-String 39 | 40 | $EmailMessage = @{ 41 | From = $From 42 | To = $SendTo 43 | Credential = $EmailCredentials 44 | Body = $Body 45 | Priority = 'High' 46 | Subject = "SQL Health Check Failed for $($SQLServer)" 47 | SendGrid = $true 48 | } 49 | Send-EmailMessage @EmailMessage 50 | } -------------------------------------------------------------------------------- /Chapter04 - Handling sensitive data/Snippets.md: -------------------------------------------------------------------------------- 1 | # Snippet 1 - Create secure string with Read-Host 2 | ```powershell 3 | $SecureString = Read-Host -AsSecureString 4 | $SecureString 5 | ``` 6 | ``` 7 | System.Security.SecureString 8 | ``` 9 | 10 | # Snippet 2 - Create secure string from plain text string 11 | ```powershell 12 | $String = "password01" 13 | $SecureString = ConvertTo-SecureString $String -AsPlainText -Force 14 | $SecureString 15 | ``` 16 | ``` 17 | System.Security.SecureString 18 | ``` 19 | 20 | # Snippet 3 - Create credential with Get-Credential 21 | ```powershell 22 | $Credential = Get-Credential 23 | ``` 24 | 25 | # Snippet 4 - Create credential by combining two strings 26 | ```powershell 27 | $Username = 'Contoso\BGates' 28 | $Password = 'P@ssword' 29 | $SecureString = ConvertTo-SecureString $Password -AsPlainText -Force 30 | $Credential = New-Object System.Management.Automation.PSCredential $Username, $SecureString 31 | ``` 32 | 33 | # Snippet 5 - Create network credential 34 | ```powershell 35 | $Username = 'Contoso\BGates' 36 | $Password = ConvertTo-SecureString 'Password' -AsPlainText -Force 37 | $Credential = New-Object System.Management.Automation.PSCredential $Username, $Password 38 | $NetCred = $Credential.GetNetworkCredential() 39 | $NetCred 40 | ``` 41 | ``` 42 | UserName Domain 43 | -------- ------ 44 | BGates Contoso 45 | ``` 46 | 47 | # Snippet 6 - Install SecretManagement and SecretStore modules 48 | ```powershell 49 | Install-Module Microsoft.PowerShell.SecretStore 50 | Install-Module Microsoft.PowerShell.SecretManagement 51 | ``` 52 | 53 | # Snippet 7 - Setting up the SecretStore 54 | ```powershell 55 | Get-SecretStoreConfiguration 56 | ``` 57 | ``` 58 | Creating a new Microsoft.PowerShell.SecretStore vault. A password is required by the current store configuration. 59 | Enter password: 60 | ******** 61 | Enter password again for verification: 62 | ******** 63 | Scope Authentication PasswordTimeout Interaction 64 | ----- -------------- --------------- ----------- 65 | CurrentUser Password 900 Prompt 66 | ``` 67 | 68 | # Snippet 8 - Setting up the SecretStore to be non-interactive 69 | ```powershell 70 | Set-SecretStoreConfiguration -Authentication None -Interaction None 71 | ``` 72 | ``` 73 | Confirm 74 | Are you sure you want to perform this action? 75 | Performing the operation "Changes local store configuration" on target "SecretStore module local store". 76 | [Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "Y"): y 77 | A password is no longer required for the local store configuration. 78 | To complete the change please provide the current password. 79 | Enter password: 80 | ******** 81 | ``` 82 | 83 | # Snippet 9 - Registering the SQLHealthCheck SecretStore 84 | ```powershell 85 | Register-SecretVault -ModuleName Microsoft.PowerShell.SecretStore -Name SQLHealthCheck 86 | ``` 87 | 88 | # Snippet 10 - Install KeePass extension 89 | ```powershell 90 | Install-Module SecretManagement.KeePass 91 | ``` 92 | 93 | # Snippet 11 - Register the SmtpKeePass KeePass vault 94 | ```powershell 95 | $ModuleName = 'SecretManagement.KeePass' 96 | Register-SecretVault -Name 'SmtpKeePass' -ModuleName $ModuleName -VaultParameters @{ 97 | Path = " \\ITShare\Automation\SmtpKeePass.kdbx" 98 | UseMasterPassword = $false 99 | KeyPath= "C:\Users\svcacct\SmtpKeePass.keyx" 100 | } 101 | ``` 102 | 103 | # Snippet 12 - Set the SQL secrets in the SQLHealthCheck SecretStore 104 | ```powershell 105 | $SQLServer = "$($env:COMPUTERNAME)\SQLEXPRESS" 106 | Set-Secret -Name TestSQL -Secret $SQLServer -Vault SQLHealthCheck 107 | $Credential = Get-Credential 108 | Set-Secret -Name TestSQLCredential -Secret $Credential -Vault SQLHealthCheck 109 | ``` 110 | 111 | # Snippet 13 - Set the SendGrid secrets in the SmtpKeePass KeePass vault 112 | ```powershell 113 | $SmtpFrom = Read-Host -AsSecureString 114 | Set-Secret -Name SendGrid -Secret $SmtpFrom -Vault SmtpKeePass 115 | $Credential = Get-Credential 116 | Set-Secret -Name SendGridKey -Secret $Credential -Vault SmtpKeePass 117 | ``` -------------------------------------------------------------------------------- /Chapter05 - PowerShell remote execution/Listing 01 - Get-VSCodeExtensions.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Get-VSCodeExtensions.ps1 2 | [System.Collections.Generic.List[PSObject]] $extensions = @() 3 | # Set the home folder path based on the operating system 4 | if ($IsLinux) { 5 | $homePath = '/home/' 6 | } 7 | else { 8 | $homePath = "$($env:HOMEDRIVE)\Users" 9 | } 10 | 11 | # Get the subfolders under the home path 12 | $homeDirs = Get-ChildItem -Path $homePath -Directory 13 | 14 | # Parse through each folder and check for VS Code extensions 15 | foreach ($dir in $homeDirs) { 16 | $vscPath = Join-Path $dir.FullName '.vscode\extensions' 17 | # If the VS Code extension folder is present, search it for vsixmanifest files 18 | if (Test-Path -Path $vscPath) { 19 | $ChildItem = @{ 20 | Path = $vscPath 21 | Recurse = $true 22 | Filter = '.vsixmanifest' 23 | Force = $true 24 | } 25 | $manifests = Get-ChildItem @ChildItem 26 | foreach ($m in $manifests) { 27 | # Get the contents of the vsixmanifest file and convert it to a PowerShell XML object 28 | [xml]$vsix = Get-Content -Path $m.FullName 29 | # Get the details from the manifest and add them to the extensions list 30 | $vsix.PackageManifest.Metadata.Identity | 31 | Select-Object -Property Id, Version, Publisher, 32 | # Add the folder path, computer name, and date to the output 33 | @{l = 'Folder'; e = { $m.FullName } }, 34 | @{l = 'ComputerName'; e = {[system.environment]::MachineName}}, 35 | @{l = 'Date'; e = { Get-Date } } | 36 | ForEach-Object { $extensions.Add($_) } 37 | } 38 | } 39 | } 40 | # If no extensions are found, return a PowerShell object with the same properties stating nothing was found 41 | if ($extensions.Count -eq 0) { 42 | $extensions.Add([pscustomobject]@{ 43 | Id = 'No extension found' 44 | Version = $null 45 | Publisher = $null 46 | Folder = $null 47 | ComputerName = [system.environment]::MachineName 48 | Date = Get-Date 49 | }) 50 | } 51 | # Just like an extension, include the output at the end 52 | $extensions -------------------------------------------------------------------------------- /Chapter05 - PowerShell remote execution/Listing 02 - Execute local script against remote commputers using WSMan remoting.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Execute local script against remote commputers using WSMan remoting 2 | # Array of servers to connect to 3 | $servers = 'Svr01', 'Svr02', 'Svr03' 4 | # Path to save results to 5 | $CsvFile = 'P:\Scripts\VSCodeExtensions.csv' 6 | # The script file from listing 5.1 7 | $ScriptFile = 'P:\Scripts\Get-VSCodeExtensions.ps1' 8 | # Another CSV file to record connection errors 9 | $ConnectionErrors = "P:\Scripts\VSCodeErrors.csv" 10 | 11 | # Test whether the CSV file exists; if it does, exclude the servers already scanned 12 | if (Test-Path -Path $CsvFile) { 13 | $csvData = Import-Csv -Path $CsvFile | 14 | Select-Object -ExpandProperty PSComputerName -Unique 15 | $servers = $servers | Where-Object { $_ -notin $csvData } 16 | } 17 | 18 | [System.Collections.Generic.List[PSObject]] $Sessions = @() 19 | # Connect to each server and add the session to the $Sessions array list 20 | foreach ($s in $servers) { 21 | $PSSession = @{ 22 | ComputerName = $s 23 | } 24 | try { 25 | $session = New-PSSession @PSSession -ErrorAction Stop 26 | $Sessions.Add($session) 27 | } 28 | catch { 29 | # Add any errors to the connection error CSV file 30 | [pscustomobject]@{ 31 | ComputerName = $s 32 | Date = Get-Date 33 | ErrorMsg = $_ 34 | } | Export-Csv -Path $ConnectionErrors -Append 35 | } 36 | } 37 | 38 | # Execute the script on all remote sessions at once 39 | $Command = @{ 40 | Session = $Sessions 41 | FilePath = $ScriptFile 42 | } 43 | $Results = Invoke-Command @Command 44 | 45 | # Export the results to CSV 46 | $Results | Export-Csv -Path $CsvFile -Append 47 | 48 | # Close and remove the remote sessions 49 | Remove-PSSession -Session $Sessions -------------------------------------------------------------------------------- /Chapter05 - PowerShell remote execution/Listing 03 - Execute local script against remote computers using WSMan and SSH remoting.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Execute local script against remote computers using WSMan and SSH remoting 2 | # Added variable for the default ssh username to use 3 | $SshUser = 'posh' 4 | # Remaining variables are unchanged 5 | $servers = 'Svr01', 'Svr02', 'Svr03' 6 | $CsvFile = 'P:\Scripts\VSCodeExtensions.csv' 7 | $ScriptFile = 'P:\Scripts\Get-VSCodeExtensions.ps1' 8 | $ConnectionErrors = "P:\Scripts\VSCodeErrors.csv" 9 | 10 | if (Test-Path -Path $CsvFile) { 11 | $csvData = Import-Csv -Path $CsvFile | 12 | Select-Object -ExpandProperty PSComputerName -Unique 13 | $servers = $servers | Where-Object { $_ -notin $csvData } 14 | } 15 | 16 | [System.Collections.Generic.List[PSObject]] $Sessions = @() 17 | foreach ($s in $servers) { 18 | # Set the parameters for the Test-NetConnection calls 19 | $test = @{ 20 | ComputerName = $s 21 | InformationLevel = 'Quiet' 22 | WarningAction = 'SilentlyContinue' 23 | } 24 | try { 25 | # Create a hashtable for New-PSSession parameters 26 | $PSSession = @{ 27 | ErrorAction = 'Stop' 28 | } 29 | # If listening on the WSMan port 30 | if (Test-NetConnection @test -Port 5985) { 31 | $PSSession.Add('ComputerName', $s) 32 | } 33 | # If listening on the SSH port 34 | elseif (Test-NetConnection @test -Port 22) { 35 | $PSSession.Add('HostName', $s) 36 | $PSSession.Add('UserName', $SshUser) 37 | } 38 | # If neither, throw to the catch block 39 | else { 40 | throw "connection test failed" 41 | } 42 | # Create a remote session using the parameters set based on the results of the Test-NetConnection commands. 43 | $session = New-PSSession @PSSession 44 | $Sessions.Add($session) 45 | } 46 | catch { 47 | [pscustomobject]@{ 48 | ComputerName = $s 49 | Date = Get-Date 50 | ErrorMsg = $_ 51 | } | Export-Csv -Path $ConnectionErrors -Append 52 | } 53 | } 54 | 55 | # Remainder of the script is unchanged from listing 5.2 56 | $Command = @{ 57 | Session = $Sessions 58 | FilePath = $ScriptFile 59 | } 60 | $Results = Invoke-Command @Command 61 | 62 | $Results | Export-Csv -Path $CsvFile -Append 63 | 64 | Remove-PSSession -Session $Sessions -------------------------------------------------------------------------------- /Chapter05 - PowerShell remote execution/Listing 04 - Connect to all Virtual Machines from a Hyper-V Host.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Connect to all Virtual Machines from a Hyper-V Host 2 | # Prompt for credentials 3 | $Credential = Get-Credential 4 | # Path to save results to 5 | $CsvFile = 'P:\Scripts\VSCodeExtensions.csv' 6 | # The script file from listing 5.1 7 | $ScriptFile = 'P:\Scripts\Get-VSCodeExtensions.ps1' 8 | # Another CSV file to record connection errors 9 | $ConnectionErrors = "P:\Scripts\VSCodeErrors.csv" 10 | 11 | # Get all the virtual machines on the host 12 | $servers = Get-VM 13 | foreach ($VM in $servers) { 14 | $TurnOff = $false 15 | # Check whether the virtual machine is running 16 | if ($VM.State -ne 'Running') { 17 | try { 18 | # Start the virtual machine 19 | $VM | Start-VM -ErrorAction Stop 20 | } 21 | catch { 22 | [pscustomobject]@{ 23 | ComputerName = $s 24 | Date = Get-Date 25 | ErrorMsg = $_ 26 | } | Export-Csv -Path $ConnectionErrors -Append 27 | # If the start command fails, continue to the next virtual machine 28 | continue 29 | } 30 | $TurnOff = $true 31 | $timer = [system.diagnostics.stopwatch]::StartNew() 32 | # Wait for the heartbeat to equal a value that starts with OK, letting you know the OS has booted 33 | while ($VM.Heartbeat -notmatch '^OK') { 34 | if ($timer.Elapsed.TotalSeconds -gt 5) { 35 | # If the operating system does not boot, break the loop and continue to the connection 36 | break 37 | } 38 | } 39 | } 40 | 41 | # Set the parameters using the virtual machine Id 42 | $Command = @{ 43 | VMId = $Vm.Id 44 | FilePath = $ScriptFile 45 | Credential = $Credential 46 | ErrorAction = 'Stop' 47 | } 48 | try { 49 | # Execute the script on the virtual machine 50 | $Results = Invoke-Command @Command 51 | $Results | Export-Csv -Path $CsvFile -Append 52 | } 53 | catch { 54 | # If execution fails, record the error 55 | [pscustomobject]@{ 56 | ComputerName = $s 57 | Date = Get-Date 58 | ErrorMsg = $_ 59 | } | Export-Csv -Path $ConnectionErrors -Append 60 | } 61 | 62 | # If the virtual machine was not running to start with, turn it back off 63 | if ($TurnOff -eq $true) { 64 | $VM | Stop-VM 65 | } 66 | 67 | # There is no disconnect needed because you did not create a persistent connection 68 | } -------------------------------------------------------------------------------- /Chapter05 - PowerShell remote execution/Listing 05 - Updated find installed Visual Studio Code extensions to output results to network share.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Updated find installed Visual Studio Code extensions to output results to network share 2 | # Add a variable with the path to the network share. 3 | $CsvPath = '\\Srv01\IT\Automations\VSCode' 4 | [System.Collections.Generic.List[PSObject]] $extensions = @() 5 | if ($IsLinux) { 6 | $homePath = '/home/' 7 | } 8 | else { 9 | $homePath = "$($env:HOMEDRIVE)\Users" 10 | } 11 | 12 | $homeDirs = Get-ChildItem -Path $homePath -Directory 13 | 14 | foreach ($dir in $homeDirs) { 15 | $vscPath = Join-Path $dir.FullName '.vscode\extensions' 16 | if (Test-Path -Path $vscPath) { 17 | $ChildItem = @{ 18 | Path = $vscPath 19 | Recurse = $true 20 | Filter = '.vsixmanifest' 21 | Force = $true 22 | } 23 | $manifests = Get-ChildItem @ChildItem 24 | foreach ($m in $manifests) { 25 | [xml]$vsix = Get-Content -Path $m.FullName 26 | $vsix.PackageManifest.Metadata.Identity | 27 | Select-Object -Property Id, Version, Publisher, 28 | @{l = 'Folder'; e = { $m.FullName } }, 29 | @{l = 'ComputerName'; e = {[system.environment]::MachineName}}, 30 | @{l = 'Date'; e = { Get-Date } } | 31 | ForEach-Object { $extensions.Add($_) } 32 | } 33 | } 34 | } 35 | 36 | if ($extensions.Count -eq 0) { 37 | $extensions.Add([pscustomobject]@{ 38 | Id = 'No extension found' 39 | Version = $null 40 | Publisher = $null 41 | Folder = $null 42 | ComputerName = [system.environment]::MachineName 43 | Date = Get-Date 44 | }) 45 | } 46 | # Create a unique file name by combining the machine name with a randomly generate GUID 47 | $fileName = [system.environment]::MachineName + 48 | '-' + (New-Guid).ToString() + '.csv' 49 | # Combine the file name with the path of the network share 50 | $File = Join-Path -Path $CsvPath -ChildPath $fileName 51 | # Export the results to the CSV file 52 | $extensions | Export-Csv -Path $File -Append -------------------------------------------------------------------------------- /Chapter05 - PowerShell remote execution/Snippets.md: -------------------------------------------------------------------------------- 1 | # Snippet 1 - IsLinux and IsMacOS variables 2 | ```powershell 3 | if ($IsLinux) { 4 | # set Linux specific variables 5 | } 6 | elseif ($IsMacOS) { 7 | # set macOS specific variables 8 | } 9 | else { 10 | # set Windows specific variables 11 | } 12 | ``` 13 | 14 | # Snippet 2 - Enable PowerShell Remoting 15 | ```powershell 16 | Enable-PSRemoting -Force 17 | ``` 18 | # Snippet 3 - Add an account to the local administrators group 19 | ```powershell 20 | Add-LocalGroupMember -Group "Administrators" -Member "" 21 | ``` 22 | 23 | # Snippet 4 - Creating presistent connections with try/catch 24 | ```powershell 25 | $s = "localhost" 26 | try{ 27 | $session = New-PSSession -ComputerName $s -ErrorAction Stop 28 | $Sessions.Add($session) 29 | } 30 | catch{ 31 | Write-Host "$($s) failed to connect: $($_)" 32 | } 33 | ``` 34 | 35 | # Snippet 5 - Install OpenSSH on Windows 10 and Windows Server 2019 36 | ```powershell 37 | Get-WindowsCapability -Online | Where-Object{ $_.Name -like 'OpenSSH*' -and $_.State -ne 'Installed' } | ForEach-Object{ Add-WindowsCapability -Online -Name $_.Name } 38 | ``` 39 | 40 | # Snippet 6 - Set SSH services 41 | ```powershell 42 | Get-Service -Name sshd,ssh-agent | 43 | Set-Service -StartupType Automatic 44 | Start-Service sshd,ssh-agent 45 | ``` 46 | 47 | # Snippet 7 - Enabling Password and Key-based Authentication in sshd_config 48 | ``` 49 | PasswordAuthentication yes 50 | PubkeyAuthentication yes 51 | ``` 52 | 53 | # Snippet 8 - Add PowerShell subsystem to the sshd_config file 54 | ```powershell 55 | # Windows 56 | Subsystem powershell c:/progra~1/powershell/7/pwsh.exe -sshs -NoLogo 57 | 58 | # Linux with Snap 59 | Subsystem powershell /snap/powershell/160/opt/powershell/pwsh -sshs -NoLogo 60 | 61 | # Other Linux 62 | Subsystem powershell /opt/microsoft/powershell/7/pwsh -sshs -NoLogo 63 | Subsystem powershell /usr/bin/pwsh -sshs -NoLogo 64 | ``` 65 | 66 | # Snippet 9 - Generate SSH key pair and add to ssh-agent 67 | ``` 68 | ssh-keygen 69 | ssh-add "$($env:USERPROFILE)\.ssh\id_rsa" 70 | ``` 71 | 72 | # Snippet 10 - Copy the SSH public key to the remote Linux servers 73 | ``` 74 | type "$($env:USERPROFILE)\.ssh\id_rsa.pub" | ssh username@hostname "mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && chmod -R go= ~/.ssh && cat >> ~/.ssh/authorized_keys" 75 | ``` 76 | 77 | # Snippet 11 - Disabling Password in sshd_config 78 | ``` 79 | PasswordAuthentication no 80 | PubkeyAuthentication yes 81 | ``` 82 | 83 | # Snippet 12 - Test SSH connection 84 | ```powershell 85 | Invoke-Command -HostName 'remotemachine' -UserName 'user' -ScriptBlock{$psversiontable} 86 | ``` 87 | 88 | # Snippet 13 - Disable Password Authentication in the user config file manually 89 | ``` 90 | PasswordAuthentication no 91 | StrictHostKeyChecking yes 92 | ``` 93 | 94 | # Snippet 14 - Disable Password Authentication in the user config file with PowerShell 95 | ```powershell 96 | "PasswordAuthentication no\r\nStrictHostKeyChecking yes" | Out-File "$($env:USERPROFILE)/.ssh/config" 97 | ``` 98 | 99 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Helper Scripts/RegistryChecks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanManServer\\Parameters", 4 | "Tests": [ 5 | { 6 | "operator": "eq", 7 | "value": "1" 8 | } 9 | ], 10 | "Name": "EnableSecuritySignature" 11 | }, 12 | { 13 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\EventLog\\Security", 14 | "Tests": [ 15 | { 16 | "operator": "ge", 17 | "value": "32768" 18 | } 19 | ], 20 | "Name": "MaxSize" 21 | }, 22 | { 23 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanManServer\\Parameters", 24 | "Tests": [ 25 | { 26 | "operator": "in", 27 | "value": "1..15" 28 | } 29 | ], 30 | "Name": "AutoDisconnect" 31 | }, 32 | { 33 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanManServer\\Parameters", 34 | "Tests": [ 35 | { 36 | "operator": "eq", 37 | "value": "1" 38 | }, 39 | { 40 | "operator": "eq", 41 | "value": "$null" 42 | } 43 | ], 44 | "Name": "EnableForcedLogoff" 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Helper Scripts/SecurityBaseline.json: -------------------------------------------------------------------------------- 1 | { 2 | "Features": null, 3 | "SecurityBaseline": [ 4 | { 5 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanManServer\\Parameters", 6 | "Name": "EnableSecuritySignature", 7 | "Type": "DWORD", 8 | "Data": "1", 9 | "SetValue": "", 10 | "Tests": [ 11 | { 12 | "operator": "eq", 13 | "Value": "1" 14 | } 15 | ] 16 | }, 17 | { 18 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\EventLog\\Security", 19 | "Name": "MaxSize", 20 | "Type": "DWORD", 21 | "Data": "32768", 22 | "SetValue": "", 23 | "Tests": [ 24 | { 25 | "operator": "ge", 26 | "Value": "32768" 27 | } 28 | ] 29 | }, 30 | { 31 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanManServer\\Parameters", 32 | "Name": "AutoDisconnect", 33 | "Type": "DWORD", 34 | "Data": "1", 35 | "SetValue": "", 36 | "Tests": [ 37 | { 38 | "operator": "in", 39 | "Value": "1..15" 40 | } 41 | ] 42 | }, 43 | { 44 | "KeyPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanManServer\\Parameters", 45 | "Name": "EnableForcedLogoff", 46 | "Type": "DWORD", 47 | "Data": "1", 48 | "SetValue": "", 49 | "Tests": [ 50 | { 51 | "operator": "eq", 52 | "Value": "1" 53 | }, 54 | { 55 | "operator": "eq", 56 | "Value": "$null" 57 | } 58 | ] 59 | } 60 | ], 61 | "Services": "SPOOLER", 62 | "FirewallLogSize": 0 63 | } 64 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 01 - Creating the PoshAutomate-ServerConfig module.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Creating the PoshAutomate-ServerConfig module 2 | # This is the same function as in listing 2.5 3 | Function New-ModuleTemplate { 4 | [CmdletBinding()] 5 | [OutputType()] 6 | param( 7 | [Parameter(Mandatory = $true)] 8 | [string]$ModuleName, 9 | [Parameter(Mandatory = $true)] 10 | [string]$ModuleVersion, 11 | [Parameter(Mandatory = $true)] 12 | [string]$Author, 13 | [Parameter(Mandatory = $true)] 14 | [string]$PSVersion, 15 | [Parameter(Mandatory = $false)] 16 | [string[]]$Functions 17 | ) 18 | $ModulePath = Join-Path .\ "$($ModuleName)\$($ModuleVersion)" 19 | New-Item -Path $ModulePath -ItemType Directory 20 | Set-Location $ModulePath 21 | New-Item -Path .\Public -ItemType Directory 22 | 23 | $ManifestParameters = @{ 24 | ModuleVersion = $ModuleVersion 25 | Author = $Author 26 | Path = ".\$($ModuleName).psd1" 27 | RootModule = ".\$($ModuleName).psm1" 28 | PowerShellVersion = $PSVersion 29 | } 30 | New-ModuleManifest @ManifestParameters 31 | 32 | $File = @{ 33 | FilePath = ".\$($ModuleName).psm1" 34 | Encoding = 'utf8' 35 | } 36 | Out-File @File 37 | 38 | $Functions | ForEach-Object { 39 | Out-File -Path ".\Public\$($_).ps1" -Encoding utf8 40 | } 41 | } 42 | 43 | # Set the parameters to pass to the function 44 | $module = @{ 45 | # The name of your module 46 | ModuleName = 'PoshAutomate-ServerConfig' 47 | # The version of your module 48 | ModuleVersion = "1.0.0.0" 49 | # Your name 50 | Author = "YourNameHere" 51 | # The minimum PowerShell version this module supports 52 | PSVersion = '5.1' 53 | # The functions to create blank files for in the Public folder 54 | Functions = 'Disable-WindowsService', 55 | 'Install-RequiredFeatures', 'Set-FirewallDefaults', 56 | 'Set-SecurityBaseline', 'Set-ServerConfig', 57 | 'Test-SecurityBaseline' 58 | } 59 | # Execute the function to create the new module 60 | New-ModuleTemplate @module -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 02 - PoshAutomate-ServerConfig.psm1: -------------------------------------------------------------------------------- 1 | # Listing 2 - PoshAutomate-ServerConfig.psm1 2 | $Path = Join-Path $PSScriptRoot 'Public' 3 | # Get all the ps1 files in the Public folder 4 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 5 | 6 | # Loop through each ps1 file 7 | Foreach ($import in $Functions) { 8 | Try { 9 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 10 | # Execute each ps1 file to load the function into memory 11 | . $import.fullname 12 | } 13 | Catch { 14 | Write-Error -Message "Failed to import function $($import.name)" 15 | } 16 | } -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 03 - Disable-WindowsService.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Disable-WindowsService 2 | Function Disable-WindowsService { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string[]]$Services, 8 | [Parameter(Mandatory = $true)] 9 | [int]$HardKillSeconds, 10 | [Parameter(Mandatory = $true)] 11 | [int]$SecondsToWait 12 | ) 13 | 14 | [System.Collections.Generic.List[PSObject]] $ServiceStatus = @() 15 | foreach ($Name in $Services) { 16 | # Create a custom PowerShell object to track the status of each service 17 | $ServiceStatus.Add([pscustomobject]@{ 18 | Service = $Name 19 | HardKill = $false 20 | Status = $null 21 | Startup = $null 22 | }) 23 | try { 24 | # Attempt to find the service and then disable and stop it 25 | $Get = @{ 26 | Name = $Name 27 | ErrorAction = 'Stop' 28 | } 29 | $Service = Get-Service @Get 30 | $Set = @{ 31 | InputObject = $Service 32 | StartupType = 'Disabled' 33 | } 34 | Set-Service @Set 35 | $Stop = @{ 36 | InputObject = $Service 37 | Force = $true 38 | NoWait = $true 39 | ErrorAction = 'SilentlyContinue' 40 | } 41 | Stop-Service @Stop 42 | Get-Service -Name $Name | ForEach-Object { 43 | $ServiceStatus[-1].Status = $_.Status.ToString() 44 | $ServiceStatus[-1].Startup = $_.StartType.ToString() 45 | } 46 | } 47 | catch { 48 | $msg = 'NoServiceFoundForGivenName,Microsoft.PowerShell' + 49 | '.Commands.GetServiceCommand' 50 | if ($_.FullyQualifiedErrorId -eq $msg) { 51 | # If the service doesn't exist, there is nothing to stop, so consider that a success 52 | $ServiceStatus[-1].Status = 'Stopped' 53 | } 54 | else { 55 | Write-Error $_ 56 | } 57 | } 58 | } 59 | 60 | $timer = [system.diagnostics.stopwatch]::StartNew() 61 | # Monitor the stopping of each service 62 | do { 63 | $ServiceStatus | Where-Object { $_.Status -ne 'Stopped' } | 64 | ForEach-Object { 65 | $_.Status = (Get-Service $_.Service).Status.ToString() 66 | 67 | # If any services have not stopped in the predetermined amount of time, kill the process. 68 | if ($_.HardKill -eq $false -and 69 | $timer.Elapsed.TotalSeconds -gt $HardKillSeconds) { 70 | Write-Verbose "Attempting hard kill on $($_.Service)" 71 | $query = "SELECT * from Win32_Service WHERE name = '{0}'" 72 | $query = $query -f $_.Service 73 | $svcProcess = Get-CimInstance -Query $query 74 | $Process = @{ 75 | Id = $svcProcess.ProcessId 76 | Force = $true 77 | ErrorAction = 'SilentlyContinue' 78 | } 79 | Stop-Process @Process 80 | $_.HardKill = $true 81 | } 82 | } 83 | $Running = $ServiceStatus | Where-Object { $_.Status -ne 'Stopped' } 84 | } while ( $Running -and $timer.Elapsed.TotalSeconds -lt $SecondsToWait ) 85 | # Set the reboot required if any services did not stop 86 | $ServiceStatus | 87 | Where-Object { $_.Status -ne 'Stopped' } | 88 | ForEach-Object { $_.Status = 'Reboot Required' } 89 | 90 | # Return the results 91 | $ServiceStatus 92 | } -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 04 - Creating JSON.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Creating JSON 2 | [System.Collections.Generic.List[PSObject]] $JsonBuilder = @() 3 | # add an entry for each registry key to check 4 | $JsonBuilder.Add(@{ 5 | KeyPath = 6 | 'HKLM:\SYSTEM\CurrentControlSet\Services\LanManServer\Parameters' 7 | Name = 'EnableSecuritySignature' 8 | Tests = @( 9 | @{operator = 'eq'; value = '1' } 10 | ) 11 | }) 12 | $JsonBuilder.Add(@{ 13 | KeyPath = 14 | 'HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\Security' 15 | Name = 'MaxSize' 16 | Tests = @( 17 | @{operator = 'ge'; value = '32768' } 18 | ) 19 | }) 20 | $JsonBuilder.Add(@{ 21 | KeyPath = 22 | 'HKLM:\SYSTEM\CurrentControlSet\Services\LanManServer\Parameters' 23 | Name = 'AutoDisconnect' 24 | Tests = @( 25 | @{operator = 'in'; value = '1..15' } 26 | ) 27 | }) 28 | $JsonBuilder.Add(@{ 29 | KeyPath = 30 | 'HKLM:\SYSTEM\CurrentControlSet\Services\LanManServer\Parameters' 31 | Name = 'EnableForcedLogoff' 32 | Tests = @( 33 | @{operator = 'eq'; value = '1' } 34 | @{operator = 'eq'; value = '$null' } 35 | ) 36 | }) 37 | 38 | # Convert the PowerShell object to JSON and export it to a file 39 | $JsonBuilder | 40 | ConvertTo-Json -Depth 3 | 41 | Out-File .\RegistryChecks.json -Encoding UTF8 -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 05 - Add new data to JSON using PowerShell.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Add new data to JSON using PowerShell 2 | # Import the JSON file and convert it to a PowerShell object 3 | $checks = Get-Content .\RegistryChecks.json -Raw | 4 | ConvertFrom-Json 5 | 6 | # Use Select-Object to add new properties to the object 7 | $updated = $checks | 8 | Select-Object -Property *, @{l='Type';e={'DWORD'}}, 9 | @{l='Data';e={$_.Tests[0].Value}} 10 | 11 | # Convert the updated object with the new properties back to JSON and export it 12 | ConvertTo-Json -InputObject $updated -Depth 3 | 13 | Out-File -FilePath .\RegistryChecksAndResolves.json -Encoding utf8 14 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 06 - Registry Test Class.ps1: -------------------------------------------------------------------------------- 1 | # Listing 6 - Registry Test Class 2 | class RegistryTest { 3 | [string]$operator 4 | [string]$Value 5 | # Method to create a blank instance of this class 6 | RegistryTest(){ 7 | } 8 | # Method to create an instance of this class populated with data from a generic PowerShell object 9 | RegistryTest( 10 | [object]$object 11 | ){ 12 | $this.operator = $object.Operator 13 | $this.Value = $object.Value 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 07 - Registry Check Class.ps1: -------------------------------------------------------------------------------- 1 | # Listing 7 - Registry Check Class 2 | class RegistryCheck { 3 | [string]$KeyPath 4 | [string]$Name 5 | [string]$Type 6 | [string]$Data 7 | [string]$SetValue 8 | [Boolean]$Success 9 | [RegistryTest[]]$Tests 10 | # Method to create a blank instance of this class 11 | RegistryCheck(){ 12 | $this.Tests += [RegistryTest]::new() 13 | $this.Success = $false 14 | } 15 | # Method to create an instance of this class populated with data from a generic PowerShell object 16 | RegistryCheck( 17 | [object]$object 18 | ){ 19 | $this.KeyPath = $object.KeyPath 20 | $this.Name = $object.Name 21 | $this.Type = $object.Type 22 | $this.Data = $object.Data 23 | $this.Success = $false 24 | $this.SetValue = $object.SetValue 25 | 26 | $object.Tests | Foreach-Object { 27 | $this.Tests += [RegistryTest]::new($_) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 08 - Test-SecurityBaseline.ps1: -------------------------------------------------------------------------------- 1 | # Listing 8 - Test-SecurityBaseline 2 | Function Test-SecurityBaseline { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [RegistryCheck]$Check 8 | ) 9 | # Set the initial value of $Data to null 10 | $Data = $null 11 | if (-not (Test-Path -Path $Check.KeyPath)) { 12 | # If the key is not found, there is nothing to do because $Data is already set to null 13 | Write-Verbose "Path not found" 14 | } 15 | else { 16 | # Get the keys that exist in the key path and confirm that the key you want is present. 17 | $SubKeys = Get-Item -LiteralPath $Check.KeyPath 18 | if ($SubKeys.Property -notcontains $Check.Name) { 19 | # If the key is not found, there is nothing to do because $Data is already set to null. 20 | Write-Verbose "Name not found" 21 | } 22 | else { 23 | try { 24 | # If the key is found, get the value and update the $Data variable with the value. 25 | $ItemProperty = @{ 26 | Path = $Check.KeyPath 27 | Name = $Check.Name 28 | } 29 | $Data = Get-ItemProperty @ItemProperty | 30 | Select-Object -ExpandProperty $Check.Name 31 | } 32 | catch { 33 | $Data = $null 34 | } 35 | } 36 | } 37 | 38 | # Run through each test for this registry key. 39 | foreach ($test in $Check.Tests) { 40 | # Build the string to create the If statement to test the value of the $Data variable. 41 | $filter = 'if($Data -{0} {1}){{$true}}' 42 | $filter = $filter -f $test.operator, $test.Value 43 | Write-Verbose $filter 44 | if (Invoke-Expression $filter) { 45 | # If the statement returns true, you know a test passed, so update the Success property. 46 | $Check.Success = $true 47 | } 48 | } 49 | 50 | # Add the value of the key for your records and debugging 51 | $Check.SetValue = $Data 52 | $Check 53 | } 54 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 09 - Set-SecurityBaseline.ps1: -------------------------------------------------------------------------------- 1 | # Listing 9 - Set-SecurityBaseline 2 | Function Set-SecurityBaseline{ 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [RegistryCheck]$Check 8 | ) 9 | # Create the registry key path if it does not exist 10 | if(-not (Test-Path -Path $Check.KeyPath)){ 11 | New-Item -Path $Check.KeyPath -Force -ErrorAction Stop 12 | } 13 | 14 | # Create or Update the registry key with the predetermined value 15 | $ItemProperty = @{ 16 | Path = $Check.KeyPath 17 | Name = $Check.Name 18 | Value = $Check.Data 19 | PropertyType = $Check.Type 20 | Force = $true 21 | ErrorAction = 'Continue' 22 | } 23 | New-ItemProperty @ItemProperty 24 | } 25 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 10 - Install-RequiredFeatures.ps1: -------------------------------------------------------------------------------- 1 | # Listing 10 - Install-RequiredFeatures 2 | Function Install-RequiredFeatures { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string[]]$Features 8 | ) 9 | [System.Collections.Generic.List[PSObject]] $FeatureInstalls = @() 10 | # Loops through each feature and install it 11 | foreach ($Name in $Features) { 12 | Install-WindowsFeature -Name $Name -ErrorAction SilentlyContinue | 13 | Select-Object -Property @{l='Name';e={$Name}}, * | 14 | ForEach-Object{ $FeatureInstalls.Add($_) } 15 | } 16 | 17 | $FeatureInstalls 18 | } 19 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 11 - Set-FirewallDefaults.ps1: -------------------------------------------------------------------------------- 1 | # Listing 11 - Set-FirewallDefaults 2 | Function Set-FirewallDefaults { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [UInt64]$LogSize 8 | ) 9 | # Create a custom object to record and output the results of the commands. 10 | $FirewallSettings = [pscustomobject]@{ 11 | Enabled = $false 12 | PublicBlocked = $false 13 | LogFileSet = $false 14 | Errors = $null 15 | } 16 | 17 | try { 18 | # Enable all firewall profiles 19 | $NetFirewallProfile = @{ 20 | Profile = 'Domain', 'Public', 'Private' 21 | Enabled = 'True' 22 | ErrorAction = 'Stop' 23 | } 24 | Set-NetFirewallProfile @NetFirewallProfile 25 | $FirewallSettings.Enabled = $true 26 | 27 | # Block all inbound public traffic 28 | $NetFirewallProfile = @{ 29 | Name = 'Public' 30 | DefaultInboundAction = 'Block' 31 | ErrorAction = 'Stop' 32 | } 33 | Set-NetFirewallProfile @NetFirewallProfile 34 | $FirewallSettings.PublicBlocked = $true 35 | 36 | $log = '%windir%\system32\logfiles\firewall\pfirewall.log' 37 | # Set the firewall log settings, including the size. 38 | $NetFirewallProfile = @{ 39 | Name = 'Domain', 'Public', 'Private' 40 | LogFileName = $log 41 | LogBlocked = 'True' 42 | LogMaxSizeKilobytes = $LogSize 43 | ErrorAction = 'Stop' 44 | } 45 | Set-NetFirewallProfile @NetFirewallProfile 46 | $FirewallSettings.LogFileSet = $true 47 | } 48 | catch { 49 | $FirewallSettings.Errors = $_ 50 | } 51 | 52 | $FirewallSettings 53 | } 54 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 12 - Server Config Class.ps1: -------------------------------------------------------------------------------- 1 | # Listing 12 - Server Config Class 2 | class ServerConfig { 3 | [string[]]$Features 4 | [string[]]$Services 5 | [RegistryCheck[]]$SecurityBaseline 6 | [UInt64]$FirewallLogSize 7 | # Method to create a blank instance of this class 8 | ServerConfig(){ 9 | $this.SecurityBaseline += [RegistryCheck]::new() 10 | } 11 | # Method to create an instance of this class populated with data from a generic PowerShell object 12 | ServerConfig( 13 | [object]$object 14 | ){ 15 | $this.Features = $object.Features 16 | $this.Services = $object.Services 17 | $this.FirewallLogSize = $object.FirewallLogSize 18 | $object.SecurityBaseline | Foreach-Object { 19 | $this.SecurityBaseline += [RegistryCheck]::new($_) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 13 - New-ServerConfig.ps1: -------------------------------------------------------------------------------- 1 | # Listing 13 - New-ServerConfig 2 | Function New-ServerConfig{ 3 | [ServerConfig]::new() 4 | } 5 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 14 - Set-ServerConfig.ps1: -------------------------------------------------------------------------------- 1 | # Listing 14 - Set-ServerConfig 2 | Function Set-ServerConfig { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [object]$ConfigJson, 8 | [Parameter(Mandatory = $true)] 9 | [object]$LogFile 10 | ) 11 | # Import the configuration data from the JSON file 12 | $JsonObject = Get-Content $ConfigJson -Raw | 13 | ConvertFrom-Json 14 | # Convert the JSON data to the class you defined 15 | $Config = [ServerConfig]::new($JsonObject) 16 | 17 | # A small function to ensure consistent logs are written for an activity starting 18 | Function Write-StartLog { 19 | param( 20 | $Message 21 | ) 22 | "`n$('#' * 50)`n# $($Message)`n" | Out-File $LogFile -Append 23 | Write-Host $Message 24 | } 25 | 26 | # A small function to ensure consistent logs are written for an activity completing 27 | Function Write-OutputLog { 28 | param( 29 | $Object 30 | ) 31 | $output = $Object | Format-Table | Out-String 32 | if ([string]::IsNullOrEmpty($output)) { 33 | $output = 'No data' 34 | } 35 | "$($output.Trim())`n$('#' * 50)" | Out-File $LogFile -Append 36 | Write-Host $output 37 | } 38 | $msg = "Start Server Setup - $(Get-Date)`nFrom JSON $($ConfigJson)" 39 | Write-StartLog -Message $msg 40 | 41 | # Set Windows Features first 42 | Write-StartLog -Message "Set Features" 43 | $Features = Install-RequiredFeatures -Features $Config.Features 44 | Write-OutputLog -Object $Features 45 | 46 | # Set the services 47 | Write-StartLog -Message "Set Services" 48 | $WindowsService = @{ 49 | Services = $Config.Services 50 | HardKillSeconds = 60 51 | SecondsToWait = 90 52 | } 53 | $Services = Disable-WindowsService @WindowsService 54 | Write-OutputLog -Object $Services 55 | 56 | Write-StartLog -Message "Set Security Baseline" 57 | # Check each registry key in the Security baseline 58 | foreach ($sbl in $Config.SecurityBaseline) { 59 | $sbl = Test-SecurityBaseline $sbl 60 | } 61 | 62 | # Fix any that did not pass the test 63 | foreach ($sbl in $Config.SecurityBaseline | 64 | Where-Object { $_.Success -ne $true }) { 65 | Set-SecurityBaseline $sbl 66 | $sbl = Test-SecurityBaseline $sbl 67 | } 68 | $SecLog = $SecBaseline | 69 | Select-Object -Property KeyPath, Name, Data, Result, SetValue 70 | Write-OutputLog -Object $SecLog 71 | 72 | # Set the firewall 73 | Write-StartLog -Message "Set Firewall" 74 | $Firewall = Set-FirewallDefaults -LogSize $Config.FirewallLogSize 75 | Write-OutputLog -Object $Firewall 76 | 77 | Write-Host "Server configuration is complete." 78 | Write-Host "All logs written to $($LogFile)" 79 | } 80 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 15 - Create Server Config JSON.ps1: -------------------------------------------------------------------------------- 1 | # Listing 15 - Create Server Config JSON 2 | # Import the module 3 | Import-Module .\PoshAutomate-ServerConfig.psd1 -Force 4 | 5 | # Create a blank configuration item 6 | $Config = New-ServerConfig 7 | 8 | # Import security baseline registry keys 9 | $Content = @{ 10 | Path = '.\RegistryChecksAndResolves.json' 11 | Raw = $true 12 | } 13 | $Data = (Get-Content @Content | ConvertFrom-Json) 14 | $Config.SecurityBaseline = $Data 15 | 16 | # Set the default firewall log size 17 | $Config.FirewallLogSize = 4096 18 | 19 | # Set roles and features to install 20 | $Config.Features = @( 21 | "RSAT-AD-PowerShell" 22 | "RSAT-AD-AdminCenter" 23 | "RSAT-ADDS-Toolsf" 24 | ) 25 | 26 | # Set services to disable 27 | $Config.Services = @( 28 | "PrintNotify", 29 | "Spooler", 30 | "lltdsvc", 31 | "SharedAccess", 32 | "wisvc" 33 | ) 34 | 35 | # Create the Configurations folder 36 | if(-not (Test-Path ".\Configurations")){ 37 | New-Item -Path ".\Configurations" -ItemType Directory 38 | } 39 | 40 | # Export the security baseline 41 | $Config | ConvertTo-Json -Depth 4 | 42 | Out-File ".\Configurations\SecurityBaseline.json" -Encoding UTF8 -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Listing 16 - Invoke-ServerConfig.ps1: -------------------------------------------------------------------------------- 1 | # Listing 16 - Invoke-ServerConfig 2 | Function Invoke-ServerConfig{ 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [string[]]$Config = $null 7 | ) 8 | [System.Collections.Generic.List[PSObject]]$selection = @() 9 | # Get the Configurations folder 10 | $Path = @{ 11 | Path = $PSScriptRoot 12 | ChildPath = 'Configurations' 13 | } 14 | $ConfigPath = Join-Path @Path 15 | 16 | # Get all the Json files in the Configurations folder 17 | $ChildItem = @{ 18 | Path = $ConfigPath 19 | Filter = '*.JSON' 20 | } 21 | $Configurations = Get-ChildItem @ChildItem 22 | 23 | # If a config name is passed, attempt to find the file 24 | if(-not [string]::IsNullOrEmpty($Config)){ 25 | foreach($c in $Config){ 26 | $Configurations | Where-Object{ $_.BaseName -eq $Config } | 27 | ForEach-Object { $selection.Add($_) } 28 | } 29 | } 30 | 31 | # If a config name is not passed or a name is not found, prompt for a file to use 32 | if($selection.Count -eq 0){ 33 | $Configurations | Select-Object BaseName, FullName | 34 | Out-GridView -PassThru | ForEach-Object { $selection.Add($_) } 35 | } 36 | 37 | # Set the default log file path 38 | $Log = "$($env:COMPUTERNAME)-Config.log" 39 | $LogFile = Join-Path -Path $($env:SystemDrive) -ChildPath $Log 40 | 41 | # Run the Set-ServerConfig for each json file 42 | foreach($json in $selection){ 43 | Set-ServerConfig -ConfigJson $json.FullName -LogFile $LogFile 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Chapter06 - Making adaptable automations/Snippets.md: -------------------------------------------------------------------------------- 1 | # Snippet 1 - Disable and Stop service one-liner 2 | ```powershell 3 | Get-Service -Name Spooler | 4 | Set-Service -StartupType Disabled -PassThru | 5 | Stop-Service -PassThru 6 | ``` 7 | 8 | # Snippet 2 - Get service with try/catch to capture errors 9 | ```powershell 10 | try{ 11 | Get-Service -Name xyz -ErrorAction Stop 12 | } 13 | catch{ 14 | $_ 15 | } 16 | ``` 17 | 18 | # Snippet 3 - Get service with try/catch to capture errors and test for certain conditions based on the error message 19 | ```powershell 20 | $Name = 'xyz' 21 | try{ 22 | $Service = Get-Service -Name $Name -ErrorAction Stop 23 | } 24 | catch{ 25 | if($_.FullyQualifiedErrorId -ne 'NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand'){ 26 | Write-Error $_ 27 | } 28 | } 29 | ``` 30 | 31 | # Snippet 4 - Get service with try/catch to capture errors and terminate if the service is not found 32 | ```powershell 33 | $Name = 'xyz' 34 | try{ 35 | $Service = Get-Service -Name $Name -ErrorAction Stop 36 | } 37 | catch{ 38 | if($_.FullyQualifiedErrorId -ne 'NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand'){ 39 | throw $_ 40 | } 41 | } 42 | ``` 43 | 44 | # Snippet 5 - Registry Check Hash table 45 | ```powershell 46 | @{ 47 | KeyPath = 'HKLM:\SYSTEM\Path\Example' 48 | Name = 'SecurityKey' 49 | Tests = @( 50 | @{operator = 'eq'; value = '1' } 51 | @{operator = 'eq'; value = $null } 52 | ) 53 | } 54 | ``` 55 | 56 | # Snippet 6 - Test for true example 57 | ```powershell 58 | if($Data -eq 1){ 59 | $true 60 | } 61 | ``` 62 | 63 | # Snippet 7 - Test for true example converted to a string with replacable value and operator 64 | ```powershell 65 | 'if($Data -{0} {1}){{$true}}' -f 'eq', 1 66 | ``` 67 | 68 | # Snippet 8 - Creating and executing the test for true string 69 | ```powershell 70 | $Data = 3 71 | $Operator = 'in' 72 | $Expected = '1..15' 73 | $cmd = 'if($Data -{0} {1}){{$true}}' -f $Operator, $Expected 74 | Invoke-Expression $cmd 75 | ``` 76 | 77 | # Snippet 9 - Import PoshAutomate-ServerConfig Module and create blank config object 78 | ```powershell 79 | Import-Module .\PoshAutomate-ServerConfig.psd1 -Force 80 | New-ServerConfig | ConvertTo-Json -Depth 4 81 | ``` 82 | ``` 83 | { 84 | "Features": null, 85 | "Service": null, 86 | "SecurityBaseline": [ 87 | { 88 | "KeyPath": null, 89 | "Name": null, 90 | "Type": null, 91 | "Data": null, 92 | "SetValue": null, 93 | "Tests": [ 94 | { 95 | "operator": null, 96 | "Value": null 97 | } 98 | ] 99 | } 100 | ], 101 | "FirewallLogSize": 0 102 | } 103 | ``` 104 | 105 | # Snippet 10 - Import and execute the PoshAutomate-ServerConfig module to set up a new server 106 | ```powershell 107 | Import-Module .\PoshAutomate-ServerConfig.psd1 -Force 108 | Invoke-ServerConfig 109 | ``` 110 | 111 | # Snippet 11 - Properly using Add Years to create date 112 | ```powershell 113 | $AddYears = 1 114 | $Data = Get-Date 1/21/2035 115 | $DateFromConfig = (Get-Date).AddYears($AddYears) 116 | $cmd = 'if($Data -{0} {1}){{$true}}' -f 'gt', '$DateFromConfig' 117 | Invoke-Expression $cmd 118 | ``` 119 | 120 | # Snippet 12 - Do not include commands in your configurations 121 | ```powershell 122 | $Data = Get-Date 1/21/2035 123 | $cmd = 'if($Data -{0} {1}){{$true}}' -f 'gt', '(Get-Date).AddYears(1)' 124 | Invoke-Expression $cmd 125 | ``` 126 | 127 | -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Helper Scripts/SampleData.csv: -------------------------------------------------------------------------------- 1 | "Name","OSType","OSVersion","Status","RemoteMethod","UUID","Source","SourceInstance" 2 | "Vm01","Linux","Ubuntu Linux (64-bit)","Active","PowerCLI","VirtualMachine-vm-1234","VMware","Cluster1" 3 | "Vm02","Windows","Microsoft Windows Server 2016","Active","PowerCLI","VirtualMachine-vm-2345","VMware","Cluster1" 4 | "Vm03","Linux","Ubuntu Linux (64-bit)","Active","PowerCLI","VirtualMachine-vm-3456","VMware","Cluster1" 5 | "Vm04","Linux","Ubuntu Linux (64-bit)","Active","PowerCLI","VirtualMachine-vm-4567","VMware","Cluster1" 6 | "Vm05","Windows","Microsoft Windows Server 2012","Active","PowerCLI","VirtualMachine-vm-5678","VMware","Cluster2" 7 | "Vm06","Linux","Ubuntu Linux (64-bit)","Active","PowerCLI","VirtualMachine-vm-6789","VMware","Cluster2" -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Helper Scripts/Sync-vSphereVMs.ps1: -------------------------------------------------------------------------------- 1 | # Set vSphere server name 2 | $vSphere = 'YourServer' 3 | # Set vSphere credentials 4 | $Credential = Get-Credential 5 | 6 | # Import PowerCLI and PoshAssetMgmt modules 7 | Import-Module -Name VMware.PowerCLI, PoshAssetMgmt 8 | 9 | # Connect to vSphere 10 | Connect-VIServer -Server $vSphere -Credential $Credential -Force | Out-Null 11 | 12 | Get-VM | ForEach-Object { 13 | # Get the values for all items and mapped to the parameters for the Set-PoshServer and New-PoshServer functions. 14 | $values = @{ 15 | Name = $_.Name 16 | OSType = (Get-OSType $_.ExtensionData.Config.GuestFullname) 17 | OSVersion = $_.ExtensionData.Config.GuestFullname 18 | Status = 'Active' 19 | RemoteMethod = 'PowerCLI' 20 | UUID = $_.Id 21 | Source = 'VMware' 22 | SourceInstance = (Get-Cluster -VM $vm).Name 23 | } 24 | 25 | # Run the Get-PoshServer to see if a record exists with a matching UUID 26 | $record = Get-PoshServer -UUID $_.UUID 27 | 28 | # If record exists update it otherwise add a new record 29 | if($record){ 30 | # Remove any blank or null values from the hashtable to keep from erasing existing values 31 | ($values.GetEnumerator() | Where-Object { [string]::IsNullOrEmpty($_.Value) }) | 32 | ForEach-Object { $values.Remove($_.Name) } 33 | 34 | # Update the existing object 35 | $record | Set-PoshServer @values 36 | } 37 | else{ 38 | # Create a new entry in the database 39 | New-PoshServer @values 40 | } 41 | } 42 | 43 | # Disconnect from vSphere 44 | Disconnect-VIServer -Server $vSphere -Force -Confirm:$false -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 01 - Create PoshAssetMgmt database.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Create PoshAssetMgmt database 2 | $SqlInstance = "$($env:COMPUTERNAME)\SQLEXPRESS" 3 | $DatabaseName = 'PoshAssetMgmt' 4 | $DbaDatabase = @{ 5 | SqlInstance = $SqlInstance 6 | Name = $DatabaseName 7 | RecoveryModel = 'Simple' 8 | } 9 | New-DbaDatabase @DbaDatabase -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 02 - Create Servers table in SQL.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Create Servers table in SQL 2 | $SqlInstance = "$($env:COMPUTERNAME)\SQLEXPRESS" 3 | $DatabaseName = 'PoshAssetMgmt' 4 | $ServersTable = 'Servers' 5 | $ServersColumns = @( 6 | # Create the ID column as an identity column 7 | @{Name = 'ID'; 8 | Type = 'int'; MaxLength = $null; 9 | Nullable = $false; Identity = $true; 10 | } 11 | # Create the Name column as a string with a max length of 50 characters 12 | @{Name = 'Name'; 13 | Type = 'nvarchar'; MaxLength = 50; 14 | Nullable = $false; Identity = $false; 15 | } 16 | # Create the OSType column as a string with a max length of 15 characters 17 | @{Name = 'OSType'; 18 | Type = 'nvarchar'; MaxLength = 15; 19 | Nullable = $false; Identity = $false; 20 | } 21 | # Create the OSVersion column as a string with a max length of 50 characters 22 | @{Name = 'OSVersion'; 23 | Type = 'nvarchar'; MaxLength = 50; 24 | Nullable = $false; Identity = $false; 25 | } 26 | # Create the a Status column as a string with a max length of 15 characters 27 | @{Name = 'Status'; 28 | Type = 'nvarchar'; MaxLength = 15; 29 | Nullable = $false; Identity = $false; 30 | } 31 | # Create the RemoteMethod column as a string with a max length of 25 characters 32 | @{Name = 'RemoteMethod'; 33 | Type = 'nvarchar'; MaxLength = 25; 34 | Nullable = $false; Identity = $false; 35 | } 36 | # Create the UUID column as a string with a max length of 255 characters 37 | @{Name = 'UUID'; 38 | Type = 'nvarchar'; MaxLength = 255; 39 | Nullable = $false; Identity = $false; 40 | } 41 | # Create the Source column as a string with a max length of 15 characters 42 | @{Name = 'Source'; 43 | Type = 'nvarchar'; MaxLength = 15; 44 | Nullable = $false; Identity = $false; 45 | } 46 | # Create the SourceInstance column as a string with a max length of 255 characters 47 | @{Name = 'SourceInstance'; 48 | Type = 'nvarchar'; MaxLength = 255; 49 | Nullable = $false; Identity = $false; 50 | } 51 | ) 52 | $DbaDbTable = @{ 53 | SqlInstance = $SqlInstance 54 | Database = $DatabaseName 55 | Name = $ServersTable 56 | ColumnMap = $ServersColumns 57 | } 58 | New-DbaDbTable @DbaDbTable -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 03 - Creating the PoshAssetMgmt module.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Creating the PoshAssetMgmt module 2 | # This is the same function as in listing 2.5 3 | Function New-ModuleTemplate { 4 | [CmdletBinding()] 5 | [OutputType()] 6 | param( 7 | [Parameter(Mandatory = $true)] 8 | [string]$ModuleName, 9 | [Parameter(Mandatory = $true)] 10 | [string]$ModuleVersion, 11 | [Parameter(Mandatory = $true)] 12 | [string]$Author, 13 | [Parameter(Mandatory = $true)] 14 | [string]$PSVersion, 15 | [Parameter(Mandatory = $false)] 16 | [string[]]$Functions 17 | ) 18 | $ModulePath = Join-Path .\ "$($ModuleName)\$($ModuleVersion)" 19 | New-Item -Path $ModulePath -ItemType Directory 20 | Set-Location $ModulePath 21 | New-Item -Path .\Public -ItemType Directory 22 | 23 | $ManifestParameters = @{ 24 | ModuleVersion = $ModuleVersion 25 | Author = $Author 26 | Path = ".\$($ModuleName).psd1" 27 | RootModule = ".\$($ModuleName).psm1" 28 | PowerShellVersion = $PSVersion 29 | } 30 | New-ModuleManifest @ManifestParameters 31 | 32 | $File = @{ 33 | Path = ".\$($ModuleName).psm1" 34 | Encoding = 'utf8' 35 | } 36 | Out-File @File 37 | 38 | $Functions | ForEach-Object { 39 | Out-File -Path ".\Public\$($_).ps1" -Encoding utf8 40 | } 41 | } 42 | 43 | # Set the parameters to pass to the function 44 | $module = @{ 45 | # The name of your module 46 | ModuleName = 'PoshAssetMgmt' 47 | # The version of your module 48 | ModuleVersion = "1.0.0.0" 49 | # Your name 50 | Author = "YourNameHere" 51 | # The minimum PowerShell version this module supports 52 | PSVersion = '7.1' 53 | # The functions to create blank files for in the Public folder 54 | Functions = 'Connect-PoshAssetMgmt', 55 | 'New-PoshServer', 'Get-PoshServer', 'Set-PoshServer' 56 | } 57 | # Execute the function to create the new module 58 | New-ModuleTemplate @module -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 04 - PoshAutomate-AssetMgmt.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - PoshAutomate-AssetMgmt 2 | $_PoshAssetMgmt = [pscustomobject]@{ 3 | # Update SqlInstance to match your server name 4 | SqlInstance = 'YourSqlSrv\SQLEXPRESS' 5 | Database = 'PoshAssetMgmt' 6 | ServerTable = 'Servers' 7 | } 8 | 9 | $Path = Join-Path $PSScriptRoot 'Public' 10 | # Get all the ps1 files in the Public folder 11 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 12 | 13 | # Loop through each ps1 file 14 | Foreach ($import in $Functions) { 15 | Try { 16 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 17 | # Execute each ps1 file to load the function into memory 18 | . $import.fullname 19 | } 20 | Catch { 21 | Write-Error -Message "Failed to import function $($import.name)" 22 | } 23 | } 24 | 25 | [System.Collections.Generic.List[PSObject]]$RequiredModules = @() 26 | # Create an object for each module to check 27 | $RequiredModules.Add([pscustomobject]@{ 28 | Name = 'dbatools' 29 | Version = '1.1.5' 30 | }) 31 | 32 | # Check whether the module is installed on the local machine 33 | foreach($module in $RequiredModules){ 34 | $Check = Get-Module $module.Name -ListAvailable 35 | 36 | if(-not $check){ 37 | throw "Module $($module.Name) not found" 38 | } 39 | 40 | $VersionCheck = $Check | 41 | Where-Object{ $_.Version -ge $module.Version } 42 | 43 | if(-not $VersionCheck){ 44 | Write-Error "Module $($module.Name) running older version" 45 | } 46 | 47 | Import-Module -Name $module.Name 48 | } 49 | -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 05 - Connect-PoshAssetMgmt.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Connect-PoshAssetMgmt 2 | Function Connect-PoshAssetMgmt { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $false)] 7 | [string]$SqlInstance = $_PoshAssetMgmt.SqlInstance, 8 | 9 | [Parameter(Mandatory = $false)] 10 | [string]$Database = $_PoshAssetMgmt.Database, 11 | 12 | [Parameter(Mandatory = $false)] 13 | [PSCredential]$Credential 14 | ) 15 | 16 | # Set the default connection parameters 17 | $connection = @{ 18 | SqlInstance = $SqlInstance 19 | Database = $Database 20 | } 21 | 22 | # Add the credential object if passed 23 | if ($Credential) { 24 | $connection.Add('SqlCredential', $Credential) 25 | } 26 | 27 | $Script:_SqlInstance = Connect-DbaInstance @connection 28 | 29 | # Output the result so the person running it can confirm the connection information 30 | $Script:_SqlInstance 31 | } -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 06 - New-PoshServer.ps1: -------------------------------------------------------------------------------- 1 | # Listing 6 - New-PoshServer 2 | Function New-PoshServer { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | # Validate that the server name is less than or equal to 50 characters 7 | [Parameter(Mandatory = $true)] 8 | [ValidateScript( { $_.Length -le 50 })] 9 | [string]$Name, 10 | 11 | # Validate that the OSType is one of the predefined values 12 | [Parameter(Mandatory = $true)] 13 | [ValidateSet('Windows', 'Linux')] 14 | [string]$OSType, 15 | 16 | # Validate that the OSVersion is less than or equal to 50 characters 17 | [Parameter(Mandatory = $true)] 18 | [ValidateScript( { $_.Length -le 50 })] 19 | [string]$OSVersion, 20 | 21 | # Validate that the Status is one of the predefined values 22 | [Parameter(Mandatory = $true)] 23 | [ValidateSet('Active', 'Depot', 'Retired')] 24 | [string]$Status, 25 | 26 | # Validate that the RemoteMethod is one of the predefined values 27 | [Parameter(Mandatory = $true)] 28 | [ValidateSet('WSMan', 'SSH', 'PowerCLI', 'HyperV', 'AzureRemote')] 29 | [string]$RemoteMethod, 30 | 31 | # Validate that the UUID is less than or equal to 255 characters 32 | [Parameter(Mandatory = $false)] 33 | [ValidateScript( { $_.Length -le 255 })] 34 | [string]$UUID, 35 | 36 | # Validate that the Source is one of the predefined values. 37 | [Parameter(Mandatory = $true)] 38 | [ValidateSet('Physical', 'VMware', 'Hyper-V', 'Azure', 'AWS')] 39 | [string]$Source, 40 | 41 | # Validate that the SourceInstance is less than or equal to 255 characters 42 | [Parameter(Mandatory = $false)] 43 | [ValidateScript( { $_.Length -le 255 })] 44 | [string]$SourceInstance 45 | ) 46 | 47 | # Build the data mapping for the SQL columns 48 | $Data = [pscustomobject]@{ 49 | Name = $Name 50 | OSType = $OSType 51 | OSVersion = $OSVersion 52 | Status = $Status 53 | RemoteMethod = $RemoteMethod 54 | UUID = $UUID 55 | Source = $Source 56 | SourceInstance = $SourceInstance 57 | } 58 | 59 | # Write the data to the table 60 | $DbaDataTable = @{ 61 | SqlInstance = $_SqlInstance 62 | Database = $_PoshAssetMgmt.Database 63 | InputObject = $Data 64 | Table = $_PoshAssetMgmt.ServerTable 65 | } 66 | Write-DbaDataTable @DbaDataTable 67 | 68 | # Since Write-DbaDataTable doesn't have any output the data object, you know which ones were added 69 | Write-Output $Data 70 | } 71 | -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 07 - Get-PoshServer.ps1: -------------------------------------------------------------------------------- 1 | # Listing 7 - Get-PoshServer 2 | Function Get-PoshServer { 3 | [CmdletBinding()] 4 | [OutputType([object])] 5 | param( 6 | [Parameter(Mandatory = $false)] 7 | [int]$ID, 8 | 9 | [Parameter(Mandatory = $false)] 10 | [string]$Name, 11 | 12 | [Parameter(Mandatory = $false)] 13 | [string]$OSType, 14 | 15 | [Parameter(Mandatory = $false)] 16 | [string]$OSVersion, 17 | 18 | [Parameter(Mandatory = $false)] 19 | [string]$Status, 20 | 21 | [Parameter(Mandatory = $false)] 22 | [string]$RemoteMethod, 23 | 24 | [Parameter(Mandatory = $false)] 25 | [string]$UUID, 26 | 27 | [Parameter(Mandatory = $false)] 28 | [string]$Source, 29 | 30 | [Parameter(Mandatory = $false)] 31 | [string]$SourceInstance 32 | ) 33 | 34 | [System.Collections.Generic.List[string]] $where = @() 35 | $SqlParameter = @{} 36 | # Loop through each item in the $PSBoundParameters to create the where clause while filtering out common parameters 37 | $PSBoundParameters.GetEnumerator() | 38 | Where-Object { $_.Key -notin 39 | [System.Management.Automation.Cmdlet]::CommonParameters } | 40 | ForEach-Object { 41 | $where.Add("$($_.Key) = @$($_.Key)") 42 | $SqlParameter.Add($_.Key, $_.Value) 43 | } 44 | 45 | # Set the default query 46 | $Query = "SELECT * FROM " + 47 | $_PoshAssetMgmt.ServerTable 48 | 49 | # If the where clause is needed, add it to the query 50 | if ($where.Count -gt 0) { 51 | $Query += " Where " + ($where -join (' and ')) 52 | } 53 | 54 | Write-Verbose $Query 55 | 56 | $DbaQuery = @{ 57 | SqlInstance = $_SqlInstance 58 | Database = $_PoshAssetMgmt.Database 59 | Query = $Query 60 | SqlParameter = $SqlParameter 61 | } 62 | 63 | # Execute the query and output the results 64 | Invoke-DbaQuery @DbaQuery 65 | } 66 | -------------------------------------------------------------------------------- /Chapter07 - Working with SQL/Listing 09 - Sync from external CSV.ps1: -------------------------------------------------------------------------------- 1 | # Listing 9 - Sync from external CSV 2 | # Import the data from the CSV 3 | $ServerData = Import-Csv ".\SampleData.CSV" 4 | 5 | # Get all the VMs 6 | $ServerData | ForEach-Object { 7 | # Get the values for all items and map them to the parameters for the Set-PoshServer and New-PoshServer functions. 8 | $values = @{ 9 | Name = $_.Name 10 | OSType = $_.OSType 11 | OSVersion = $_.OSVersion 12 | Status = 'Active' 13 | RemoteMethod = 'PowerCLI' 14 | UUID = $_.UUID 15 | Source = 'VMware' 16 | SourceInstance = $_.SourceInstance 17 | } 18 | 19 | # Run the Get-PoshServer to see whether a record exists with a matching UUID 20 | $record = Get-PoshServer -UUID $_.UUID 21 | 22 | # If the record exists, update it; otherwise, add a new record 23 | if($record){ 24 | $record | Set-PoshServer @values 25 | } 26 | else{ 27 | New-PoshServer @values 28 | } 29 | } -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Helper Scripts/New-TestArchiveFile.ps1: -------------------------------------------------------------------------------- 1 | # Set directory to create logs in 2 | $Directory = "L:\Logs" 3 | $ZipPath = "L:\Archives\" 4 | 5 | # create the zip path if not found 6 | if(-not (Test-Path $ZipPath)){ 7 | New-Item -Path $ZipPath -ItemType Directory 8 | } 9 | 10 | # Create the zip file name and full path 11 | $ZipName = "LogArchive-$((Get-Date).ToFileTime()).zip" 12 | $ZipFile = Join-Path $ZipPath $ZipName 13 | 14 | # Set number of test files to create 15 | $days = 30 16 | 17 | # create the folder if not found 18 | if(-not (Test-Path $Directory)){ 19 | New-Item -Path $Directory -ItemType Directory 20 | } 21 | 22 | # this function creates randomly sized files 23 | Function Set-RandomFileSize { 24 | param( [string]$FilePath ) 25 | $size = Get-Random -Minimum 1 -Maximum 50 26 | $size = $size*1024*1024 27 | $file = [System.IO.File]::Open($FilePath, 4) 28 | $file.SetLength($Size) 29 | $file.Close() 30 | Get-Item $file.Name 31 | } 32 | 33 | [System.Collections.Generic.List[PSObject]] $FilesCreated = @() 34 | # loop to create a file for each day back 35 | for($i = 0; $i -lt $days; $i++) { 36 | # Get Date and create log file 37 | $Date = (Get-Date).AddDays(-$i) 38 | # create unique file name with the date in it 39 | $FileName = "u_ex$($date.ToString('yyyyMMdd')).log" 40 | # set the file path 41 | $FilePath = Join-Path -Path $Directory -ChildPath $FileName 42 | 43 | # write the date inside the file, will override existing files 44 | $Date | Out-File $FilePath 45 | # set a random file size 46 | Set-RandomFileSize -FilePath $FilePath 47 | 48 | # Set the Creation, Write, and Access time of log file to past date 49 | Get-Item $FilePath | ForEach-Object { 50 | $_.CreationTime = $date 51 | $_.LastWriteTime = $date 52 | $_.LastAccessTime = $date 53 | # Add the path to the array for zipping later 54 | $FilesCreated.Add($_) 55 | } 56 | } 57 | 58 | # Create archive will all the files 59 | $FilesCreated | Compress-Archive -DestinationPath $ZipFile 60 | 61 | # Remove the temp files 62 | $FilesCreated | Remove-Item -Force 63 | 64 | $ZipFile -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Helper Scripts/Output-Examples.ps1: -------------------------------------------------------------------------------- 1 | # These will do nothing 2 | Write-Host "Write-Host does not work in Azure Automation runbooks" 3 | 4 | Write-Progress -Activity "Running" -Status "Write-Progress does not work" -PercentComplete 50 5 | 6 | # These will work in Azure Automation 7 | Write-Output "Write-Output shows in the All Logs and Output tabs" 8 | 9 | Write-Verbose "Write-Verbose only shows in All Logs when it is turned on" 10 | 11 | Write-Warning "Write-Warning shows in the All Logs and Warnings tabs" 12 | 13 | Write-Error "Write-Error does shows in the All Logs and Errors tabs" 14 | 15 | "Writing directly to the stream works, but can be unreliable. It is best to use Write-Output" 16 | 17 | throw "Any terminating error will write to the Exception tab" -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Helper Scripts/Upload-ZipToBlob.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $true)] 3 | [string]$FolderPath, 4 | [Parameter(Mandatory = $true)] 5 | [string]$Container 6 | ) 7 | 8 | # Connect to Azure 9 | Connect-AzAccount -Identity 10 | 11 | # Get the values from the automation variables 12 | $ResourceGroupName = Get-AutomationVariable -Name 'ZipStorage_ResourceGroup' 13 | $StorageAccountName = Get-AutomationVariable -Name 'ZipStorage_AccountName' 14 | 15 | # Get all the ZIP files in the folder 16 | $ZipFiles = Get-ChildItem -Path $FolderPath -Filter '*.zip' 17 | 18 | # Get the storage keys and create a context object that will be used to authenticate with the storage account 19 | $Keys = Get-AzStorageAccountKey -ResourceGroupName $ResourceGroupName -Name $StorageAccountName 20 | $Context = New-AzStorageContext -StorageAccountName $StorageAccountName -StorageAccountKey $Keys[0].Value 21 | 22 | # Check to see if the container exsists. If it does not create it. 23 | $containerCheck = Get-AzStorageContainer -Name $Container -Context $Context -ErrorAction SilentlyContinue 24 | if(-not $containerCheck){ 25 | New-AzStorageContainer -Name $Container -Context $Context -ErrorAction Stop | Out-Null 26 | } 27 | 28 | foreach($file in $ZipFiles){ 29 | # Check if the file already exists in the container. If not upload it, then delete it from the local server. 30 | $blobCheck = Get-AzStorageBlob -Container $container -Blob $file.Name -Context $Context -ErrorAction SilentlyContinue 31 | if (-not $blobCheck) { 32 | # Upload the file to the Azure storage 33 | Set-AzStorageBlobContent -File $file.FullName -Container $Container -Blob $file.Name -Context $Context -Force -ErrorAction Stop 34 | Remove-Item -Path $file.FullName -Force 35 | } 36 | } -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Listing 01 - Install Microsoft Monitoring Agent.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Install Microsoft Monitoring Agent 2 | # Set the parameters for your workspace 3 | $WorkspaceID = 'YourId' 4 | $WorkSpaceKey = 'YourKey' 5 | 6 | # URL for the agent installer 7 | $agentURL = 'https://download.microsoft.com/download' + 8 | '/3/c/d/3cd6f5b3-3fbe-43c0-88e0-8256d02db5b7/MMASetup-AMD64.exe' 9 | 10 | # Download the agent 11 | $FileName = Split-Path $agentURL -Leaf 12 | $MMAFile = Join-Path -Path $env:Temp -ChildPath $FileName 13 | Invoke-WebRequest -Uri $agentURL -OutFile $MMAFile | Out-Null 14 | 15 | # Install the agent 16 | $ArgumentList = '/C:"setup.exe /qn ' + 17 | 'ADD_OPINSIGHTS_WORKSPACE=0 ' + 18 | 'AcceptEndUserLicenseAgreement=1"' 19 | $Install = @{ 20 | FilePath = $MMAFile 21 | ArgumentList = $ArgumentList 22 | ErrorAction = 'Stop' 23 | } 24 | Start-Process @Install -Wait | Out-Null 25 | 26 | # Load the agent config com object 27 | $Object = @{ 28 | ComObject = 'AgentConfigManager.MgmtSvcCfg' 29 | } 30 | $AgentCfg = New-Object @Object 31 | 32 | # Set the workspace ID and key 33 | $AgentCfg.AddCloudWorkspace($WorkspaceID, 34 | $WorkspaceKey) 35 | 36 | # Restart the agent for the changes to take effect 37 | Restart-Service HealthService -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Listing 02 - Create Hybrid Runbook Worker.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Create Hybrid Runbook Worker 2 | # Set the parameters for your Automation Account 3 | $AutoUrl = '' 4 | $AutoKey = '' 5 | $Group = $env:COMPUTERNAME 6 | 7 | # Find the directory the agent was installed in 8 | $Path = 'HKLM:\SOFTWARE\Microsoft\System Center ' + 9 | 'Operations Manager\12\Setup\Agent' 10 | $installPath = Get-ItemProperty -Path $Path | 11 | Select-Object -ExpandProperty InstallDirectory 12 | $AutomationFolder = Join-Path $installPath 'AzureAutomation' 13 | 14 | # Search the folder for the HybridRegistration module 15 | $ChildItem = @{ 16 | Path = $AutomationFolder 17 | Recurse = $true 18 | Include = 'HybridRegistration.psd1' 19 | } 20 | $modulePath = Get-ChildItem @ChildItem | 21 | Select-Object -ExpandProperty FullName 22 | 23 | # Import the HybridRegistration module 24 | Import-Module $modulePath 25 | 26 | # Register the local machine with the automation account 27 | $HybridRunbookWorker = @{ 28 | Url = $AutoUrl 29 | key = $AutoKey 30 | GroupName = $Group 31 | } 32 | Add-HybridRunbookWorker @HybridRunbookWorker -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Listing 03 - Upload ZIP files to Azure Blob.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Upload ZIP files to Azure Blob 2 | # Set the local variables 3 | $FolderPath = 'L:\Archives' 4 | $Container = 'devtest' 5 | 6 | # Set the Azure Storage Variables 7 | $ResourceGroupName = 'PoshAutomate' 8 | $StorageAccountName = '' 9 | $SubscriptionID = '' 10 | 11 | # Connect to Azure 12 | Connect-AzAccount 13 | Set-AzContext -Subscription $SubscriptionID 14 | 15 | # Get all the ZIP files in the folder 16 | $ChildItem = @{ 17 | Path = $FolderPath 18 | Filter = '*.zip' 19 | } 20 | $ZipFiles = Get-ChildItem @ChildItem 21 | 22 | # Get the storage keys and create a context object that will be used to authenticate with the storage account 23 | $AzStorageAccountKey = @{ 24 | ResourceGroupName = $ResourceGroupName 25 | Name = $StorageAccountName 26 | } 27 | $Keys = Get-AzStorageAccountKey @AzStorageAccountKey 28 | $AzStorageContext = @{ 29 | StorageAccountName = $StorageAccountName 30 | StorageAccountKey = $Keys[0].Value 31 | } 32 | $Context = New-AzStorageContext @AzStorageContext 33 | 34 | # Check to see whether the container exists. If it does not, create it 35 | $AzStorageContainer = @{ 36 | Name = $Container 37 | Context = $Context 38 | ErrorAction = 'SilentlyContinue' 39 | } 40 | $containerCheck = Get-AzStorageContainer @AzStorageContainer 41 | if(-not $containerCheck){ 42 | $AzStorageContainer = @{ 43 | Name = $Container 44 | Context = $Context 45 | ErrorAction = 'Stop' 46 | } 47 | New-AzStorageContainer @AzStorageContainer| Out-Null 48 | } 49 | 50 | foreach($file in $ZipFiles){ 51 | # Check whether the file already exists in the container. If not, upload it, and then delete it from the local server 52 | $AzStorageBlob = @{ 53 | Container = $container 54 | Blob = $file.Name 55 | Context = $Context 56 | ErrorAction = 'SilentlyContinue' 57 | } 58 | $blobCheck = Get-AzStorageBlob @AzStorageBlob 59 | if (-not $blobCheck) { 60 | # Upload the file to Azure storage 61 | $AzStorageBlobContent = @{ 62 | File = $file.FullName 63 | Container = $Container 64 | Blob = $file.Name 65 | Context = $Context 66 | Force = $true 67 | ErrorAction = 'Stop' 68 | } 69 | Set-AzStorageBlobContent @AzStorageBlobContent 70 | Remove-Item -Path $file.FullName -Force 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Chapter08 - Cloud-based automation/Listing 04 - Upload-ZipToBlob.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Upload-ZipToBlob 2 | param( 3 | [Parameter(Mandatory = $true)] 4 | [string]$FolderPath, 5 | [Parameter(Mandatory = $true)] 6 | [string]$Container 7 | ) 8 | 9 | # Get the Azure Storage Variables 10 | $SubscriptionID = Get-AutomationVariable ` 11 | -Name 'ZipStorage_SubscriptionID' 12 | $ResourceGroupName = Get-AutomationVariable -Name 'ZipStorage_ResourceGroup' 13 | $StorageAccountName = Get-AutomationVariable -Name 'ZipStorage_AccountName' 14 | 15 | # Connect to Azure 16 | Connect-AzAccount -Identity 17 | Set-AzContext -Subscription $SubscriptionID 18 | 19 | # Get all the ZIP files in the folder 20 | $ChildItem = @{ 21 | Path = $FolderPath 22 | Filter = '*.zip' 23 | } 24 | $ZipFiles = Get-ChildItem @ChildItem 25 | 26 | # Get the storage keys and create a context object that will be used to authenticate with the storage account 27 | $AzStorageAccountKey = @{ 28 | ResourceGroupName = $ResourceGroupName 29 | Name = $StorageAccountName 30 | } 31 | $Keys = Get-AzStorageAccountKey @AzStorageAccountKey 32 | $AzStorageContext = @{ 33 | StorageAccountName = $StorageAccountName 34 | StorageAccountKey = $Keys[0].Value 35 | } 36 | $Context = New-AzStorageContext @AzStorageContext 37 | 38 | # Check to see whether the container exists. If it does not, create it 39 | $AzStorageContainer = @{ 40 | Name = $Container 41 | Context = $Context 42 | ErrorAction = 'SilentlyContinue' 43 | } 44 | $containerCheck = Get-AzStorageContainer @AzStorageContainer 45 | if(-not $containerCheck){ 46 | $AzStorageContainer = @{ 47 | Name = $Container 48 | Context = $Context 49 | ErrorAction = 'Stop' 50 | } 51 | New-AzStorageContainer @AzStorageContainer| Out-Null 52 | } 53 | 54 | foreach($file in $ZipFiles){ 55 | # Check whether the file already exists in the container. If not, upload it, and then delete it from the local server 56 | $AzStorageBlob = @{ 57 | Container = $container 58 | Blob = $file.Name 59 | Context = $Context 60 | ErrorAction = 'SilentlyContinue' 61 | } 62 | $blobCheck = Get-AzStorageBlob @AzStorageBlob 63 | if (-not $blobCheck) { 64 | # Upload the file to Azure storage 65 | $AzStorageBlobContent = @{ 66 | File = $file.FullName 67 | Container = $Container 68 | Blob = $file.Name 69 | Context = $Context 70 | Force = $true 71 | ErrorAction = 'Stop' 72 | } 73 | Set-AzStorageBlobContent @AzStorageBlobContent 74 | Remove-Item -Path $file.FullName -Force 75 | } 76 | } -------------------------------------------------------------------------------- /Chapter09 - Working outside of PowerShell/Helper Scripts/Install-Python.ps1: -------------------------------------------------------------------------------- 1 | Function Test-PythonInstall { 2 | $Before = $ErrorActionPreference 3 | $ErrorActionPreference = 'SilentlyContinue' 4 | $testpy = py -0p 5 | if (-not [string]::IsNullOrEmpty($testpy)) { 6 | $testpy.Split("`n").Trim() | ForEach-Object { 7 | if ($_.Split() -eq '-3.8-64') { 8 | $_.Split() | Where-Object { $_ -and $_ -ne '-3.8-64' } 9 | } 10 | } 11 | } 12 | $ErrorActionPreference = $Before 13 | } 14 | 15 | 16 | 17 | # Install Python 18 | $PyPath = Test-PythonInstall 19 | if (-not($PyPath)) { 20 | Write-Host 'Installing Python...' 21 | choco install python3 --version=3.8.0 --side-by-side --params "/InstallDir:$env:USERPROFILE\Python38" -y 22 | # Reload environment variables to ensure python is now avaiable 23 | $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") 24 | } 25 | else { 26 | Write-Host "Python 3.8 is already installed" 27 | } 28 | 29 | 30 | 31 | # confirm Python is available 32 | $PyPath = Test-PythonInstall 33 | if (-not($PyPath)) { 34 | Write-Host "Unable to locate PythonInstall package. If it was just installed try restarting this script." -ForegroundColor Red -ErrorAction SilentlyContinue 35 | Start-Sleep -Seconds 30 36 | break 37 | } 38 | 39 | 40 | Function Install-PythonModule { 41 | param( 42 | $PyPath, 43 | $Module 44 | ) 45 | try { 46 | $Before = $ErrorActionPreference 47 | $ErrorActionPreference = 'Stop' 48 | Invoke-Expression "$PyPath -m pip install $Module" 49 | } 50 | catch { 51 | $_ 52 | } 53 | $ErrorActionPreference = $Before 54 | } 55 | 56 | Install-PythonModule $PyPath '--upgrade pip setuptools wheel' 57 | Install-PythonModule $PyPath 'pandas' 58 | Install-PythonModule $PyPath 'matplotlib' -------------------------------------------------------------------------------- /Chapter09 - Working outside of PowerShell/Helper Scripts/timeseries.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pandas as pd 3 | import matplotlib.pyplot as plt 4 | 5 | # Define command line arguments 6 | parser = argparse.ArgumentParser(description='Creates and saves a time series chart') 7 | parser.add_argument('output', type=str, help='The name of the output file') 8 | parser.add_argument('title', type=str, help='The title of the chart') 9 | parser.add_argument('json_data', type=str, help='The time series JSON to populate the chart with.') 10 | args = parser.parse_args() 11 | 12 | # plot time series chart 13 | series = pd.read_json(args.json_data) 14 | plt.plot_date(series['Timestamp'], series['Value'], linestyle ='solid') 15 | plt.title(args.title) 16 | plt.xlabel('Time') 17 | plt.ylabel('counter') 18 | plt.grid(True) 19 | 20 | # save the chart locally 21 | plt.savefig(args.output) 22 | print('File saved to : ' + args.output) -------------------------------------------------------------------------------- /Chapter09 - Working outside of PowerShell/Listing 01 - New-WordTableFromObject.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - New-WordTableFromObject 2 | Function New-WordTableFromObject { 3 | [CmdletBinding()] 4 | [OutputType()] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [object]$object 8 | ) 9 | 10 | # Get the properties of the object 11 | $Properties = @($object.psobject.Properties) 12 | 13 | # Create the table 14 | $Table = $Selection.Tables.add( 15 | $Word.Selection.Range, 16 | $Properties.Count, 17 | 2, 18 | [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior 19 | ,[Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitContent 20 | ) 21 | 22 | # Loop through each property, adding it and the value to the table 23 | for ($r = 0; $r -lt $Properties.Count; $r++) { 24 | $Table.Cell($r + 1, 1).Range.Text = 25 | $Properties[$r].Name.ToString() 26 | $Table.Cell($r + 1, 2).Range.Text = 27 | $Properties[$r].Value.ToString() 28 | } 29 | 30 | # Add a paragraph after the table 31 | $Word.Selection.Start = $Document.Content.End 32 | $Selection.TypeParagraph() 33 | } 34 | -------------------------------------------------------------------------------- /Chapter09 - Working outside of PowerShell/Listing 02 - New-WordTableFromArray.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - New-WordTableFromArray 2 | Function New-WordTableFromArray{ 3 | [CmdletBinding()] 4 | [OutputType()] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [object]$object 8 | ) 9 | 10 | # Get the name of the columns 11 | $columns = $object | Select-Object -First 1 | 12 | Select-Object -Property @{l='Name';e={$_.psobject.Properties.Name}} | 13 | Select-Object -ExpandProperty Name 14 | 15 | # Create the table 16 | $Table = $Selection.Tables.add( 17 | $Word.Selection.Range, 18 | $Object.Count + 1, 19 | $columns.Count, 20 | [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior 21 | ,[Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitContent 22 | ) 23 | 24 | # Set the table style 25 | $Table.Style = 'Grid Table 1 Light' 26 | 27 | # Add the header row 28 | for($c = 0; $c -lt $columns.Count; $c++){ 29 | $Table.Cell(1,$c+1).Range.Text = $columns[$c] 30 | } 31 | 32 | # Loop through each item in the array row, adding the data to the correct row 33 | for($r = 0; $r -lt $object.Count; $r++){ 34 | # Loop through each column, adding the data to the correct cell 35 | for($c = 0; $c -lt $columns.Count; $c++){ 36 | $Table.Cell($r+2,$c+1).Range.Text = 37 | $object[$r].psobject.Properties.Value[$c].ToString() 38 | } 39 | } 40 | 41 | # Add a paragraph after the table 42 | $Word.Selection.Start= $Document.Content.End 43 | $Selection.TypeParagraph() 44 | } 45 | -------------------------------------------------------------------------------- /Chapter09 - Working outside of PowerShell/Listing 03 - New-TimeseriesGraph.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - New-TimeseriesGraph 2 | Function New-TimeseriesGraph { 3 | [CmdletBinding()] 4 | [OutputType()] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string]$PyPath, 8 | [Parameter(Mandatory = $true)] 9 | [string]$ScriptPath, 10 | [Parameter(Mandatory = $true)] 11 | [string]$Title, 12 | [Parameter(Mandatory = $true)] 13 | [Microsoft.PowerShell.Commands.GetCounter.PerformanceCounterSampleSet[]] 14 | $CounterData 15 | ) 16 | 17 | # Convert the counter data into a JSON string 18 | $CounterJson = $CounterData | 19 | Select-Object Timestamp, 20 | @{l = 'Value'; e = { $_.CounterSamples.CookedValue } } | 21 | ConvertTo-Json -Compress 22 | 23 | # Generate a random GUID to use with the file names 24 | $Guid = New-Guid 25 | 26 | # Set the name and path of the picture and output file 27 | $path = @{ 28 | Path = $env:TEMP 29 | } 30 | $picture = Join-Path @Path -ChildPath "$($Guid).PNG" 31 | $StandardOutput = Join-Path @Path -ChildPath "$($Guid)-Output.txt" 32 | $StandardError = Join-Path @Path -ChildPath "$($Guid)-Error.txt" 33 | 34 | # Set the arguments for the timeseries.py script. Wrap the parameters in double quotes to account for potential spaces. 35 | $ArgumentList = @( 36 | """$($ScriptPath)""" 37 | """$($picture)""" 38 | """$($Title)""" 39 | $CounterJson.Replace('"', '\"') 40 | ) 41 | # Set the arguments for the Start-Process command 42 | $Process = @{ 43 | FilePath = $PyPath 44 | ArgumentList = $ArgumentList 45 | RedirectStandardOutput = $StandardOutput 46 | RedirectStandardError = $StandardError 47 | NoNewWindow = $true 48 | PassThru = $true 49 | } 50 | $graph = Start-Process @Process 51 | 52 | # Start the timer and wait for the process to exit 53 | $RuntimeSeconds = 30 54 | $timer = [system.diagnostics.stopwatch]::StartNew() 55 | while ($graph.HasExited -eq $false) { 56 | if ($timer.Elapsed.TotalSeconds -gt $RuntimeSeconds) { 57 | $graph | Stop-Process -Force 58 | throw "The application did not exit in time" 59 | } 60 | } 61 | $timer.Stop() 62 | 63 | # Get the content from the output and error files 64 | $OutputContent = Get-Content -Path $StandardOutput 65 | $ErrorContent = Get-Content -Path $StandardError 66 | if ($ErrorContent) { 67 | # If there is anything in the error file, write it as an error in the PowerShell console. 68 | Write-Error $ErrorContent 69 | } 70 | elseif ($OutputContent | Where-Object { $_ -match 'File saved to :' }) { 71 | # If the output has the expected data, parse it to return what you need in PowerShell 72 | $output = $OutputContent | 73 | Where-Object { $_ -match 'File saved to :' } 74 | $Return = $output.Substring($output.IndexOf(':') + 1).Trim() 75 | } 76 | else { 77 | # If there was no error and no output, then something else went wrong, so you will want to notify the person running the script. 78 | Write-Error "Unknown error occurred" 79 | } 80 | 81 | # Delete the output files 82 | Remove-Item -LiteralPath $StandardOutput -Force 83 | Remove-Item -LiteralPath $StandardError -Force 84 | 85 | $Return 86 | } -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Helper Scripts/oscdimg.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdowst/Practical-Automation-with-PowerShell/9399bd4884b8985d434db56bc7d5f984d073fc87/Chapter10 - Automation coding best practices/Helper Scripts/oscdimg.exe -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 01 - Extract the ISO.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Extract the ISO 2 | $ExtractTo = 'C:\Temp' 3 | $SourceISOPath = 'C:\ISO\WindowsSrv2022.iso' 4 | # Check if the folder exists and delete it if it does 5 | if (test-path $ExtractTo) { 6 | Remove-Item -Path $ExtractTo -Recurse -Force 7 | } 8 | 9 | # Mount the ISO image 10 | $DiskImage = @{ 11 | ImagePath = $SourceISOPath 12 | PassThru = $true 13 | } 14 | $image = Mount-DiskImage @DiskImage 15 | 16 | # Get the new drive letter 17 | $drive = $image | 18 | Get-Volume | 19 | Select-Object -ExpandProperty DriveLetter 20 | 21 | # Create destination folder 22 | New-Item -type directory -Path $ExtractTo 23 | 24 | # Copy the ISO files 25 | Get-ChildItem -Path "$($drive):" | 26 | Copy-Item -Destination $ExtractTo -recurse -Force 27 | 28 | # Remove the read-only flag for all files and folders 29 | Get-ChildItem -Path $ExtractTo -Recurse | 30 | ForEach-Object { 31 | Set-ItemProperty -Path $_.FullName -Name IsReadOnly -Value $false 32 | } 33 | 34 | # Dismount the ISO 35 | $image | Dismount-DiskImage -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 02 - Create a Windows zero-touch ISO.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Create a Windows zero-touch ISO 2 | $ExtractTo = 'C:\Temp' 3 | $password = 'P@55word' 4 | # Delete the bootfix.bin 5 | $bootFix = Join-Path $ExtractTo "boot\bootfix.bin" 6 | Remove-Item -Path $bootFix -Force 7 | 8 | # Rename the efisys files 9 | $ChildItem = @{ 10 | Path = $ExtractTo 11 | Filter = "efisys.bin" 12 | Recurse = $true 13 | } 14 | Get-ChildItem @ChildItem | Rename-Item -NewName "efisys_prompt.bin" 15 | $ChildItem['Filter'] = "efisys_noprompt.bin" 16 | Get-ChildItem @ChildItem | Rename-Item -NewName "efisys.bin" 17 | 18 | # Download the AutoUnattend XML 19 | $Path = @{ 20 | Path = $ExtractTo 21 | ChildPath = "Autounattend.xml" 22 | } 23 | $AutounattendXML = Join-Path @Path 24 | $Uri = 'https://gist.githubusercontent.com/mdowst/3826e74507e0d0188e13b8' + 25 | 'c1be453cf1/raw/0f018ec04d583b63c8cb98a52ad9f500be4ece75/Autounattend.xml' 26 | Invoke-WebRequest -Uri $Uri -OutFile $AutounattendXML 27 | 28 | # load the Autounattend.xml 29 | [xml]$Autounattend = Get-Content $AutounattendXML 30 | 31 | # Update the values 32 | $passStr = $password + 'AdministratorPassword' 33 | $bytes = [System.Text.Encoding]::Unicode.GetBytes($passStr) 34 | $passEncoded = [system.convert]::ToBase64String($bytes) 35 | $setting = $Autounattend.unattend.settings | 36 | Where-Object{$_.pass -eq 'oobeSystem'} 37 | $setting.component.UserAccounts.AdministratorPassword.Value = $passEncoded 38 | 39 | # Select the image to use 40 | $ChildItem = @{ 41 | Path = $ExtractTo 42 | Include = "install.wim" 43 | Recurse = $true 44 | } 45 | $ImageWim = Get-ChildItem @ChildItem 46 | $WinImage = Get-WindowsImage -ImagePath $ImageWim.FullName | 47 | Out-GridView -Title 'Select the image to use' -PassThru 48 | $image = $WinImage.ImageIndex.ToString() 49 | 50 | # Set the selected image in the Autounattend.xml 51 | $setup = $Autounattend.unattend.settings | 52 | Where-Object{$_.pass -eq 'windowsPE'} | 53 | Select-Object -ExpandProperty component | 54 | Where-Object{ $_.name -eq "Microsoft-Windows-Setup"} 55 | $setup.ImageInstall.OSImage.InstallFrom.MetaData.Value = $image 56 | 57 | # Save the updated XML file 58 | $Autounattend.Save($AutounattendXML) -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 03 - Find the oscdimg.exe file.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Find the oscdimg.exe file 2 | $FileName = 'oscdimg.exe' 3 | [System.Collections.Generic.List[PSObject]] $SearchFolders = @() 4 | 5 | # Check if the Assessment and Deployment Kit is installed 6 | $ItemProperty = @{ 7 | Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed 8 | Roots' 9 | } 10 | $DevTools = Get-ItemProperty @ItemProperty 11 | 12 | # If ADK is found, add the path to the folder search list 13 | if(-not [string]::IsNullOrEmpty($DevTools.KitsRoot10)){ 14 | $SearchFolders.Add($DevTools.KitsRoot10) 15 | } 16 | 17 | # Add the other common installation locations to the folder search list 18 | $SearchFolders.Add($env:ProgramFiles) 19 | $SearchFolders.Add(${env:ProgramFiles(x86)}) 20 | $SearchFolders.Add($env:ProgramData) 21 | $SearchFolders.Add($env:LOCALAPPDATA) 22 | 23 | # Add the system disks to the folder search list 24 | Get-Volume | 25 | Where-Object { $_.FileSystemLabel -ne 'Temporary Storage' -and 26 | $_.DriveType -ne 'Removable' -and $_.DriveLetter } | 27 | Sort-Object DriveLetter -Descending | Foreach-Object { 28 | $SearchFolders.Add("$($_.DriveLetter):\") 29 | } 30 | 31 | # Loop through each folder and break if the executable is found 32 | foreach ($path in $SearchFolders) { 33 | $ChildItem = @{ 34 | Path = $path 35 | Filter = $FileName 36 | Recurse = $true 37 | ErrorAction = 'SilentlyContinue' 38 | } 39 | $filePath = Get-ChildItem @ChildItem | 40 | Select-Object -ExpandProperty FullName -First 1 41 | if($filePath){ 42 | break 43 | } 44 | } 45 | 46 | if(-not $filePath){ 47 | throw "$FileName not found" 48 | } 49 | 50 | $filePath -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 04 - Running the oscdimg.exe.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Running the oscdimg.exe 2 | $NewIsoPath = 'C:\ISO\WindowsSrv2022_zerotouch.iso' 3 | $filePath = ".\Chapter10\Helper Scripts\oscdimg.exe" 4 | # Get the path to the etfsboot.com file 5 | $Path = @{ 6 | Path = $ExtractTo 7 | ChildPath = 'boot\etfsboot.com' 8 | } 9 | # Get the path to the efisys.bin file 10 | $etfsboot = Join-Path @Path 11 | $Path = @{ 12 | Path = $ExtractTo 13 | ChildPath = 'efi\microsoft\boot\efisys.bin' 14 | } 15 | $efisys = Join-Path @Path 16 | # Build an array with the arguments for the oscdimg.exe 17 | $arguments = @( 18 | '-m' 19 | '-o' 20 | '-u2' 21 | '-udfver102' 22 | "-bootdata:2#p0,e,b$($etfsboot)#pEF,e,b$($efisys)" 23 | $ExtractTo 24 | $NewIsoPath 25 | ) 26 | 27 | # execute the oscdimg.exe with the arguments using the call operator 28 | & $filePath $arguments 29 | 30 | -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 06 - Get the Path and External Switch.ps1: -------------------------------------------------------------------------------- 1 | # Listing 6 - Get the Path and External Switch 2 | # Get the VM host to determine the VM Path 3 | $VmHost = Get-VMHost -ComputerName $VMHostName 4 | 5 | # Confirm the script can access the VM Path 6 | $TestPath = Test-Path -Path $VmHost.VirtualMachinePath 7 | if($TestPath -eq $false){ 8 | throw "Unable to access path '$($VmHost.VirtualMachinePath)'" 9 | } 10 | 11 | # Set the path for the VM's virtual hard disk 12 | $Path = @{ 13 | Path = $VmHost.VirtualMachinePath 14 | ChildPath = "$VMName\$VMName.vhdx" 15 | } 16 | $NewVHDPath = Join-Path @Path 17 | 18 | # Set the new VM parameters 19 | $VMParams = @{ 20 | Name = $VMName 21 | NewVHDPath = $NewVHDPath 22 | NewVHDSizeBytes = 40GB 23 | Path = $VmHost.VirtualMachinePath 24 | Generation = 2 25 | } 26 | 27 | # Determine the switch to use 28 | $VmSwitch = Get-VMSwitch -SwitchType External | 29 | Select-Object -First 1 30 | if (-not $VmSwitch) { 31 | $VmSwitch = Get-VMSwitch -Name 'Default Switch' 32 | } 33 | 34 | # If the switch is found, add it to the VM parameters 35 | if ($VmSwitch) { 36 | $VMParams.Add('SwitchName',$VmSwitch.Name) 37 | } 38 | -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 07 - Create a VM.ps1: -------------------------------------------------------------------------------- 1 | # Listing 7 - Create a VM 2 | $VMParams = @{ 3 | Name = $VMName 4 | NewVHDPath = $NewVHDPath 5 | NewVHDSizeBytes = 40GB 6 | SwitchName = $VmSwitch.Name 7 | Path = $VmHost.VirtualMachinePath 8 | Generation = 2 9 | ErrorAction = 'Stop' 10 | } 11 | $VM = New-VM @VMParams -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 08 - Check if the VM exists before creating it.ps1: -------------------------------------------------------------------------------- 1 | # Listing 8 - Check if the VM exists before creating it 2 | # Attempt to see if the VM already exists 3 | try { 4 | $VM = Get-VM -Name $VMName -ErrorAction Stop 5 | } 6 | catch { 7 | # If the catch is triggered, then set $VM to null to ensure that any previous data is cleared out 8 | $VM = $null 9 | 10 | # If the error is not the expected one for a VM not being there, then throw a terminating error 11 | if ($_.FullyQualifiedErrorId -ne 12 | 'InvalidParameter,Microsoft.HyperV.PowerShell.Commands.GetVM') { 13 | throw $_ 14 | } 15 | } 16 | 17 | # If the VM is not found, then create it 18 | if ($null -eq $VM) { 19 | # Create the VM 20 | $VMParams = @{ 21 | Name = $VMName 22 | NewVHDPath = $NewVHDPath 23 | NewVHDSizeBytes = 40GB 24 | SwitchName = $VmSwitch.Name 25 | Path = $VmHost.VirtualMachinePath 26 | Generation = 2 27 | ErrorAction = 'Stop' 28 | } 29 | $VM = New-VM @VMParams 30 | } 31 | -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 09 - Update VM settings.ps1: -------------------------------------------------------------------------------- 1 | # Listing 9 - Update VM settings 2 | # Set the VM memory 3 | $VMMemory = @{ 4 | DynamicMemoryEnabled = $true 5 | MinimumBytes = 512MB 6 | MaximumBytes = 2048MB 7 | Buffer = 20 8 | StartupBytes = 1024MB 9 | } 10 | $VM | Set-VMMemory @VMMemory 11 | 12 | # Disable automatic checkpoints 13 | $VM | Set-VM -AutomaticCheckpointsEnabled $false 14 | 15 | # Add the Windows installation ISO 16 | if(-not $VM.DVDDrives){ 17 | $VM | Add-VMDvdDrive -Path $ISO 18 | } 19 | else{ 20 | $VM | Set-VMDvdDrive -Path $ISO 21 | } 22 | 23 | # Set the boot order to use the DVD drive first 24 | $BootOrder = @( 25 | $VM.DVDDrives[0] 26 | $VM.HardDrives[0] 27 | ) 28 | $VM | Set-VMFirmware -BootOrder $BootOrder -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 10 - Wait for the OS install to finish.ps1: -------------------------------------------------------------------------------- 1 | # Listing 10 - Wait for the OS install to finish 2 | $OsInstallTimeLimit = 30 3 | # Command to return the VM guest hostname. It will be used to determine that the OS install has been completed. 4 | $Command = @{ 5 | VMId = $VM.Id 6 | ScriptBlock = { $env:COMPUTERNAME } 7 | Credential = $Credential 8 | ErrorAction = 'Stop' 9 | } 10 | 11 | # Include a timer or counter to ensure that your script doesn't end after so many minutes 12 | $timer = [system.diagnostics.stopwatch]::StartNew() 13 | 14 | # Set the variable before the while loop to $null to ensure that past variables are not causing false positives 15 | $Results = $null 16 | while ([string]::IsNullOrEmpty($Results)) { 17 | try { 18 | # Run the command to get the hostname 19 | $Results = Invoke-Command @Command 20 | } 21 | catch { 22 | # If the timer exceeds the number of minutes, throw a terminating error 23 | if ($timer.Elapsed.TotalMinutes -gt 24 | $OsInstallTimeLimit) { 25 | throw "Failed to provision virtual machine after 10 minutes." 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 11 - Add a second virtual hard disk.ps1: -------------------------------------------------------------------------------- 1 | # Listing 11 - Add a second virtual hard disk 2 | Function Add-SecondVHD{ 3 | param( 4 | $VM 5 | ) 6 | # Set the path for the second hard drive 7 | $Path = @{ 8 | Path = $VM.Path 9 | ChildPath = "$($VM.Name)-Data.vhdx" 10 | } 11 | $DataDisk = Join-Path @Path 12 | 13 | # If the VHD does not exist, create it 14 | if (-not(Test-Path $DataDisk)) { 15 | New-VHD -Path $DataDisk -SizeBytes 10GB | Out-Null 16 | } 17 | 18 | # If the VHD is not attached to the VM, attach it 19 | $Vhd = $VM.HardDrives | 20 | Where-Object { $_.Path -eq $DataDisk } 21 | if (-not $Vhd) { 22 | $VM | Get-VMScsiController -ControllerNumber 0 | 23 | Add-VMHardDiskDrive -Path $DataDisk 24 | } 25 | } 26 | 27 | Add-SecondVHD -VM $VM 28 | 29 | # Script block to initialize, partition, and format the new drive inside the guest OS 30 | $ScriptBlock = { 31 | $Volume = @{ 32 | FileSystem = 'NTFS' 33 | NewFileSystemLabel = "Data" 34 | Confirm = $false 35 | } 36 | Get-Disk | Where-Object { $_.PartitionStyle -eq 'raw' } | 37 | Initialize-Disk -PartitionStyle MBR -PassThru | 38 | New-Partition -AssignDriveLetter -UseMaximumSize | 39 | Format-Volume @Volume 40 | } 41 | 42 | # Run the command on the guest OS to set up the new drive 43 | $Command = @{ 44 | VMId = $VM.Id 45 | ScriptBlock = $ScriptBlock 46 | Credential = $Credential 47 | ErrorAction = 'Stop' 48 | } 49 | $Results = Invoke-Command @Command 50 | $Results -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Listing 12 - Get-IsoCredentials.ps1: -------------------------------------------------------------------------------- 1 | # Listing 12 - Get-IsoCredentials 2 | Function Get-IsoCredentials { 3 | param($ISO) 4 | 5 | # Mount the ISO image 6 | $DiskImage = @{ 7 | ImagePath = $ISO 8 | PassThru = $true 9 | } 10 | $image = Mount-DiskImage @DiskImage 11 | 12 | # Get the new drive letter 13 | $drive = $image | 14 | Get-Volume | 15 | Select-Object -ExpandProperty DriveLetter 16 | 17 | # Attempt to find the autounattend.xml in the ISO image 18 | $ChildItem = @{ 19 | Path = "$($drive):" 20 | Filter = "autounattend.xml" 21 | } 22 | $AutounattendXml = Get-ChildItem @ChildItem 23 | 24 | # If the autounattend.xml is found, attempt to extract the password 25 | if ($AutounattendXml) { 26 | [xml]$Autounattend = Get-Content $AutounattendXML.FullName 27 | $object = $Autounattend.unattend.settings | 28 | Where-Object { $_.pass -eq "oobeSystem" } 29 | $AdminPass = $object.component.UserAccounts.AdministratorPassword 30 | if ($AdminPass.PlainText -eq $false) { 31 | $encodedpassword = $AdminPass.Value 32 | $base64 = [system.convert]::Frombase64string($encodedpassword) 33 | $decoded = [system.text.encoding]::Unicode.GetString($base64) 34 | $AutoPass = ($decoded -replace ('AdministratorPassword$', '')) 35 | } 36 | else { 37 | $AutoPass = $AdminPass.Value 38 | } 39 | } 40 | 41 | # Dismount the ISO 42 | $image | Dismount-DiskImage | Out-Null 43 | 44 | # If the password is returned, create a credential object; otherwise, prompt the user for the credentials 45 | $user = "administrator" 46 | if ([string]::IsNullOrEmpty($AutoPass)) { 47 | $parameterHash = @{ 48 | UserName = $user 49 | Message = 'Enter administrator password' 50 | } 51 | $credential = Get-Credential @parameterHash 52 | } 53 | else { 54 | $pass = ConvertTo-SecureString $AutoPass -AsPlainText -Force 55 | $Object = @{ 56 | TypeName = 'System.Management.Automation.PSCredential' 57 | ArgumentList = ( $user , $pass ) 58 | } 59 | $credential = New-Object @Object 60 | } 61 | 62 | $credential 63 | } -------------------------------------------------------------------------------- /Chapter10 - Automation coding best practices/Snippets.md: -------------------------------------------------------------------------------- 1 | # Snippet 1 - Autounattend.xml example 2 | ```xml 3 | 4 | 5 | UABAAHMAcwB3ADAAcgBkAEEAZABtAGkAbgAA== 6 | false</PlainText> 7 | </AdministratorPassword> 8 | </UserAccounts> 9 | ``` 10 | 11 | # Snippet 2 - Setting a new password in the Autounattend PowerShell object 12 | ```powershell 13 | $object = $Autounattend.unattend.settings | 14 | Where-Object { $_.pass -eq "oobeSystem" } 15 | $object.component.UserAccounts.AdministratorPassword.Value = $NewPassword 16 | ``` 17 | 18 | # Snippet 3 - Encoding the password for the Autounattend.xml 19 | ```powershell 20 | $NewPassword = 'P@ssw0rd' 21 | $pass = $NewPassword + 'AdministratorPassword' 22 | $bytes = [System.Text.Encoding]::Unicode.GetBytes($pass) 23 | $base64Password = [system.convert]::ToBase64String($bytes) 24 | ``` 25 | 26 | # Snippet 4 - The updated Autounattend.xml with the encoded password 27 | ```powershell 28 | <UserAccounts> 29 | <AdministratorPassword> 30 | <Value>UABAAHMAcwB3ADAAcgBkAEEAZABtAGkAbgAA==</Value> 31 | <PlainText>false</PlainText> 32 | </AdministratorPassword> 33 | </UserAccounts> 34 | ``` 35 | 36 | # Snippet 5 - The current installed applications in Windows 37 | ```powershell 38 | HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall 39 | HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall 40 | ``` 41 | 42 | # Snippet 6 - Search the registry for a certain value 43 | ```powershell 44 | $SearchFor = '*Windows Assessment and Deployment Kit*' 45 | $Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' 46 | Get-ChildItem -Path $Path | ForEach-Object{ 47 | if($_.GetValue('DisplayName') -like $SearchFor){ 48 | $_ 49 | } 50 | } 51 | ``` 52 | 53 | # Snippet 7 - Get the value of a registry entry 54 | ```powershell 55 | $Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots' 56 | $DevTools = Get-ItemProperty -Path $Path 57 | $DevTools.KitsRoot10 58 | ``` 59 | 60 | # Snippet 8 - Multiple-line comment example 61 | ```powershell 62 | #region Section the requires explaining 63 | <# 64 | This is where I would put a multiple-line 65 | comment. It is also best to use the less than hash 66 | and hash greater than when creating multiple-line 67 | comments, as it allows you to collapse the entire 68 | comment section. 69 | #> 70 | 71 | ... your code 72 | 73 | #endregion 74 | ``` 75 | 76 | # Snippet 9 - VS Code auto-generated help section 77 | ```powershell 78 | Function New-VmFromIso { 79 | <# 80 | .SYNOPSIS 81 | Short description 82 | 83 | .DESCRIPTION 84 | Long description 85 | 86 | .PARAMETER VMName 87 | Parameter description 88 | 89 | .PARAMETER VMHostName 90 | Parameter description 91 | 92 | .EXAMPLE 93 | An example 94 | 95 | .NOTES 96 | General notes 97 | #> 98 | [CmdletBinding()] 99 | param( 100 | [Parameter(Mandatory = $true)] 101 | [string]$VMName, 102 | [Parameter(Mandatory = $true)] 103 | [string]$VMHostName 104 | ) 105 | 106 | } 107 | ``` 108 | 109 | # Snippet 10 - Help section example with details about how to set the parameter values 110 | ```powershell 111 | .EXAMPLE 112 | $ISO = 'D:\ISO\Windows11.iso' 113 | $VM = Get-VM -Name 'Vm01' 114 | Set-VmSettings -VM $VM -ISO $ISO 115 | ``` 116 | 117 | # Snippet 11 - Line breaks after pipelines 118 | ```powershell 119 | Get-Service -Name Spooler | Stop-Service 120 | 121 | Get-Service -Name Spooler | 122 | Stop-Service 123 | ``` -------------------------------------------------------------------------------- /Chapter11 - End-user scripts and forms/Listing 01 - Monitor for new site requests.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Monitor for new site requests 2 | # Your connection information 3 | $ClientId = '<Your Client GUID>' 4 | $Thumbprint = '<Your Certificate Thumbprint>' 5 | $RequestSite = "https://<subdomain>.sharepoint.com/sites/SiteManagement" 6 | $Tenant = '<subdomain>.onmicrosoft.com' 7 | 8 | # The Action script that will perform the site creation 9 | $ActionScript = ".\Listing 2.ps1" 10 | 11 | # The name of the list 12 | $RequestList = 'Site Requests' 13 | 14 | # Connect to the Site Management site 15 | $PnPOnline = @{ 16 | ClientId = $ClientId 17 | Url = $RequestSite 18 | Tenant = $Tenant 19 | Thumbprint = $Thumbprint 20 | } 21 | Connect-PnPOnline @PnPOnline 22 | 23 | # Query to get all entries on the Site Request list with the status of Submitted 24 | $Query = @' 25 | <View> 26 | <Query> 27 | <Where> 28 | <Eq> 29 | <FieldRef Name='Status'/> 30 | <Value Type='Text'>Submitted</Value> 31 | </Eq> 32 | </Where> 33 | </Query> 34 | </View> 35 | '@ 36 | $submittedSites = Get-PnPListItem -List $RequestList -Query $Query 37 | 38 | foreach ($newSite in $submittedSites) { 39 | # Set the arguments from the action script 40 | $Arguments = "-file ""$ActionScript""", 41 | "-ListItemId ""$($newSite.Id)""" 42 | 43 | $jobParams = @{ 44 | FilePath = 'pwsh' 45 | ArgumentList = $Arguments 46 | NoNewWindow = $true 47 | ErrorAction = 'Stop' 48 | } 49 | 50 | # Set the status to Creating 51 | $PnPListItem = @{ 52 | List = $RequestList 53 | Identity = $newSite 54 | Values = @{ Status = 'Creating' } 55 | } 56 | Set-PnPListItem @PnPListItem 57 | 58 | try { 59 | # Confirm that the Action Script can be found 60 | if (-not (Test-Path -Path $ActionScript)) { 61 | throw ("The file '$($ActionScript)' is not recognized as " + 62 | "the name of a script file. Check the spelling of the " + 63 | "name, or if a path was included, verify that the path " + 64 | "is correct and try again.") 65 | } 66 | 67 | # Invoke the action script 68 | Start-Process @jobParams -PassThru 69 | } 70 | catch { 71 | # If it errors trying to execute the action script, then report a problem 72 | $PnPListItem['Values'] = 73 | @{ Status = 'Problem' } 74 | Set-PnPListItem @PnPListItem 75 | 76 | Write-Error $_ 77 | } 78 | } -------------------------------------------------------------------------------- /Chapter11 - End-user scripts and forms/Listing 02 - Creating a new SharePoint site.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Creating a new SharePoint site 2 | param( 3 | [Parameter(Mandatory = $false)] 4 | [int]$ListItemId = 1 5 | ) 6 | # Your connection information 7 | $ClientId = '<Your Client GUID>' 8 | $Thumbprint = '<Your Certificate Thumbprint>' 9 | $RequestSite = "https://<subdomain>.sharepoint.com/sites/SiteManagement" 10 | $Tenant = '<subdomain>.onmicrosoft.com' 11 | 12 | # The name of the list 13 | $RequestList = 'Site Requests' 14 | $TemplateList = 'Site Templates' 15 | 16 | # Set the parameters to set the Status to Problem if anything goes wrong during the script execution 17 | $SiteProblem = @{ 18 | List = $RequestList 19 | Identity = $ListItemId 20 | Values = @{ Status = 'Problem' } 21 | } 22 | 23 | # Connect to the Site Management site 24 | $PnPOnline = @{ 25 | ClientId = $ClientId 26 | Url = $RequestSite 27 | Tenant = $Tenant 28 | Thumbprint = $Thumbprint 29 | } 30 | Connect-PnPOnline @PnPOnline 31 | 32 | # Get the site request details from SharePoint 33 | $PnPListItem = @{ 34 | List = $RequestList 35 | Id = $ListItemId 36 | } 37 | $siteRequest = Get-PnPListItem @PnPListItem 38 | 39 | # Look up the name of the template from the Site Templates list 40 | $PnpListItem = @{ 41 | List = $TemplateList 42 | Id = $siteRequest['Template'].LookupId 43 | } 44 | $templateItem = Get-PnpListItem @PnpListItem 45 | 46 | # Get the current web object. It will be used to determine URL and time zone ID. 47 | $web = Get-PnPWeb -Includes 'RegionalSettings.TimeZone' 48 | 49 | # Get the top-level SharePoint URL from the current website URL 50 | $URI = [URI]::New($web.Url) 51 | $ParentURL = $URI.GetLeftPart([System.UriPartial]::Authority) 52 | $BaseURL = $ParentURL + '/sites/' 53 | 54 | # Get the site URL path from the title 55 | $regex = "[^0-9a-zA-Z_\-'\.]" 56 | $Path = [regex]::Replace($siteRequest['Title'], $regex, "") 57 | $URL = $BaseURL + $Path 58 | 59 | $iteration = 1 60 | do { 61 | try { 62 | # If the site is not found, then trigger the catch 63 | $PnPTenantSite = @{ 64 | Identity = $URL 65 | ErrorAction = 'Stop' 66 | } 67 | Get-PnPTenantSite @PnPTenantSite 68 | # If it is found, then add a number to the end and check again 69 | $URL = $BaseURL + $Path + 70 | $iteration.ToString('00') 71 | $iteration++ 72 | } 73 | catch { 74 | # If error ID does not match the expected value for the site not being there, set the Status to Problem and throw a terminating error 75 | if ($_.FullyQualifiedErrorId -ne 76 | 'EXCEPTION,PnP.PowerShell.Commands.GetTenantSite') { 77 | Set-PnPListItem @SiteProblem 78 | throw $_ 79 | } 80 | else { 81 | $siteCheck = $null 82 | } 83 | } 84 | # Final fail-safe: If the iterations get too high, something went wrong, so set the Status to Problem and terminate the script 85 | if ($iteration -gt 99) { 86 | Set-PnPListItem @SiteProblem 87 | throw "Unable to find unique website name for '$($URL)'" 88 | } 89 | } while ( $siteCheck ) 90 | 91 | # Set all the parameter values 92 | $PnPTenantSite = @{ 93 | Title = $siteRequest['Title'] 94 | Url = $URL 95 | Owner = $siteRequest['Author'].Email 96 | Template = $templateItem['Name'] 97 | TimeZone = $web.RegionalSettings.TimeZone.Id 98 | } 99 | try { 100 | # Create the new site 101 | New-PnPTenantSite @PnPTenantSite -ErrorAction Stop 102 | 103 | # Update the original request with the URL and set the status to Active 104 | $values = @{ 105 | Status = 'Active' 106 | SiteURL = $URL 107 | } 108 | $PnPListItem = @{ 109 | List = $RequestList 110 | Identity = $ListItemId 111 | Values = $values 112 | } 113 | Set-PnPListItem @PnPListItem 114 | } 115 | catch { 116 | # If something goes wrong in the site- creation process, set the status to Problem 117 | Set-PnPListItem @SiteProblem 118 | } -------------------------------------------------------------------------------- /Chapter11 - End-user scripts and forms/Listing 03 - git-install.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - git-install.ps1 2 | param( 3 | $branch 4 | ) 5 | # Install Git 6 | choco install git.install -y 7 | 8 | # Set an alias to the full path of git.exe 9 | $alias = @{ 10 | Name = 'git' 11 | Value = (Join-Path $Env:ProgramFiles 'Git\bin\git.exe') 12 | } 13 | New-Alias @alias -force 14 | 15 | # Enable Auto CRLF to LF conversion 16 | git config --system core.autocrlf true 17 | 18 | # Set the default branch at the user level 19 | git config --global init.defaultBranch $branch -------------------------------------------------------------------------------- /Chapter11 - End-user scripts and forms/Listing 04 - New-ActiveSetup.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - New-ActiveSetup 2 | Function New-ActiveSetup { 3 | param( 4 | [string]$Name, 5 | [System.Management.Automation.ScriptBlock]$ScriptBlock, 6 | [version]$Version = '1.0.0.0' 7 | ) 8 | 9 | # The path to the Active Setup registry keys 10 | $ActiveSetupReg = 11 | 'HKLM:\Software\Microsoft\Active Setup\Installed Components' 12 | 13 | # Create the Active Setup registry key 14 | $Item = @{ 15 | Path = $ActiveSetupReg 16 | Name = $Name 17 | Force = $true 18 | } 19 | $ActiveSetup = New-Item @Item | Select-Object -ExpandProperty PSPath 20 | 21 | # Set the path for the script 22 | $DefaultPath = 'ActiveSetup\{0}_v{1}.ps1' 23 | $ChildPath = $DefaultPath -f $Name, $Version 24 | $ScriptPath = Join-Path -Path $env:ProgramData -ChildPath $ChildPath 25 | $ScriptFolder = Split-Path -Path $ScriptPath 26 | 27 | # Create the ActiveSetup folder if it does not exist 28 | if (-not(Test-Path -Path $ScriptFolder)) { 29 | New-Item -type Directory -Path $ScriptFolder | Out-Null 30 | } 31 | 32 | # Declare the Wrapper script code 33 | $WrapperScript = { 34 | param($Name,$Version) 35 | $Path = "ActiveSetup\$($Name)_$($Version).log" 36 | $log = Join-Path $env:APPDATA $Path 37 | $Transcript = @{ Path = $log; Append = $true; 38 | IncludeInvocationHeader = $true; Force = $true} 39 | Start-Transcript @Transcript 40 | try{ 41 | {0} 42 | } 43 | catch{ Write-Host $_ } 44 | finally{ Stop-Transcript } 45 | } 46 | 47 | # Convert wrapper code to string and fix curly brackets to all for string formatting 48 | $WrapperString = $WrapperScript.ToString() 49 | $WrapperString = $WrapperString.Replace('{','{{') 50 | $WrapperString = $WrapperString.Replace('}','}}') 51 | $WrapperString = $WrapperString.Replace('{{0}}','{0}') 52 | 53 | # Add the script block to the wrapper code and export it to the script file 54 | $WrapperString -f $ScriptBlock.ToString() | 55 | Out-File -FilePath $ScriptPath -Encoding utf8 56 | 57 | # Set the registry values for the Active Setup 58 | $args = @{ 59 | Path = $ActiveSetup 60 | Force = $true 61 | } 62 | $ActiveSetupValue = 'powershell.exe -ExecutionPolicy bypass ' + 63 | "-File ""$($ScriptPath.Replace('\', '\\'))""" + 64 | " -Name ""$($Name)"" -Version ""$($Version)""" 65 | Set-ItemProperty @args -Name '(Default)' -Value $Name 66 | Set-ItemProperty @args -Name 'Version' -Value $Version 67 | Set-ItemProperty @args -Name 'StubPath' -Value $ActiveSetupValue 68 | } -------------------------------------------------------------------------------- /Chapter11 - End-user scripts and forms/Listing 05 - Git install with Active Setup.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Git install with Active Setup 2 | Function New-ActiveSetup { 3 | <# 4 | Code from listing 4 5 | #> 6 | } 7 | 8 | # Install Git 9 | choco install git.install -y 10 | 11 | # Set an alias to the full path of git.exe 12 | $alias = @{ 13 | Name = 'git' 14 | Value = (Join-Path $Env:ProgramFiles 'Git\bin\git.exe') 15 | } 16 | New-Alias @alias -force 17 | 18 | # Enable Auto CRLF to LF conversion 19 | git config --system core.autocrlf true 20 | 21 | # Set the default branch at the user level using Active Setup 22 | $ScriptBlock = { 23 | git config --global init.defaultBranch main 24 | git config --global --list 25 | } 26 | 27 | New-ActiveSetup -Name 'Git' -ScriptBlock $ScriptBlock -Version '1.0' 28 | -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 01 - Get-SystemInfo.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Get-SystemInfo.ps1 2 | Get-CimInstance -Class Win32_OperatingSystem | 3 | Select-Object Caption, InstallDate, ServicePackMajorVersion, 4 | OSArchitecture, BootDevice, BuildNumber, CSName, 5 | @{l='Total_Memory';e={[math]::Round($_.TotalVisibleMemorySize/1MB)}} -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 02 - Create PoshAutomator module.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Create PoshAutomator module 2 | Function New-ModuleTemplate { 3 | [CmdletBinding()] 4 | [OutputType()] 5 | param( 6 | [Parameter(Mandatory = $true)] 7 | [string]$ModuleName, 8 | [Parameter(Mandatory = $true)] 9 | [string]$ModuleVersion, 10 | [Parameter(Mandatory = $true)] 11 | [string]$Author, 12 | [Parameter(Mandatory = $true)] 13 | [string]$PSVersion, 14 | [Parameter(Mandatory = $false)] 15 | [string[]]$Functions 16 | ) 17 | # Do not include the version path since GitHub will take care of the version controls 18 | $ModulePath = Join-Path .\ "$($ModuleName)" 19 | New-Item -Path $ModulePath -ItemType Directory 20 | Set-Location $ModulePath 21 | New-Item -Path .\Public -ItemType Directory 22 | 23 | $ManifestParameters = @{ 24 | ModuleVersion = $ModuleVersion 25 | Author = $Author 26 | Path = ".\$($ModuleName).psd1" 27 | RootModule = ".\$($ModuleName).psm1" 28 | PowerShellVersion = $PSVersion 29 | } 30 | New-ModuleManifest @ManifestParameters 31 | 32 | # Go ahead and autopopulate the functionality to import the function scripts 33 | $File = @{ 34 | Path = ".\$($ModuleName).psm1" 35 | Encoding = 'utf8' 36 | } 37 | @' 38 | $Path = Join-Path $PSScriptRoot 'Public' 39 | # Get all the ps1 files in the Public folder 40 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 41 | 42 | # Loop through each ps1 file 43 | Foreach ($import in $Functions) { 44 | Try { 45 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 46 | # Execute each ps1 file to load the function into memory 47 | . $import.fullname 48 | } 49 | Catch { 50 | Write-Error -Message "Failed to import function $($import.name)" 51 | } 52 | } 53 | '@ | Out-File @File 54 | $Functions | ForEach-Object { 55 | Out-File -Path ".\Public\$($_).ps1" -Encoding utf8 56 | } 57 | } 58 | 59 | # Set the parameters to pass to the function 60 | $module = @{ 61 | # The name of your module 62 | ModuleName = 'PoshAutomator' 63 | # The version of your module 64 | ModuleVersion = "1.0.0.0" 65 | # Your name 66 | Author = "YourNameHere" 67 | # The minimum PowerShell version this module supports 68 | PSVersion = '5.1' 69 | # The functions to create blank files for in the Public folder 70 | Functions = 'Get-SystemInfo' 71 | } 72 | # Execute the function to create the new module 73 | New-ModuleTemplate @module -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 03 - Get-SystemInfo.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Get-SystemInfo 2 | Function Get-SystemInfo{ 3 | Get-CimInstance -Class Win32_OperatingSystem | 4 | Select-Object Caption, InstallDate, ServicePackMajorVersion, 5 | OSArchitecture, BootDevice, BuildNumber, CSName, 6 | @{l='Total_Memory';e={[math]::Round($_.TotalVisibleMemorySize/1MB)}} 7 | } -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 04 - Test-CmdInstall.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Test-CmdInstall 2 | Function Test-CmdInstall { 3 | param( 4 | $TestCommand 5 | ) 6 | try { 7 | # Capture the current ErrorActionPreference 8 | $Before = $ErrorActionPreference 9 | # Set ErrorActionPreference to stop on all errors, even nonterminating ones 10 | $ErrorActionPreference = 'Stop' 11 | # Attempt to run the command 12 | $Command = @{ 13 | Command = $TestCommand 14 | } 15 | $testResult = Invoke-Expression @Command 16 | } 17 | catch { 18 | # If an error is returned, set results to null 19 | $testResult = $null 20 | } 21 | finally { 22 | # Reset the ErrorActionPreference to the original value 23 | $ErrorActionPreference = $Before 24 | } 25 | $testResult 26 | } -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 05 - Install-PoshAutomator.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Install-PoshAutomator.ps1 2 | # The URL to your GitHub repository 3 | $RepoUrl = 4 | 'https://github.com/<yourprofile>/PoshAutomator.git' 5 | Function Test-CmdInstall { 6 | param( 7 | $TestCommand 8 | ) 9 | try { 10 | $Before = $ErrorActionPreference 11 | $ErrorActionPreference = 'Stop' 12 | $testResult = Invoke-Expression -Command $TestCommand 13 | } 14 | catch { 15 | $testResult = $null 16 | } 17 | finally { 18 | $ErrorActionPreference = $Before 19 | } 20 | $testResult 21 | } 22 | 23 | Function Set-EnvPath{ 24 | # Reload the Path environment variables 25 | $env:Path = 26 | [System.Environment]::GetEnvironmentVariable("Path", "Machine") + 27 | ";" + 28 | [System.Environment]::GetEnvironmentVariable("Path", "User") 29 | } 30 | 31 | # Check for Git.exe and install if not found 32 | $GitVersion = Test-CmdInstall 'git --version' 33 | if (-not ($GitVersion)) { 34 | if($IsWindows){ 35 | Write-Host "Installing Git for Windows..." 36 | $wingetParams = 'winget install --id Git.Git' + 37 | ' -e --source winget --accept-package-agreements' + 38 | ' --accept-source-agreements' 39 | Invoke-Expression $wingetParams 40 | } 41 | elseif ($IsLinux) { 42 | Write-Host "Installing Git for Linux..." 43 | apt-get install git -y 44 | } 45 | elseif ($IsMacOS) { 46 | Write-Host "Installing Git for macOS..." 47 | brew install git -y 48 | } 49 | # Reload environment variables to ensure Git is available 50 | Set-EnvPath 51 | $GitVersion = Test-CmdInstall 'git --version' 52 | if (-not ($GitVersion)) { 53 | throw "Unable to locate Git.exe install. 54 | Please install manually and rerun this script." 55 | } 56 | else{ 57 | Write-Host "Git Version $($GitVersion) has been installed" 58 | } 59 | } 60 | else { 61 | Write-Host "Git Version $($GitVersion) is already installed" 62 | } 63 | 64 | # Set the location to the user's profile 65 | if($IsWindows){ 66 | Set-Location $env:USERPROFILE 67 | } 68 | else { 69 | Set-Location $env:HOME 70 | } 71 | 72 | # Clone the repository locally 73 | Invoke-Expression -Command "git clone $RepoUrl" 74 | 75 | $ModuleFolder = Get-Item './PoshAutomator' 76 | # Get the first folder listed in the PSModulePath 77 | $UserPowerShellModules = 78 | [Environment]::GetEnvironmentVariable("PSModulePath").Split(';')[0] 79 | # Create the Symbolic Link 80 | $SimLinkProperties = @{ 81 | ItemType = 'SymbolicLink' 82 | Path = (Join-Path $UserPowerShellModules $ModuleFolder.BaseName) 83 | Target = $ModuleFolder.FullName 84 | Force = $true 85 | } 86 | New-Item @SimLinkProperties -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 06 - PoshAutomator.psm1: -------------------------------------------------------------------------------- 1 | # Listing 6 - PoshAutomator.psm1 2 | # Create a temporary file to capture command outputs 3 | $gitResults = New-TemporaryFile 4 | # Set the default parameters to use when executing the Git command 5 | $Process = @{ 6 | FilePath = 'git.exe' 7 | WorkingDirectory = $PSScriptRoot 8 | RedirectStandardOutput = $gitResults 9 | Wait = $true 10 | NoNewWindow = $true 11 | } 12 | # Get the current branch 13 | $Argument = 'branch --show-current' 14 | Start-Process @Process -ArgumentList $Argument 15 | $content = Get-Content -LiteralPath $gitResults -Raw 16 | 17 | # Check if the current branch is main 18 | if($content.Trim() -ne 'main'){ 19 | # Set the branch to main 20 | $Argument = 'checkout main' 21 | Start-Process @Process -ArgumentList $Argument 22 | } 23 | # Update the metadata for the main branch on GitHub 24 | $Argument = 'fetch' 25 | Start-Process @Process -ArgumentList $Argument 26 | # Compare the local version of main against the remote version 27 | $Argument = 'diff main origin/main --compact-summary' 28 | Start-Process @Process -ArgumentList $Argument 29 | $content = Get-Content -LiteralPath $gitResults -Raw 30 | 31 | # If a difference is detected, force the module to download the newest version 32 | if($content){ 33 | Write-Host "A module update was detected. Downloading new code base..." 34 | $Argument = 'reset origin/main' 35 | Start-Process @Process -ArgumentList $Argument 36 | $content = Get-Content -LiteralPath $gitResults 37 | Write-Host $content 38 | Write-Host "It is recommended that you reload your PowerShell window." 39 | } 40 | 41 | # Delete the temporary file 42 | if(Test-Path $gitResults){ 43 | Remove-Item -Path $gitResults -Force 44 | } 45 | 46 | # Get all the ps1 files in the Public folder 47 | $Path = Join-Path $PSScriptRoot 'Public' 48 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 49 | 50 | # Loop through each ps1 file 51 | Foreach ($import in $Functions) { 52 | Try { 53 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 54 | # Execute each ps1 file to load the function into memory 55 | . $import.fullname 56 | } 57 | Catch { 58 | Write-Error -Message "Failed to import function $($import.name)" 59 | } 60 | } -------------------------------------------------------------------------------- /Chapter12 - Sharing scripts among a team/Listing 6 - PoshAutomator.psm1: -------------------------------------------------------------------------------- 1 | # Listing 6 - PoshAutomator.psm1 2 | # Create a temporary file to capture command outputs 3 | $gitResults = New-TemporaryFile 4 | # Set the default parameters to use when executing the Git command 5 | $Process = @{ 6 | FilePath = 'git.exe' 7 | WorkingDirectory = $PSScriptRoot 8 | RedirectStandardOutput = $gitResults 9 | Wait = $true 10 | NoNewWindow = $true 11 | } 12 | # Get the current branch 13 | $Argument = 'branch --show-current' 14 | Start-Process @Process -ArgumentList $Argument 15 | $content = Get-Content -LiteralPath $gitResults -Raw 16 | 17 | # Check if the current branch is main 18 | if($content.Trim() -ne 'main'){ 19 | # Set branch to main 20 | $Argument = 'checkout main' 21 | Start-Process @Process -ArgumentList $Argument 22 | } 23 | # Update the metadata for the main branch on GitHub 24 | $Argument = 'fetch' 25 | Start-Process @Process -ArgumentList $Argument 26 | # Compare the local version of main against the remote version 27 | $Argument = 'diff main origin/main --compact-summary' 28 | Start-Process @Process -ArgumentList $Argument 29 | $content = Get-Content -LiteralPath $gitResults -Raw 30 | 31 | # If a difference is detected, force the module to download the newest version 32 | if($content){ 33 | Write-Host "A module update was detected. Downloading new code base..." 34 | $Argument = 'reset origin/main' 35 | Start-Process @Process -ArgumentList $Argument 36 | $content = Get-Content -LiteralPath $gitResults 37 | Write-Host $content 38 | Write-Host "It is recommended that you reload your PowerShell window." 39 | } 40 | 41 | # Delete the temporary file 42 | if(Test-Path $gitResults){ 43 | Remove-Item -Path $gitResults -Force 44 | } 45 | 46 | # Get all the ps1 files in the Public folder 47 | $Path = Join-Path $PSScriptRoot 'Public' 48 | $Functions = Get-ChildItem -Path $Path -Filter '*.ps1' 49 | 50 | # Loop through each ps1 file 51 | Foreach ($import in $Functions) { 52 | Try { 53 | Write-Verbose "dot-sourcing file '$($import.fullname)'" 54 | # Execute each ps1 file to load the function into memory 55 | . $import.fullname 56 | } 57 | Catch { 58 | Write-Error -Message "Failed to import function $($import.name)" 59 | } 60 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Find-KbSupersedence.Integration.Tests.ps1: -------------------------------------------------------------------------------- 1 | # Listing 11 - Find-KbSupersedence.Integration.Tests.ps1 integration tests 2 | BeforeAll { 3 | Set-Location -Path $PSScriptRoot 4 | . ".\Find-KbSupersedence.ps1" 5 | } 6 | 7 | Describe 'Find-KbSupersedence' { 8 | It "KB Article is found" { 9 | # Find-KbSupersedence without a Mock 10 | $KBSearch = 11 | Find-KbSupersedence -KbArticle 'KB4521858' 12 | $KBSearch | Should -Not -Be $null 13 | $KBSearch | Should -HaveCount 3 14 | # Confirm the ID is a GUID 15 | $GuidRegEx = '(\{){0,1}[0-9a-fA-F]{8}\-' + 16 | '[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\' + 17 | '-[0-9a-fA-F]{12}(\}){0,1}' 18 | $KBSearch.Id | Should -Match $GuidRegEx 19 | # Confirm products are populating 20 | $KBSearch.Products | Should -Not -Be $null 21 | # Confirm the number of results that have the expected architecture matches the number of results. 22 | $KBSearch | 23 | Where-Object{ $_.Architecture -in 'x86','AMD64','ARM' } | 24 | Should -HaveCount $KBSearch.Count 25 | # Confirm there are at least 9 Superseded By KB articles 26 | $KB = $KBSearch | Select-Object -First 1 27 | ($KB.SupersededBy | Measure-Object).Count | 28 | Should -BeGreaterOrEqual 9 29 | } 30 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Find-KbSupersedence.Unit.Tests.ps1: -------------------------------------------------------------------------------- 1 | # Listing 8 - Find-KbSupersedence.Unit.Tests.ps1 in-depth with Mocks 2 | BeforeAll { 3 | Set-Location -Path $PSScriptRoot 4 | . ".\Find-KbSupersedence.ps1" 5 | } 6 | 7 | Describe 'Find-KbSupersedence' { 8 | BeforeAll { 9 | # Build the Mock for ConvertFrom-Html 10 | Mock ConvertFrom-Html -ParameterFilter{ 11 | $URI } -MockWith { 12 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 13 | $Path = Join-Path $PSScriptRoot $File 14 | ConvertFrom-Html -Path $Path 15 | } 16 | 17 | Mock Invoke-WebRequest -MockWith { 18 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 19 | $Path = Join-Path $PSScriptRoot $File 20 | $Content = Get-Content -Path $Path -Raw 21 | [pscustomobject]@{ 22 | Content = $Content 23 | } 24 | } 25 | } 26 | 27 | # Find-KbSupersedence should use the Mock 28 | It "KB Article is found" { 29 | $KBSearch = Find-KbSupersedence -KbArticle 'KB4521858' 30 | $KBSearch | Should -Not -Be $null 31 | $KBSearch | Should -HaveCount 3 32 | 33 | # Confirm the Mocks were called the expected number of time 34 | $cmd = 'ConvertFrom-Html' 35 | Should -Invoke -CommandName $cmd -Times 1 36 | $cmd = 'Invoke-WebRequest' 37 | Should -Invoke -CommandName $cmd -Times 3 38 | } 39 | 40 | It "In Depth Search results" { 41 | $KBSearch = Find-KbSupersedence -KbArticle 'KB4521858' 42 | $KBSearch.Id | 43 | Should -Contain '250bfd45-b92c-49af-b604-dbdfd15061e6' 44 | $KBSearch | 45 | Where-Object{ $_.Products -contains 'Windows 10' } | 46 | Should -HaveCount 2 47 | $KBSearch | 48 | Where-Object{ $_.Architecture -eq 'AMD64' } | 49 | Should -HaveCount 2 50 | $KB = $KBSearch | 51 | Where-Object{ $_.Id -eq '83d7bc64-ff39-4073-9d77-02102226aff6' } 52 | $KB.Products | Should -Be 'Windows Server 2016' 53 | ($KB.SupersededBy | Measure-Object).Count | Should -Be 9 54 | } 55 | 56 | # Run the Find-KbSupersedence for the not superseded update 57 | It "SupersededBy results" { 58 | $KBMock = Find-KbSupersedence -KbArticle 'KB5008295' 59 | 60 | # Confirm there are no superseding updates for both updates returned 61 | $KBMock.SupersededBy | 62 | Should -Be @($null, $null) 63 | 64 | # Confirm the Mocks were called the expected number of time 65 | $cmd = 'ConvertFrom-Html' 66 | Should -Invoke -CommandName $cmd -Times 1 67 | $cmd = 'Invoke-WebRequest' 68 | Should -Invoke -CommandName $cmd -Times 2 69 | } 70 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Find-KbSupersedence.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Find-KbSupersedence.ps1 2 | Function Find-KbSupersedence { 3 | param( 4 | $KbArticle 5 | ) 6 | 7 | $UriHost = 'https://www.catalog.update.microsoft.com/' 8 | $SearchUri = $UriHost + 'Search.aspx?q=' + 9 | $KbArticle 10 | $Search = ConvertFrom-Html -URI $SearchUri 11 | 12 | # XPath query for the KB articles returned from the search 13 | $xPath = '//*[' + 14 | '@class="contentTextItemSpacerNoBreakLink" ' + 15 | 'and @href="javascript:void(0);"]' 16 | 17 | # Parse through each search result 18 | $Search.SelectNodes($xPath) | ForEach-Object { 19 | # Get the title and GUID of the KB article 20 | $Title = $_.InnerText.Trim() 21 | $Id = $_.Id.Replace('_link', '') 22 | 23 | # Get the details page from the Catalog 24 | $DetailsUri = $UriHost + 25 | "ScopedViewInline.aspx?updateid=$($Id)" 26 | $Headers = @{"accept-language"="en-US,en;q=0.9"} 27 | $Request = Invoke-WebRequest -Uri $DetailsUri -Headers $Headers 28 | $Details = ConvertFrom-Html -Content $Request 29 | 30 | # Get the Architecture 31 | $xPath = '//*[@id="archDiv"]' 32 | $Architecture = $Details.SelectSingleNode($xPath).InnerText 33 | $Architecture = $Architecture.Replace('Architecture:', '').Trim() 34 | 35 | # Get the products 36 | $xPath = '//*[@id="productsDiv"]' 37 | $Products = $Details.SelectSingleNode($xPath).InnerText 38 | $Products = $Products.Replace('Supported products:', '') 39 | $Products = $Products.Split(',').Trim() 40 | 41 | # Get the Superseded By Updates 42 | $xPath = '//*[@id="supersededbyInfo"]' 43 | $DivElements = $Details.SelectSingleNode($xPath).Elements("div") 44 | if ($DivElements.HasChildNodes) { 45 | $SupersededBy = $DivElements.Elements("a") | Foreach-Object { 46 | $KB = [regex]::Match($_.InnerText.Trim(), 'KB[0-9]{7}') 47 | [pscustomobject]@{ 48 | KbArticle = $KB.Value 49 | Title = $_.InnerText.Trim() 50 | ID = [guid]$_.Attributes.Value.Split('=')[-1] 51 | } 52 | } 53 | } 54 | 55 | # Create a PowerShell object with search results 56 | [pscustomobject]@{ 57 | KbArticle = $KbArticle 58 | Title = $Title 59 | Id = $Id 60 | Architecture = $Architecture 61 | Products = $Products 62 | SupersededBy = $SupersededBy 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Get-HotFixStatus.Unit.Tests.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Get-HotFixStatus.Unit.Tests.ps1 with Mocking 2 | BeforeAll { 3 | # Import your function 4 | Set-Location -Path $PSScriptRoot 5 | . .\Get-HotFixStatus.ps1 6 | 7 | # Set a default value for the ID 8 | $Id = 'KB1234567' 9 | } 10 | 11 | # Pester tests 12 | Describe 'Get-HotFixStatus' { 13 | Context "Hotfix Found" { 14 | BeforeAll { 15 | Mock Get-HotFix {} 16 | } 17 | It "Hotfix is found on the computer" { 18 | $KBFound = Get-HotFixStatus -Id $Id -Computer 'localhost' 19 | $KBFound | Should -Be $true 20 | } 21 | } 22 | 23 | Context "Hotfix Not Found" { 24 | BeforeAll { 25 | Mock Get-HotFix { 26 | throw ('GetHotFixNoEntriesFound,' + 27 | 'Microsoft.PowerShell.Commands.GetHotFixCommand') 28 | } 29 | } 30 | It "Hotfix is not found on the computer" { 31 | $KBFound = Get-HotFixStatus -Id $Id -Computer 'localhost' 32 | $KBFound | Should -Be $false 33 | } 34 | } 35 | 36 | Context "Not able to connect to the remote machine" { 37 | BeforeAll { 38 | Mock Get-HotFix { 39 | throw ('System.Runtime.InteropServices.COMException,' + 40 | 'Microsoft.PowerShell.Commands.GetHotFixCommand' ) 41 | } 42 | } 43 | 44 | It "Unable to connect" { 45 | { Get-HotFixStatus -Id $Id -Computer 'Bad' } | Should -Throw 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Get-HotFixStatus.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Get-HotFixStatus 2 | Function Get-HotFixStatus{ 3 | param( 4 | $Id, 5 | $Computer 6 | ) 7 | 8 | # Set the initial value to false 9 | $Found = $false 10 | try{ 11 | # Attempt the return the patch and stop execution on any error 12 | $HotFix = @{ 13 | Id = $Id 14 | ComputerName = $Computer 15 | ErrorAction = 'Stop' 16 | } 17 | Get-HotFix @HotFix | Out-Null 18 | # If the command above did not error, then it is safe to assume it was found 19 | $Found = $true 20 | } 21 | catch{ 22 | # If catch block is triggered, check to see if the error was because the patch was not found. 23 | $NotFound = 'GetHotFixNoEntriesFound,' + 24 | 'Microsoft.PowerShell.Commands.GetHotFixCommand' 25 | if($_.FullyQualifiedErrorId -ne $NotFound){ 26 | # Termination execution on any error other than the patch not found 27 | throw $_ 28 | } 29 | } 30 | $Found 31 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Get-VulnerabilityStatus.Integration.Tests.ps1: -------------------------------------------------------------------------------- 1 | # Listing 10 - Get-VulnerabilityStatus.Integration.Test.ps1 2 | BeforeAll { 3 | # Import all your functions 4 | Set-Location -Path $PSScriptRoot 5 | . ".\Get-HotFixStatus.ps1" 6 | . ".\Find-KbSupersedence.ps1" 7 | . ".\Get-VulnerabilityStatus.ps1" 8 | } 9 | 10 | Describe 'Find-KbSupersedence not superseded' { 11 | BeforeAll { 12 | $Id = 'KB4521858' 13 | $Vulnerability = @{ 14 | Id = $Id 15 | Product = 'Windows Server 2016' 16 | Computer = 'localhost' 17 | } 18 | # Build the Mock for ConvertFrom-Html 19 | Mock ConvertFrom-Html -ParameterFilter{ 20 | $URI } -MockWith { 21 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 22 | $Path = Join-Path $PSScriptRoot $File 23 | ConvertFrom-Html -Path $Path 24 | } 25 | } 26 | Context "Patch Found" { 27 | # Mock Get-HotFix so the integration test thinks the patch is installed 28 | BeforeAll { 29 | Mock Get-HotFix {} 30 | } 31 | 32 | It "Patch is found on the computer" { 33 | $KBFound = Get-VulnerabilityStatus @Vulnerability 34 | $KBFound | Should -Be $Id 35 | } 36 | } 37 | 38 | Context "Patch Not Found" { 39 | # Mock Get-HotFix, so the integration test thinks the patch is not installed, and neither are any that supersedes it 40 | BeforeAll { 41 | Mock Get-HotFix { 42 | throw ('GetHotFixNoEntriesFound,' + 43 | 'Microsoft.PowerShell.Commands.GetHotFixCommand') 44 | } 45 | } 46 | It "Patch is not found on the computer" { 47 | $KBFound = Get-VulnerabilityStatus @Vulnerability 48 | $KBFound | Should -BeNullOrEmpty 49 | } 50 | } 51 | 52 | Context "Superseding Patch Found" { 53 | # Mock Get-HotFix, so that not installed is returned for any patch other than KB4565912 54 | BeforeAll { 55 | Mock Get-HotFix { 56 | throw ('GetHotFixNoEntriesFound,' + 57 | 'Microsoft.PowerShell.Commands.GetHotFixCommand') 58 | } -ParameterFilter { $Id -ne 'KB4565912' } 59 | 60 | # Mock Get-HotFix, so that installed is returned only for KB4565912 61 | Mock Get-HotFix { } -ParameterFilter { 62 | $Id -eq 'KB4565912' } 63 | } 64 | It "Superseding Patch is found on the computer" { 65 | $KBFound = Get-VulnerabilityStatus @Vulnerability 66 | $KBFound | Should -Be 'KB4565912' 67 | 68 | # Add the same ParameterFilters to the Should -Invoke to confirm they execute the expected number of time 69 | $cmd = 'Get-HotFix' 70 | Should -Invoke -CommandName $cmd -ParameterFilter { 71 | $Id -ne 'KB4565912' } -Times 4 72 | Should -Invoke -CommandName $cmd -ParameterFilter { 73 | $Id -eq 'KB4565912' } -Times 1 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Helper Scripts/Get-VulnerabilityStatus.ps1: -------------------------------------------------------------------------------- 1 | # Listing 9 - Get-VulnerabilityStatus.ps1 2 | Function Get-VulnerabilityStatus{ 3 | param( 4 | [string]$Id, 5 | [string]$Product, 6 | [string]$Computer 7 | ) 8 | # First check is the patch is installed 9 | $HotFixStatus = @{ 10 | Id = $Id 11 | Computer = $Computer 12 | } 13 | $Status = Get-HotFixStatus @HotFixStatus 14 | 15 | # If not installed, check for any patches that supersede it 16 | if($Status -eq $false){ 17 | $Supersedence = Find-KbSupersedence -KbArticle $Id 18 | $KBs = $Supersedence | 19 | Where-Object{ $_.Products -contains $Product } 20 | # Check each superseded patch to see if any are installed 21 | foreach($item in $KBs.SupersededBy){ 22 | $Test = Get-HotFixStatus -Id $item.KbArticle -Computer $Computer 23 | if($Test -eq $true){ 24 | $item.KbArticle 25 | # If a superseded patch is found, there is no need to check additional ones, so go ahead and break the foreach loop 26 | break 27 | } 28 | } 29 | } 30 | else{ 31 | $Id 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 01 - Get-HotFixStatus.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Get-HotFixStatus 2 | Function Get-HotFixStatus{ 3 | param( 4 | $Id, 5 | $Computer 6 | ) 7 | 8 | # Set the initial value to false 9 | $Found = $false 10 | try{ 11 | # Attempt to return the patch and stop execution on any error 12 | $HotFix = @{ 13 | Id = $Id 14 | ComputerName = $Computer 15 | ErrorAction = 'Stop' 16 | } 17 | Get-HotFix @HotFix | Out-Null 18 | # If the previous command did not error, then it is safe to assume it was found 19 | $Found = $true 20 | } 21 | catch{ 22 | # If the catch block is triggered, check to see if the error was because the patch was not found 23 | $NotFound = 'GetHotFixNoEntriesFound,' + 24 | 'Microsoft.PowerShell.Commands.GetHotFixCommand' 25 | if($_.FullyQualifiedErrorId -ne $NotFound){ 26 | # Termination execution on any error other than the patch not found 27 | throw $_ 28 | } 29 | } 30 | $Found 31 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 02 - Get-HotFixStatus.Unit.Tests.ps1 Local Check.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Get-HotFixStatus.Unit.Tests.ps1 Local Check 2 | BeforeAll { 3 | # Import your function 4 | Set-Location -Path $PSScriptRoot 5 | . .\Get-HotFixStatus.ps1 6 | } 7 | 8 | # Pester tests 9 | Describe 'Get-HotFixStatus' { 10 | It "Hotfix is found on the computer" { 11 | $KBFound = Get-HotFixStatus -Id 'KB5011493' -Computer 'localhost' 12 | $KBFound | Should -Be $true 13 | } 14 | 15 | It "Hotfix is not found on the computer" { 16 | $KBFound = Get-HotFixStatus -Id 'KB1234567' -Computer 'localhost' 17 | $KBFound | Should -Be $false 18 | } 19 | 20 | It "Unable to connect" { 21 | { Get-HotFixStatus -Id 'KB5011493' -Computer 'Bad' } | Should -Throw 22 | } 23 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 03 - Get-HotFixStatus.Unit.Tests.ps1 with Mocking.ps1: -------------------------------------------------------------------------------- 1 | # Listing 3 - Get-HotFixStatus.Unit.Tests.ps1 with Mocking 2 | BeforeAll { 3 | # Import your function 4 | Set-Location -Path $PSScriptRoot 5 | . .\Get-HotFixStatus.ps1 6 | 7 | # Set a default value for the ID 8 | $Id = 'KB1234567' 9 | } 10 | 11 | # Pester tests 12 | Describe 'Get-HotFixStatus' { 13 | Context "Hotfix Found" { 14 | BeforeAll { 15 | Mock Get-HotFix {} 16 | } 17 | It "Hotfix is found on the computer" { 18 | $KBFound = Get-HotFixStatus -Id $Id -Computer 'localhost' 19 | $KBFound | Should -Be $true 20 | } 21 | } 22 | 23 | Context "Hotfix Not Found" { 24 | BeforeAll { 25 | Mock Get-HotFix { 26 | throw ('GetHotFixNoEntriesFound,' + 27 | 'Microsoft.PowerShell.Commands.GetHotFixCommand') 28 | } 29 | } 30 | It "Hotfix is not found on the computer" { 31 | $KBFound = Get-HotFixStatus -Id $Id -Computer 'localhost' 32 | $KBFound | Should -Be $false 33 | } 34 | } 35 | 36 | Context "Not able to connect to the remote machine" { 37 | BeforeAll { 38 | Mock Get-HotFix { 39 | throw ('System.Runtime.InteropServices.COMException,' + 40 | 'Microsoft.PowerShell.Commands.GetHotFixCommand' ) 41 | } 42 | } 43 | 44 | It "Unable to connect" { 45 | { Get-HotFixStatus -Id $Id -Computer 'Bad' } | Should -Throw 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 04 - Find-KbSupersedence.ps1: -------------------------------------------------------------------------------- 1 | # Listing 4 - Find-KbSupersedence.ps1 2 | Function Find-KbSupersedence { 3 | param( 4 | $KbArticle 5 | ) 6 | 7 | $UriHost = 'https://www.catalog.update.microsoft.com/' 8 | $SearchUri = $UriHost + 'Search.aspx?q=' + 9 | $KbArticle 10 | $Search = ConvertFrom-Html -URI $SearchUri 11 | 12 | # XPath query for the KB articles returned from the search 13 | $xPath = '//*[' + 14 | '@class="contentTextItemSpacerNoBreakLink" ' + 15 | 'and @href="javascript:void(0);"]' 16 | 17 | # Parse through each search result 18 | $Search.SelectNodes($xPath) | ForEach-Object { 19 | # Get the title and GUID of the KB article 20 | $Title = $_.InnerText.Trim() 21 | $Id = $_.Id.Replace('_link', '') 22 | 23 | # Get the details page from the Catalog 24 | $DetailsUri = $UriHost + 25 | "ScopedViewInline.aspx?updateid=$($Id)" 26 | $Headers = @{"accept-language"="en-US,en;q=0.9"} 27 | $Request = Invoke-WebRequest -Uri $DetailsUri -Headers $Headers 28 | $Details = ConvertFrom-Html -Content $Request 29 | 30 | # Get the Architecture 31 | $xPath = '//*[@id="archDiv"]' 32 | $Architecture = $Details.SelectSingleNode($xPath).InnerText 33 | $Architecture = $Architecture.Replace('Architecture:', '').Trim() 34 | 35 | # Get the products 36 | $xPath = '//*[@id="productsDiv"]' 37 | $Products = $Details.SelectSingleNode($xPath).InnerText 38 | $Products = $Products.Replace('Supported products:', '') 39 | $Products = $Products.Split(',').Trim() 40 | 41 | # Get the Superseded By Updates 42 | $xPath = '//*[@id="supersededbyInfo"]' 43 | $DivElements = $Details.SelectSingleNode($xPath).Elements("div") 44 | if ($DivElements.HasChildNodes) { 45 | $SupersededBy = $DivElements.Elements("a") | Foreach-Object { 46 | $KB = [regex]::Match($_.InnerText.Trim(), 'KB[0-9]{7}') 47 | [pscustomobject]@{ 48 | KbArticle = $KB.Value 49 | Title = $_.InnerText.Trim() 50 | ID = [guid]$_.Attributes.Value.Split('=')[-1] 51 | } 52 | } 53 | } 54 | 55 | # Create a PowerShell object with search results 56 | [pscustomobject]@{ 57 | KbArticle = $KbArticle 58 | Title = $Title 59 | Id = $Id 60 | Architecture = $Architecture 61 | Products = $Products 62 | SupersededBy = $SupersededBy 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 05 - Find-KbSupersedence.Unit.Test.ps1 Initial.ps1: -------------------------------------------------------------------------------- 1 | # Listing 5 - Find-KbSupersedence.Unit.Test.ps1 Initial 2 | BeforeAll { 3 | Set-Location -Path $PSScriptRoot 4 | . ".\Listing 04 - Find-KbSupersedence.ps1" 5 | } 6 | 7 | Describe 'Find-KbSupersedence' { 8 | It "KB Article is found" { 9 | $KBSearch = Find-KbSupersedence -KbArticle 'KB4521858' 10 | $KBSearch | Should -Not -Be $null 11 | $KBSearch | Should -HaveCount 3 12 | } 13 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 06 - Export HTML to file.ps1: -------------------------------------------------------------------------------- 1 | # Listing 6 - Export HTML to file 2 | $KbArticle = 'KB5008295' 3 | 4 | # Build the search URL 5 | $UriHost = 'https://www.catalog.update.microsoft.com/' 6 | $SearchUri = $UriHost + 'Search.aspx?q=' + $KbArticle 7 | 8 | # Get the search results 9 | $Search = ConvertFrom-Html -URI $SearchUri 10 | 11 | # Output the HTML of the page to a file named after the KB 12 | $Search.OuterHtml | Out-File ".\$($KbArticle).html" 13 | 14 | # XPath query for the KB articles returned from the search 15 | $xPath = '//*[' + 16 | '@class="contentTextItemSpacerNoBreakLink" ' + 17 | 'and @href="javascript:void(0);"]' 18 | 19 | # Parse through each search result 20 | $Search.SelectNodes($xPath) | ForEach-Object { 21 | # Get the ID and use it to get the details page from the Catalog 22 | $Id = $_.Id.Replace('_link', '') 23 | $DetailsUri = $UriHost + 24 | "ScopedViewInline.aspx?updateid=$($Id)" 25 | $Header = @{"accept-language"="en-US,en;q=0.9"} 26 | $Details = Invoke-WebRequest -Uri $DetailsUri -Headers $Header | 27 | ConvertFrom-Html 28 | 29 | # Output the HTML of the page to a file named after the ID 30 | $Details.OuterHtml | Out-File ".\$($Id).html" 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 07 - Find-KbSupersedence.Unit.Tests.ps1 with Mock test files.ps1: -------------------------------------------------------------------------------- 1 | # Listing 7 - Find-KbSupersedence.Unit.Tests.ps1 with Mock test files 2 | BeforeAll { 3 | Set-Location -Path $PSScriptRoot 4 | . ".\Find-KbSupersedence.ps1" 5 | } 6 | 7 | Describe 'Find-KbSupersedence' { 8 | BeforeAll { 9 | # Build the Mock for ConvertFrom-Html 10 | Mock ConvertFrom-Html -ParameterFilter{ 11 | $URI } -MockWith { 12 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 13 | $Path = Join-Path $PSScriptRoot $File 14 | ConvertFrom-Html -Path $Path 15 | } 16 | 17 | # Build the Mock for Invoke-WebRequest 18 | Mock Invoke-WebRequest -MockWith { 19 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 20 | $Path = Join-Path $PSScriptRoot $File 21 | $Content = Get-Content -Path $Path -Raw 22 | # Build a custom PowerShell Object to mock what the cmdlet would return 23 | [pscustomobject]@{ 24 | Content = $Content 25 | } 26 | } 27 | } 28 | 29 | # Find-KbSupersedence should use the Mock 30 | It "KB Article is found" { 31 | $KBSearch = Find-KbSupersedence -KbArticle 'KB4521858' 32 | $KBSearch | Should -Not -Be $null 33 | $KBSearch | Should -HaveCount 3 34 | 35 | # Confirm the Mocks were called the expected number of times 36 | $cmd = 'ConvertFrom-Html' 37 | Should -Invoke -CommandName $cmd -Times 1 38 | $cmd = 'Invoke-WebRequest' 39 | Should -Invoke -CommandName $cmd -Times 3 40 | } 41 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 08 - Find-KbSupersedence.Unit.Tests.ps1 in-depth with Mocks.ps1: -------------------------------------------------------------------------------- 1 | # Listing 8 - Find-KbSupersedence.Unit.Tests.ps1 in-depth with Mocks 2 | BeforeAll { 3 | Set-Location -Path $PSScriptRoot 4 | . ".\Find-KbSupersedence.ps1" 5 | } 6 | 7 | Describe 'Find-KbSupersedence' { 8 | BeforeAll { 9 | # Build the Mock for ConvertFrom-Html 10 | Mock ConvertFrom-Html -ParameterFilter{ 11 | $URI } -MockWith { 12 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 13 | $Path = Join-Path $PSScriptRoot $File 14 | ConvertFrom-Html -Path $Path 15 | } 16 | 17 | Mock Invoke-WebRequest -MockWith { 18 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 19 | $Path = Join-Path $PSScriptRoot $File 20 | $Content = Get-Content -Path $Path -Raw 21 | [pscustomobject]@{ 22 | Content = $Content 23 | } 24 | } 25 | } 26 | 27 | # Find-KbSupersedence should use the Mock 28 | It "KB Article is found" { 29 | $KBSearch = Find-KbSupersedence -KbArticle 'KB4521858' 30 | $KBSearch | Should -Not -Be $null 31 | $KBSearch | Should -HaveCount 3 32 | 33 | # Confirm the Mocks were called the expected number of times 34 | $cmd = 'ConvertFrom-Html' 35 | Should -Invoke -CommandName $cmd -Times 1 36 | $cmd = 'Invoke-WebRequest' 37 | Should -Invoke -CommandName $cmd -Times 3 38 | } 39 | 40 | It "In Depth Search results" { 41 | $KBSearch = Find-KbSupersedence -KbArticle 'KB4521858' 42 | $KBSearch.Id | 43 | Should -Contain '250bfd45-b92c-49af-b604-dbdfd15061e6' 44 | $KBSearch | 45 | Where-Object{ $_.Products -contains 'Windows 10' } | 46 | Should -HaveCount 2 47 | $KBSearch | 48 | Where-Object{ $_.Architecture -eq 'AMD64' } | 49 | Should -HaveCount 2 50 | $KB = $KBSearch | 51 | Where-Object{ $_.Id -eq '83d7bc64-ff39-4073-9d77-02102226aff6' } 52 | $KB.Products | Should -Be 'Windows Server 2016' 53 | ($KB.SupersededBy | Measure-Object).Count | Should -Be 9 54 | } 55 | 56 | # Run the Find-KbSupersedence for the not superseded update 57 | It "SupersededBy results" { 58 | $KBMock = Find-KbSupersedence -KbArticle 'KB5008295' 59 | 60 | # Confirm there are no superseding updates for both updates returned 61 | $KBMock.SupersededBy | 62 | Should -Be @($null, $null) 63 | 64 | # Confirm the Mocks were called the expected number of times 65 | $cmd = 'ConvertFrom-Html' 66 | Should -Invoke -CommandName $cmd -Times 1 67 | $cmd = 'Invoke-WebRequest' 68 | Should -Invoke -CommandName $cmd -Times 2 69 | } 70 | } -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 09 - Get-VulnerabilityStatus.ps1: -------------------------------------------------------------------------------- 1 | # Listing 9 - Get-VulnerabilityStatus.ps1 2 | Function Get-VulnerabilityStatus{ 3 | param( 4 | [string]$Id, 5 | [string]$Product, 6 | [string]$Computer 7 | ) 8 | # First check is the patch is installed 9 | $HotFixStatus = @{ 10 | Id = $Id 11 | Computer = $Computer 12 | } 13 | $Status = Get-HotFixStatus @HotFixStatus 14 | 15 | # If it is not installed, check for any patches that supersede it 16 | if($Status -eq $false){ 17 | $Supersedence = Find-KbSupersedence -KbArticle $Id 18 | $KBs = $Supersedence | 19 | Where-Object{ $_.Products -contains $Product } 20 | # Check each superseded patch to see if any are installed 21 | foreach($item in $KBs.SupersededBy){ 22 | $Test = Get-HotFixStatus -Id $item.KbArticle -Computer $Computer 23 | if($Test -eq $true){ 24 | $item.KbArticle 25 | # If a superseded patch is found, there is no need to check for additional ones, so go ahead and break the foreach loop 26 | break 27 | } 28 | } 29 | } 30 | else{ 31 | $Id 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 10 - Get-VulnerabilityStatus.Integration.Test.ps1: -------------------------------------------------------------------------------- 1 | # Listing 10 - Get-VulnerabilityStatus.Integration.Test.ps1 2 | BeforeAll { 3 | # Import all your functions 4 | Set-Location -Path $PSScriptRoot 5 | . ".\Get-HotFixStatus.ps1" 6 | . ".\Find-KbSupersedence.ps1" 7 | . ".\Get-VulnerabilityStatus.ps1" 8 | } 9 | 10 | Describe 'Find-KbSupersedence not superseded' { 11 | BeforeAll { 12 | $Id = 'KB4521858' 13 | $Vulnerability = @{ 14 | Id = $Id 15 | Product = 'Windows Server 2016' 16 | Computer = 'localhost' 17 | } 18 | # Build the Mock for ConvertFrom-Html 19 | Mock ConvertFrom-Html -ParameterFilter{ 20 | $URI } -MockWith { 21 | $File = "$($URI.AbsoluteUri.Split('=')[-1]).html" 22 | $Path = Join-Path $PSScriptRoot $File 23 | ConvertFrom-Html -Path $Path 24 | } 25 | } 26 | Context "Patch Found" { 27 | # Mock Get-HotFix so the integration test thinks the patch is installed 28 | BeforeAll { 29 | Mock Get-HotFix {} 30 | } 31 | 32 | It "Patch is found on the computer" { 33 | $KBFound = Get-VulnerabilityStatus @Vulnerability 34 | $KBFound | Should -Be $Id 35 | } 36 | } 37 | 38 | Context "Patch Not Found" { 39 | # Mock Get-HotFix so the integration test thinks the patch is not installed, and neither is one that supersedes it 40 | BeforeAll { 41 | Mock Get-HotFix { 42 | throw ('GetHotFixNoEntriesFound,' + 43 | 'Microsoft.PowerShell.Commands.GetHotFixCommand') 44 | } 45 | } 46 | It "Patch is not found on the computer" { 47 | $KBFound = Get-VulnerabilityStatus @Vulnerability 48 | $KBFound | Should -BeNullOrEmpty 49 | } 50 | } 51 | 52 | Context "Superseding Patch Found" { 53 | # Mock Get-HotFix so that not installed is returned for any patch other than KB4565912 54 | BeforeAll { 55 | Mock Get-HotFix { 56 | throw ('GetHotFixNoEntriesFound,' + 57 | 'Microsoft.PowerShell.Commands.GetHotFixCommand') 58 | } -ParameterFilter { $Id -ne 'KB4565912' } 59 | 60 | # Mock Get-HotFix so that installed is returned only for KB4565912 61 | Mock Get-HotFix { } -ParameterFilter { 62 | $Id -eq 'KB4565912' } 63 | } 64 | It "Superseding Patch is found on the computer" { 65 | $KBFound = Get-VulnerabilityStatus @Vulnerability 66 | $KBFound | Should -Be 'KB4565912' 67 | 68 | # Add the same ParameterFilters to the Should-Invoke to confirm they execute the expected number of times 69 | $cmd = 'Get-HotFix' 70 | Should -Invoke -CommandName $cmd -ParameterFilter { 71 | $Id -ne 'KB4565912' } -Times 4 72 | Should -Invoke -CommandName $cmd -ParameterFilter { 73 | $Id -eq 'KB4565912' } -Times 1 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Chapter13 - Testing your scripts/Listing 11 - Find-KbSupersedence.Integration.Tests.ps1 integration tests.ps1: -------------------------------------------------------------------------------- 1 | # Listing 11 - Find-KbSupersedence.Integration.Tests.ps1 integration tests 2 | BeforeAll { 3 | Set-Location -Path $PSScriptRoot 4 | . ".\Find-KbSupersedence.ps1" 5 | } 6 | 7 | Describe 'Find-KbSupersedence' { 8 | It "KB Article is found" { 9 | # Find-KbSupersedence without a Mock 10 | $KBSearch = 11 | Find-KbSupersedence -KbArticle 'KB4521858' 12 | $KBSearch | Should -Not -Be $null 13 | $KBSearch | Should -HaveCount 3 14 | # Confirm the ID is a GUID 15 | $GuidRegEx = '(\{){0,1}[0-9a-fA-F]{8}\-' + 16 | '[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\' + 17 | '-[0-9a-fA-F]{12}(\}){0,1}' 18 | $KBSearch.Id | Should -Match $GuidRegEx 19 | # Confirm products are populating 20 | $KBSearch.Products | Should -Not -Be $null 21 | # Confirm the number of results that have the expected architecture matches the number of results. 22 | $KBSearch | 23 | Where-Object{ $_.Architecture -in 'x86','AMD64','ARM' } | 24 | Should -HaveCount $KBSearch.Count 25 | # Confirm there are at least nine SupersededBy KB articles 26 | $KB = $KBSearch | Select-Object -First 1 27 | ($KB.SupersededBy | Measure-Object).Count | 28 | Should -BeGreaterOrEqual 9 29 | } 30 | } -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/GitHub-Setup.md: -------------------------------------------------------------------------------- 1 | You will need a GitHub account. A free account will do. 2 | 3 | # Scripted Install 4 | Run the script Install-GitHubCli.ps1 to install Git and GitHub CLI 5 | 6 | # Manual Install 7 | Download and install Git from git-scm.com/downloads 8 | Download and install GitHub CLI from cli.github.com 9 | 10 | # Config Git 11 | After the install open a new PowerShell prompt and enter the commands below 12 | 13 | ```PowerShell 14 | git config --global user.email "you@example.com" 15 | git config --global user.name "Your Name" 16 | gh auth login –web 17 | ``` 18 | 19 | 20 | Invoke-Expression -Command 'git init' 21 | Invoke-Expression -Command 'git add .' 22 | Invoke-Expression -Command 'git commit -m "initial commit"' 23 | Invoke-Expression -Command "gh repo create ""PoshAutomator.$(Get-Date -Format o)"" --private --source=. --remote=upstream" 24 | $url = Select-String -Path .\.git\config -Pattern 'url' | ForEach-Object{ $_.Line.Split('=')[-1] } 25 | Invoke-Expression -Command "git remote add origin $url" 26 | Invoke-Expression -Command "git push -u origin main" -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/PostAutomator-v1.0.0.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdowst/Practical-Automation-with-PowerShell/9399bd4884b8985d434db56bc7d5f984d073fc87/Chapter14 - Maintaining your code/Helper Scripts/PostAutomator-v1.0.0.2.zip -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/test.SUSE.txt: -------------------------------------------------------------------------------- 1 | NAME="SLES" 2 | VERSION="15-SP3" 3 | VERSION_ID="15.3" 4 | PRETTY_NAME="SUSE Linux Enterprise Server 15 SP3" 5 | ID="sles" 6 | ID_LIKE="suse" 7 | ANSI_COLOR="0;32" 8 | CPE_NAME="cpe:/o:suse:sles:15:sp3" 9 | DOCUMENTATION_URL="https://documentation.suse.com/" 10 | VARIANT_ID="sles-basic" -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/test.Ubuntu.txt: -------------------------------------------------------------------------------- 1 | NAME="Ubuntu" 2 | VERSION="20.04.4 LTS (Focal Fossa)" 3 | ID=ubuntu 4 | ID_LIKE=debian 5 | PRETTY_NAME="Ubuntu 20.04.4 LTS" 6 | VERSION_ID="20.04" 7 | HOME_URL="https://www.ubuntu.com/" 8 | SUPPORT_URL="https://help.ubuntu.com/" 9 | BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" 10 | PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" 11 | VERSION_CODENAME=focal 12 | UBUNTU_CODENAME=focal -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/test.df.txt: -------------------------------------------------------------------------------- 1 | Filesystem 1K-blocks Used Available Use% Mounted on 2 | /dev/sda2 50825728 17064828 31149396 36% / -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/test.rhel.txt: -------------------------------------------------------------------------------- 1 | NAME="Red Hat Enterprise Linux" 2 | VERSION="8.2 (Ootpa)" 3 | ID="rhel" 4 | ID_LIKE="fedora" 5 | VERSION_ID="8.2" 6 | PLATFORM_ID="platform:el8" 7 | PRETTY_NAME="Red Hat Enterprise Linux 8.2 (Ootpa)" 8 | ANSI_COLOR="0;31" 9 | CPE_NAME="cpe:/o:redhat:enterprise_linux:8.2:GA" 10 | HOME_URL="https://www.redhat.com/" 11 | BUG_REPORT_URL="https://bugzilla.redhat.com/" 12 | REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 8" 13 | REDHAT_BUGZILLA_PRODUCT_VERSION=8.2 14 | REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" 15 | REDHAT_SUPPORT_PRODUCT_VERSION="8.2" -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Helper Scripts/test.stat.txt: -------------------------------------------------------------------------------- 1 | Filesystem 1K-blocks Used Available Use% Mounted on 2 | /dev/sda2 50825728 17064828 31149396 36% / 3 | PS /home/tux> stat / 4 | File: / 5 | Size: 4096 Blocks: 8 IO Block: 4096 directory 6 | Device: 802h/2050d Inode: 2 Links: 20 7 | Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root) 8 | Access: 2022-04-02 06:49:20.250126169 -0500 9 | Modify: 2021-10-01 13:57:20.213260279 -0500 10 | Change: 2021-10-01 13:57:20.213260279 -0500 11 | Birth: 2021-10-01 13:57:20.213260279 -0500 -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Listing 01 - Get-SystemInfo Test before updating.ps1: -------------------------------------------------------------------------------- 1 | # Listing 1 - Get-SystemInfo Test before updating 2 | # Import the module 3 | $ModulePath = Split-Path $PSScriptRoot 4 | Import-Module (Join-Path $ModulePath 'PoshAutomator.psd1') -Force 5 | 6 | # Set the module scope to the module you are testing 7 | InModuleScope -ModuleName PoshAutomator { 8 | Describe 'Get-SystemInfo' { 9 | # Test Get-SystemInfo generic results to ensure data is returned 10 | Context "Get-SystemInfo works" { 11 | It "Get-SystemInfo returns data" { 12 | $Info = Get-SystemInfo 13 | $Info.Caption | Should -Not -BeNullOrEmpty 14 | $Info.InstallDate | Should -Not -BeNullOrEmpty 15 | $Info.ServicePackMajorVersion | Should -Not -BeNullOrEmpty 16 | $Info.OSArchitecture | Should -Not -BeNullOrEmpty 17 | $Info.BootDevice | Should -Not -BeNullOrEmpty 18 | $Info.BuildNumber | Should -Not -BeNullOrEmpty 19 | $Info.CSName | Should -Not -BeNullOrEmpty 20 | $Info.Total_Memory | Should -Not -BeNullOrEmpty 21 | } 22 | } 23 | 24 | # Test Get-SystemInfo results with mocking to ensure data that is returned matches the expected values 25 | Context "Get-SystemInfo returns data" { 26 | BeforeAll { 27 | Mock Get-CimInstance { 28 | Import-Clixml -Path ".\Get-CimInstance.Windows.xml" 29 | } 30 | } 31 | It "Get-SystemInfo Windows 11" { 32 | $Info = Get-SystemInfo 33 | $Info.Caption | Should -Be 'Microsoft Windows 11 Enterprise' 34 | $Date = Get-Date '10/21/2021 5:09:00 PM' 35 | $Info.InstallDate | Should -Be $Date 36 | $Info.ServicePackMajorVersion | Should -Be 0 37 | $Info.OSArchitecture | Should -Be '64-bit' 38 | $Info.BootDevice | Should -Be '\Device\HarddiskVolume3' 39 | $Info.BuildNumber | Should -Be 22000 40 | $Info.CSName | Should -Be 'MyPC' 41 | $Info.Total_Memory | Should -Be 32 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Listing 02 - Get-SystemInfo.ps1: -------------------------------------------------------------------------------- 1 | # Listing 2 - Get-SystemInfo.ps1 2 | Function Get-SystemInfo{ 3 | [CmdletBinding()] 4 | param() 5 | # Check if the machine is running a Linux-based OS 6 | if(Get-Variable -Name IsLinux -ValueOnly){ 7 | # Get the data from the os-release file, and convert it to a PowerShell object 8 | $OS = Get-Content -Path /etc/os-release | 9 | ConvertFrom-StringData 10 | 11 | # Search the meminfo file for the MemTotal line and extract the number 12 | $search = @{ 13 | Path = '/proc/meminfo' 14 | Pattern = 'MemTotal' 15 | } 16 | $Mem = Select-String @search | 17 | ForEach-Object{ [regex]::Match($_.line, "(\d+)").value} 18 | 19 | # Run the stat command, parse the output for the Birth line, and then extract the date 20 | $stat = Invoke-Expression -Command 'stat /' 21 | $InstallDate = $stat | Select-String -Pattern 'Birth:' | 22 | ForEach-Object{ 23 | Get-Date $_.Line.Replace('Birth:','').Trim() 24 | } 25 | 26 | # Run the df and uname commands, and save the output as is 27 | $boot = Invoke-Expression -Command 'df /boot' 28 | $OSArchitecture = Invoke-Expression -Command 'uname -m' 29 | $CSName = Invoke-Expression -Command 'uname -n' 30 | 31 | # Build the results into a PowerShell object that matches the same properties as the existing Windows output 32 | [pscustomobject]@{ 33 | Caption = $OS.PRETTY_NAME.Replace('"',"") 34 | InstallDate = $InstallDate 35 | ServicePackMajorVersion = $OS.VERSION.Replace('"',"") 36 | OSArchitecture = $OSArchitecture 37 | BootDevice = $boot.Split("`n")[-1].Split()[0] 38 | BuildNumber = $OS.VERSION_ID.Replace('"',"") 39 | CSName = $CSName 40 | Total_Memory = [math]::Round($Mem/1MB) 41 | } 42 | } 43 | else{ 44 | # Original Windows system information commands 45 | Get-CimInstance -Class Win32_OperatingSystem | 46 | Select-Object Caption, InstallDate, ServicePackMajorVersion, 47 | OSArchitecture, BootDevice, BuildNumber, CSName, 48 | @{l='Total_Memory'; 49 | e={[math]::Round($_.TotalVisibleMemorySize/1MB)}} 50 | } 51 | } -------------------------------------------------------------------------------- /Chapter14 - Maintaining your code/Listing 4 - Get-SystemInfo.yaml: -------------------------------------------------------------------------------- 1 | # Listing 4 - Get-SystemInfo.yaml 2 | name: PoshAutomator Pester Tests 3 | on: 4 | pull_request: 5 | types: [opened, reopened] 6 | 7 | jobs: 8 | pester-test: 9 | name: Pester test 10 | runs-on: windows-latest 11 | steps: 12 | - name: Check out repository code 13 | uses: actions/checkout@v3 14 | - name: Run the Get-SystemInfo.Unit.Test.ps1 Test File 15 | shell: pwsh 16 | run: | 17 | Invoke-Pester .\Test\Get-SystemInfo.Test.ps1 -Passthru -------------------------------------------------------------------------------- /LabSetup/README.md: -------------------------------------------------------------------------------- 1 | # Practical-Automation-with-PowerShell Lab Setup 2 | The files contained here can be used to setup your lab environment, that will help you follow all the examples in the book 3 | 4 | ## MainMachinceSetup.ps1 5 | Installs and setups most software required for the book 6 | - PowerShell 7 7 | - Chocolatey 8 | - Git 9 | - Visual Studio Code 10 | - Visual Studio Code Extensions 11 | - PowerShell 12 | - GitHub Pull Requests and Issues 13 | - Shell launcher 14 | - Downloads this Repository locally -------------------------------------------------------------------------------- /PoSHAutomate.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "terminal.integrated.shell.windows": "c:\\Program Files\\PowerShell\\7\\pwsh.exe", 9 | "files.defaultLanguage": "powershell", 10 | "shellLauncher.shells.windows": [ 11 | { 12 | "shell": "C:\\Windows\\System32\\cmd.exe", 13 | "label": "cmd" 14 | }, 15 | { 16 | "shell": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", 17 | "label": "PowerShell" 18 | }, 19 | { 20 | "shell": "c:\\Program Files\\PowerShell\\7\\pwsh.exe", 21 | "label": "PowerShell Core" 22 | }, 23 | { 24 | "shell": "C:\\Program Files\\Git\\bin\\bash.exe", 25 | "label": "Git bash" 26 | }, 27 | { 28 | "shell": "C:\\Windows\\System32\\bash.exe", 29 | "label": "WSL Bash" 30 | } 31 | ] 32 | } 33 | } --------------------------------------------------------------------------------