roe.Misc.psm1
function Build-PowerShellModule { <# .SYNOPSIS Builds PowerShell module from functions found in given path .DESCRIPTION Takes function from seperate files in FunctionsPath and builds a PowerShell module to ModuleDir. Existing module files in ModuleDir will be overwritten. FunctionsPath must include one separate file for each function to include. The full content of the file will be included as a function. If a Function declaration is not found as the first line, it will be automatically created. Build version for module is incrementeted by 1 on each build, relative to version found in ManifestFile. .PARAMETER FunctionsPath Path to individual functions files .PARAMETER ModuleDir Directory to export finished module to .PARAMETER ModuleName Name of module. If omitted name will be taken from Manifest. If no manifest can be found, name will be autogenerated as Module-yyyyMMdd .PARAMETER ManifestFile Path to .psd1 file to use as template. If omitted the first .psd1 file found in ModuleDir will be used. If no file can be found a default will be created .PARAMETER Description Description of module .PARAMETER RunScriptAtImport Script to run when module is imported. Will be added to .psm1 as-is #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateScript({Test-Path -path $_})] [String]$FunctionsPath, [Parameter(Mandatory = $true)] [string]$ModuleDir, [Parameter(Mandatory = $false)] [string]$ModuleName, [Parameter(Mandatory = $false)] [String]$ManifestFile, [Parameter(Mandatory = $false)] [String]$Description, [Parameter(Mandatory = $false)] [String]$RunScriptAtImport, [Parameter(Mandatory = $false)] [switch]$RetainVersion ) $ErrorActionPreference = 'Stop' switch ($ModuleDir) { {-not (Test-Path -Path $_ -ErrorAction SilentlyContinue)} {Write-Verbose "Creating $_" ; $null = New-Item -Path $_ -ItemType Directory ; break} {-not (Get-Item -Path $ModuleDir -ErrorAction SilentlyContinue).PSIsContainer} {Throw "$ModuleDir is not a valid directory path"} default {} } # Make sure we have a valid Manifest file if ([string]::isnullorwhitespace($ManifestFile)) { Write-Verbose "Getting first manifest file from $ModuleDir" $ManifestFile = (Get-Item -Path (Join-Path -Path $ModuleDir -ChildPath "*.psd1") | Select-Object -First 1).FullName } if ([string]::isnullorwhitespace($ManifestFile)) { if ([string]::IsNullOrWhiteSpace($ModuleName)) { Write-Verbose "Generating Modulename" $ModuleName = "Module-$(Get-Date -format yyyyMMdd)" } Write-Verbose "Creating default manifestfile" $ManifestFile = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" New-ModuleManifest -Path $ManifestFile -ModuleVersion 0.0.0 } # Get content of manifest $ManifestHash = [ScriptBlock]::Create((Get-Content -Path $ManifestFile -Raw)).InvokeReturnAsIs() # Make sure destination files will end up in the ModuleDir if ([string]::isnullorwhitespace($ModuleName)) { Write-Verbose "Naming module after $Manifestfile" $ModuleName = split-path -path $ManifestFile -LeafBase } $ManifestFile = Join-Path -path $moduleDir -childPath "$ModuleName.psd1" $moduleFile = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psm1" $ManifestHash["RootModule"] = "$ModuleName.psm1" if (-not [String]::isnullorwhitespace($Description)) { Write-Verbose "Adding description to manifest" $ManifestHash["Description"] = $Description } [Version]$Version = $manifestHash["ModuleVersion"] if (-not $RetainVersion) { # Increment version number if ($Version.Minor -eq 9 -and $Version.Build -eq 9) { Write-Verbose "Incrementing major version" [version]$Version = "$($version.Major + 1).0.0" } elseif ($Version.Build -lt 9) { Write-Verbose "Incrementing build version" [version]$Version = "$($Version.Major).$($Version.Minor).$($Version.Build + 1)" } elseif ($Version.Minor -lt 9 -and $version.build -eq 9){ Write-Verbose "Incrementing minor version" [Version]$Version = "$($Version.Major).$($Version.Minor + 1).0" } else { Write-Warning "WTF?" } } else { Write-Verbose "Retaining version: $($Version)" } # Get content of PS1 files in FunctionsPath $moduleContent = @( Get-Item -ErrorAction SilentlyContinue -Path (Join-Path -Path $FunctionsPath -childPath "*.ps1") | Sort-Object -Property BaseName | ForEach-Object -Process { Write-Verbose "Processing functionfile: $_" $FunctionsToExport += @($_.BaseName) $FunctionContent = Get-Content -Path $_ | Where-Object {-not [String]::isnullorwhitespace($_)} if ($FunctionContent[0].trim() -match "^Function") { Write-Verbose "Detected function" Get-Content -Path $_ -Raw } else { Write-Verbose "Adding function declaration" $Return = @() $Return += "Function $($_.BaseName) {" $Return += Get-Content -Path $_ -Raw $Return += "}" $Return } } ) if (-not [String]::IsNullOrWhiteSpace($RunScriptAtImport)) { Write-Verbose "Adding RunScriptAtImport" $moduleContent += $RunScriptAtImport } Write-Verbose "Writing $Modulefile" Set-Content -Path $moduleFile -Value $moduleContent # Update manifest $ManifestHash["Path"] = $manifestFile $ManifestHash["ModuleVersion"] = $version $ManifestHash['FunctionsToExport'] = $functionsToExport Write-Verbose "Updating manifest" Update-ModuleManifest @ManifestHash $props = [ordered]@{"ModuleName" = $ModuleName "Version" = $Version "Manifest" = (Get-Item -Path $manifestFile).FullName "Module" = (Get-Item -Path $moduleFile).FullName } return New-Object -TypeName PSObject -Property $props } Function Clear-UserVariables { #Clear any variable not defined in the $SysVars variable if (Get-Variable -Name SysVars -ErrorAction SilentlyContinue) { $UserVars = get-childitem variable: | Where-Object {$SysVars -notcontains $_.Name} ForEach ($var in $UserVars) { Write-Host ("Clearing $" + $var.name) Remove-Variable $var.name -Scope 'Global' } } else { Write-Warning "SysVars variable not set" break } } Function Connect-EXOPartner { param( [parameter(Mandatory = $false)] [System.Management.Automation.CredentialAttribute()] $Credential, [parameter(Mandatory = $false)] [string] $TenantDomain ) if (-not $TenantDomain) { $TenantDomain = Read-Host -Prompt "Input tenant domain, e.g. hosters.com" } if (-not $Credential) { $Credential = Get-Credential -Message "Credentials for CSP delegated admin, e.g. ""bm@klestrup.dk""/""password""" } $ExSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$TenantDomain" -Credential $Credential -Authentication Basic -AllowRedirection if ($ExSession) {Import-PSSession $ExSession} } Function ConvertTo-HashTable { <# .SYNOPSIS Converts PS Object, or JSON string, to Hashtable #> # https://stackoverflow.com/questions/3740128/pscustomobject-to-hashtable param ( [Parameter(ValueFromPipeline)] $PSObject ) process { if ($null -eq $PSObject -and $null -eq $JSON ) { return $null } if ($PSObject -is [string]) { try { $PSObject = $PSObject | ConvertFrom-Json } catch { [string]$PSObject = $PSObject #Uncast string parameter is both type [psobject] and [string] } } if ($PSObject -is [Hashtable] -or $PSObject -is [System.Collections.Specialized.OrderedDictionary]) { return $PSObject } if ($PSObject -is [System.Collections.IEnumerable] -and $PSObject -isnot [string]) { $collection = @( foreach ($object in $PSObject) { ConvertTo-HashTable $object } ) Write-Output -NoEnumerate $collection } elseif ($PSObject -is [psobject]) { $hash = [ordered]@{} foreach ($property in $PSObject.PSObject.Properties) { $hash[$property.Name] = ConvertTo-HashTable $property.Value } return $hash } else { return $PSObject } } } function Find-NugetPackage { <# .SYNOPSIS Finds URL for NuGet package in PSGallery .DESCRIPTION Finds, and optionally downloads, URL for NuGet packages in PSGallery. Can be used as substitute for Install-Module in circumstances where it is not possible to install the required PackageProviders. .PARAMETER Name Name of module to find .Parameter All Get all versions of module .PARAMETER Version Get specific version of module .Parameter IncludeDependencies Get info for all dependecies for module (doesn't work with -All) .Parameter KeepDuplicateDependencies Don't clean up output, if several versions of the same module is named as dependency. If omitted only the newest version of depedency modules will be returned. .Parameter DownloadTo Download found modules to path #> # Moddified from https://www.powershellgallery.com/packages/BuildHelpers/2.0.16/Content/Public%5CFind-NugetPackage.ps1 [CMDLetbinding()] Param ( [Parameter(Mandatory = $true)] [String]$Name, [Parameter(Mandatory = $false)] [switch]$All, [Parameter(Mandatory = $false)] [string]$Version, [Parameter(Mandatory = $false)] [switch]$IncludeDependencies, [Parameter(Mandatory = $false)] [switch]$KeepDuplicateDependencies, [Parameter(Mandatory = $false)] [String]$DownloadTo ) # Return stuff in this $Result = @() $PackageSourceUrl = "https://www.powershellgallery.com/api/v2/" #Figure out which version to find if ($PSBoundParameters.ContainsKey("Version")) { Write-Verbose "Searching for version [$version] of [$name]" $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name' and Version eq '$Version'" } elseif ($PSBoundParameters.ContainsKey("All")) { Write-Verbose "Searching for all versions of [$name] module" $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name'" } else { Write-Verbose "Searching for latest [$name] module" $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name' and IsLatestVersion" } $NUPKG = @(Invoke-RestMethod $URI -Verbose:$false) if ($Null -eq $NUPKG) { Write-Warning "No result for module $Name" } foreach ($pkg in $NUPKG) { $PkgDependencies = $pkg.properties.Dependencies.split("|") $Dependencies = @() foreach ($Dependency in ($PkgDependencies | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { # Dependency will be formatted similar to (the lonely [ is not a typo): # Az.Accounts:[2.7.1, ): try { $DepName = $Dependency.split(":")[0] $DepVersion = (($Dependency.split(":")[1].split(",")[0] | Select-String -Pattern "\d|\." -AllMatches).Matches | ForEach-Object { $_.Value }) -join "" $Dependencies += New-Object -TypeName PSObject -property ([ordered]@{"Module" = $DepName; "Version" = $DepVersion }) } catch { Write-Warning $_.Exception.Message throw $_ } } # Not sure in which cases NormalizedVersion and Version will differ, but the original author made a distinction so so do I # And in case of -preview version numbers or similar, move the -preview part to the module name, so we can treat the rest of the version as a [version]. Not perfect but best we can do. [string]$PkgVersion = if ($pkg.properties.NormalizedVersion) { $pkg.properties.NormalizedVersion } else { $pkg.properties.Version } if ($pkgVersion -match "-") { [version]$PkgVersion = ($pkg.properties.NormalizedVersion | Select-String -AllMatches -Pattern "\d|\." | ForEach-Object { $_.Matches.value }) -join "" $pkgName = "$($pkg.title.('#text'))-$($pkg.properties.NormalizedVersion.split("-",2)[-1])" } else { [version]$PkgVersion = $PkgVersion $pkgName = $pkg.title.('#text') } $Props = [ordered]@{"Name" = $PkgName "Author" = $pkg.author.name "Version" = $pkgVersion "URI" = $pkg.content.src "Description" = $pkg.properties.Description "Properties" = $pkg.properties "NuGet" = $pkg "Dependencies" = $Dependencies } Write-Verbose "Finished collecting for $($pkgName) $($pkgVersion)" $Result += New-Object -TypeName PSObject -Property $Props } if ($IncludeDependencies) { # When function is called by itself, get a list of already retrieved dependencies, so we don't waste time getting info for the same module multiple times unless there is a requirement for a newer version $CurrentDependencies = [ordered]@{} foreach ($dep in $Dependencies ) { $CurrentDependencies.add($dep.Module, $dep.Version) } try { # If we can retrieve $KnownDependencies from parent scope this is most likely a nested run. Maybe use $Script scope... need testing try { $KnownDependencies = Get-Variable -Name KnownDependencies -ValueOnly -Scope 1 } catch { } $GetDependencies = @{} foreach ($key in $CurrentDependencies.Keys) { if ($KnownDependencies[$key] -lt $CurrentDependencies[$key]) { $KnownDependencies[$key] = $CurrentDependencies[$key] $GetDependencies[$key] = $CurrentDependencies[$key] } } # Now we have updated $KnownDependencies with any new versions, so return it to parent for the next modules dependency check Set-Variable -Name KnownDependencies -Value $KnownDependencies -Scope 1 } catch { # If we cant retrieve $KnownDependencies from parent scope, this is most likely first iteration of function # Save a list of dependencymodules we already know we need to get. $KnownDependencies = $CurrentDependencies $GetDependencies = $KnownDependencies } # For some reason a "Collection was modified" error is thrown if referencing the keys directly. $GetModules = $GetDependencies.keys | ForEach-Object {$_.ToString()} foreach ($module in $GetModules) { if ($Module -eq "Az.Accounts") { Write-Verbose "Finding dependency for $($pkgName) $($Pkgversion): $($module) $($GetDependencies[$module])" } $Result += Find-Nugetpackage -Name $module -Version $GetDependencies[$module] -IncludeDependencies -KeepDuplicateDependencies:$PSBoundParameters.ContainsKey("KeepDuplicateDependencies") } } if (-not $KeepDuplicateDependencies) { # Only keep latest version of each dependecy $DuplicateModules = $Result | Group-Object -Property Name | Where-Object {$_.Count -gt 1} | Select-object -ExpandProperty Name Write-Verbose "$($DuplicateModules.count) duplicate dependency-module versions found" $RemoveVersions = @() foreach ($Module in $DuplicateModules) { $RemoveVersions += $Result | Where-Object {$_.Name -eq $Module} | Sort-Object -Property Version -Descending | Select-object -Skip 1 Write-Verbose "Removing duplicates of $Module" } $Result = $Result | Where-Object {$_ -notin $RemoveVersions} } if (-not [string]::IsNullOrWhiteSpace($DownloadTo)) { if (-not (Test-Path -Path $DownloadTo -ErrorAction SilentlyContinue)) { $null = New-Item -Path $DownloadTo -Force -ItemType Directory } $DownloadCounter = 1 foreach ($Module in $Result) { $ZipFile = Join-Path $DownloadTo -ChildPath $($Module.Name) -AdditionalChildPath "$($Module.version).zip" $ExtractDir = $ZipFile.replace(".zip", "") if (-not (Test-Path -Path $ExtractDir -ErrorAction SilentlyContinue)) { $null = New-Item -Path $ExtractDir -ItemType Directory -Force } Write-Verbose "$($DownloadCounter)/$($Result.count) : Downloading $($Module.Name) $($Module.Version) to $ZipFile" Invoke-WebRequest -Uri $Module.Uri -OutFile $ZipFile Expand-Archive -Path $ZipFile -DestinationPath $ExtractDir -Force Remove-Item -Path $ZipFile $DownloadCounter++ } } return $Result } Function Get-COMObjects { if (-not $IsLinux) { $Objects = Get-ChildItem HKLM:\Software\Classes -ErrorAction SilentlyContinue | Where-Object {$_.PSChildName -match '^\w+\.\w+$' -and (Test-Path -Path "$($_.PSPath)\CLSID")} $Objects | Select-Object -ExpandProperty PSChildName } } Function Get-GraphAPIHeaders { [CMDLetbinding()] param ( [string]$AppID, [string]$AppSecret, [string]$TenantID, [string]$AccessToken, [string]$ResourceURL = "https://graph.microsoft.com/" ) # Get an Graph API header, with token if ((-not $AccessToken) -and ($appid)) { $RequestBody = @{client_id = $appID; client_secret = $AppSecret; grant_type = "client_credentials"; scope = "https://graph.microsoft.com/.default"; } $OAuthResponse = $OAuthResponse = (Invoke-Webrequest -UseBasicParsing -Method Post -Uri https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token -Body $RequestBody).content | ConvertFrom-Json $AccessToken = $OAuthResponse.access_token } elseif (-not $AccessToken) { $AccessToken =(Get-AzAccessToken -ResourceUrl $ResourceURL).Token } $headers = @{ "Authorization" = "Bearer $AccessToken" "Content-Type" = "application/json" } return $headers } Function Get-MatchingString { param( [string]$Text, [string]$RegEx, [switch]$CaseSensitive ) $Text | Select-String -Pattern $RegEx -AllMatches -CaseSensitive:$CaseSensitive | ForEach-Object {$_.Matches.Value} } Function Get-NetIPAdapters { Param( [Parameter(Mandatory = $false)] [String[]]$ComputerName ) if ($ComputerName.length -lt 1 -or $computername.Count -lt 1) { $computername = @($env:COMPUTERNAME) } $OutPut = @() #Vis netkort med tilhørende IP adr. foreach ($pc in $Computername) { $OutPut += Get-NetAdapter -CimSession $pc | Select-Object Name,InterfaceDescription,IfIndex,Status,MacAddress,LinkSpeed,@{N="IPv4";E={(Get-NetIpaddress -CimSession $pc -InterfaceIndex $_.ifindex -AddressFamily IPv4 ).IPAddress}},@{N="IPv6";E={(Get-NetIpaddress -CimSession $pc -InterfaceIndex $_.ifindex -AddressFamily IPv6 ).IPAddress}},@{N="Computer";E={$pc}} | Sort-Object -Property Name } $OutPut } Function Get-NthDayOfMonth { <# .SYNOPSIS Returns the Nth weekday of a specified month .DESCRIPTION Returns the Nth weekday of a specified month. If no weekday is specified an array is returned containing dates for the 7 weekdays throughout the specified month. If no Month or Year is specified current month will be used. .PARAMETER Month The month to process, in numeric format. If no month is specified current month is used. .PARAMETER Weekday The weekday to lookup. If no weekday is specified, all weekdays are returned. .PARAMETER Number The week number in the specified month. If no number is specified, all weekdays are returned. .PARAMETER Year The year to process. If no year is specified, current year is used. .INPUTS None. You cannot pipe objects to this function .OUTPUTS PSCustomObject .EXAMPLE PS> #Get the 3rd tuesday of march 1962 PS> Get-NthDayOfMonth -Month 3 -Year 1962 -Weekday Tuesday -Number 3 20. marts 1962 00:00:00 #> [cmdletbinding()] param ( [validateset(1,2,3,4,5,6,7,8,9,10,11,12)] [int]$Month, [ValidatePattern("^[0-9]{4}$")] [int]$Year, [validateset("Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday")] [string]$Weekday, [validateset(-5,-4,-3,-2,-1,0,1,2,3,4,5)] $Number ) if ($number) { [int]$number = $number #If parameter is cast as [int] in param section it will default to 0. If no type is defined null value is allowed, but any value will be string by default } #Find last date of current month. Workarounds to avoid any cultural differences (/,- or whatever as date seperator as well as mm/dd/yyyy, yyyy/dd/mm or whatever syntax) $BaseDate = Get-Date if ($Month) { $CurMonth = [int](Get-Date -Format MM) if ($CurMonth -ge $Month) { $BaseDate = (Get-Date $BaseDate).AddMonths(-($CurMonth - $Month)) } else { $BaseDate = (Get-Date $BaseDate).AddMonths(($Month - $CurMonth)) } } if ($Year) { $CurYear = [int](Get-Date -Format yyyy) if ($CurYear -ge $Year) { $BaseDate = (Get-Date $BaseDate).AddYears(-($CurYear - $Year)) } else { $BaseDate = (Get-Date $BaseDate).AddYears(($Year - $CurYear)) } } $CurDate = (Get-Date $BaseDate).Day if ($CurDate -gt 1) { $FirstDate = (Get-Date $BaseDate).AddDays(-($CurDate -1)).Date } else { $FirstDate = (Get-Date $BaseDate).date } $NextMonth = (Get-Date $FirstDate).AddMonths(1) $LastDate = (Get-Date $NextMonth).AddDays(-1) # Build the object to get dates for each weekday $Props = [ordered]@{"Monday" = @() "Tuesday" = @() "Wednesday" = @() "Thursday" = @() "Friday" = @() "Saturday" = @() "Sunday" = @() } $DaysOfMonth = New-Object -TypeName PSObject -Property $Props #We start on day one and add the numeric values to parse through the dates $DaysToProcess = @(0..($LastDate.Day - 1)) Foreach ($Day in $DaysToProcess) { $Date = (Get-Date $FirstDate).AddDays($Day) #Get dates corresponding the 7 weekdays $CurDayValue = $Date.DayOfWeek.value__ if ($CurDayValue -eq 0) { $DaysOfMonth.Sunday += $Date } if ($CurDayValue -eq 1) { $DaysOfMonth.Monday += $Date } if ($CurDayValue -eq 2) { $DaysOfMonth.Tuesday += $Date } if ($CurDayValue -eq 3) { $DaysOfMonth.Wednesday += $Date } if ($CurDayValue -eq 4) { $DaysOfMonth.Thursday += $Date } if ($CurDayValue -eq 5) { $DaysOfMonth.Friday += $Date } if ($CurDayValue -eq 6) { $DaysOfMonth.Saturday += $Date } } $NumberWithinRange = ($number -ge -$DaysOfMonth.$Weekday.count -and $number -le ($DaysOfMonth.$Weekday.Count -1) -and -not [string]::IsNullOrWhiteSpace($number) ) if ($Weekday -and $NumberWithinRange) { if ($number -lt 0) { $number = $number } else { $number = $number - 1 } Return $DaysOfMonth.$Weekday[($number)] } if ($Weekday -and -not $NumberWithinRange -and -not [string]::IsNullOrWhiteSpace($number)) { Write-Warning "No $Weekday number $number in selected month" break } if ($Weekday) { Return $DaysOfMonth.$Weekday } if ($Number) { $Days = $DaysOfMonth | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name | Sort-Object foreach ($Day in $days) { #Recycle the $props from earlier with weekdays in correct order $props.$Day = $DaysOfMonth.$Day[($number -1)] } $Result = New-Object -TypeName PSObject -Property $props Return $Result } Return $DaysOfMonth } Function Get-StringASCIIValues { [CMDLetbinding()] param ( [string]$String ) return $String.ToCharArray() | ForEach-Object {$_ + " : " + [int][Char]$_} } Function Get-StringHash { #https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.1 (ex. 4) [CMDLetBinding()] param ( [Parameter(Mandatory = $true)] [string[]]$String, [Parameter(Mandatory = $false)] [ValidateSet("SHA1","SHA256","SHA384","SHA512","MACTripleDES","MD5","RIPEMD160")] [string]$Algorithm = "SHA256", [Parameter(Mandatory = $false)] [int]$GroupCount = 2, [Parameter(Mandatory = $false)] [String]$Seperator = "-" ) $Result = @() Write-Verbose "Received $($String.count) string(s)" Write-Verbose "Using algorithm: $Algorithm" $stringAsStream = [System.IO.MemoryStream]::new() $writer = [System.IO.StreamWriter]::new($stringAsStream) foreach ($t in $String) { $writer.write($t) $writer.Flush() $stringAsStream.Position = 0 $HashString = Get-FileHash -InputStream $stringAsStream -Algorithm $Algorithm | Select-Object -ExpandProperty Hash Write-Verbose "$($HashString.length) characters in hash" } Write-Verbose "Dividing string to groups of $GroupCount characters, seperated by $Seperator" for ($x = 0 ; $x -lt $HashString.Length ; $x = $x + $GroupCount) { $Result += $HashString[$x..($x + ($GroupCount -1))] -join "" } Write-Verbose "$($Result.count) groups" $Result = $Result -join $Seperator Write-Verbose "Returning $($Result.length) character string" return $Result } Function Get-Unicode { param( [string]$Word ) $word.ToCharArray() | ForEach-Object { $_ + ": " + [int][char]$_ } } Function Get-UserVariables { #Get, and display, any variable not defined in the $SysVars variable get-childitem variable: | Where-Object {$SysVars -notcontains $_.Name} } Function Get-WebRequestError { <# .SYNOPSIS Read more detailed error from failed Invoke-Webrequest and Invoke-RestMethod https://stackoverflow.com/questions/35986647/how-do-i-get-the-body-of-a-web-request-that-returned-400-bad-request-from-invoke .Parameter ErrorObject $_ from a Catch block #> [CMDLetbinding()] param ( [object]$ErrorObject ) $streamReader = [System.IO.StreamReader]::new($ErrorObject.Exception.Response.GetResponseStream()) $ErrResp = $streamReader.ReadToEnd() | ConvertFrom-Json $streamReader.Close() return $ErrResp } Function New-CSVExportable { param($Object ) # Gennemgår properties for alle elementer, og tilføjer manglende så alle elementer har samme properties til CSV eksport eller lignende $AllMembers = @() foreach ($Item in $Object) { $ItemMembers = ($item | ConvertTo-Csv -NoTypeInformation -Delimiter ";")[0] -split ";" -replace '"','' #For at sikre vi får alle properties i den korrekte rækkefølge (Get-Member kan være lidt tilfældig i rækkefølgen) foreach ($itemmember in $ItemMembers) { if ($ItemMember -notin $AllMembers) { $AllMembers += $ItemMember } } } #$AllMembers = $AllMembers | Select-Object -Unique for ($x = 0 ; $x -lt $Object.Count ; $x++) { $CurMembers = $Object[$x] | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name for ($y = 0 ; $y -lt $AllMembers.count ; $y++) { if ($AllMembers[$y] -notin $CurMembers) { $Object[$x] | Add-Member -MemberType NoteProperty -Name $AllMembers[$y] -Value "N/A" } } } return $Object } Function New-YAMLTemplate { <# .SYNOPSIS Generates Azure Pipelines based on the comment based help, and parameter definitions, in the input script .Description Generates Azure Pipelines based on the comment based help in the input script. For help on comment based help see https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help?view=powershell-7.2 Script parameters are parsed, and YAML template, pipeline and variable files are generated with default values, and validateset values prepopulated. Default variables that will be defined in pipeline yaml: deployment: DeploymentDisplayName converted to lower-case, spaces replaced with underscores, and non-alphanumeric characters removed. deploymentDisplayName: Value of DeploymentDisplayName parameter For each supplied environment, default variables will be created: <ENV>_ServiceConnectionAD: '<ServiceConnection parameter value>' <ENV>_ServiceConnectionResources: '<ServiceConnection parameter value>' <ENV>_EnvironmentName: '<ENV>' Unless otherwise specified with the -Agent parameter, the template will use the first of the valid options from the validateset. The script will output the following files: if -WikiOnly is not specified: <scriptname>.yml - the template for the script itself. deploy-<scriptname>.yml.sample - a sample pipeline yaml. deploy-<scriptname>-<environment>-vars.yml - a variable file for the <environment> stage of the pipeline file. One file for each environment specified in -Environment parameter if -WikiOnly, or -Wiki, is specified: <scriptname>.md - A Wiki file based on the comment based help. Outputfiles will be placed in the same directory as the source script, unless the -OutDir parameter is specified. The template for the script will have the extension .yml and the sample files will have the extension .yml.sample so the yaml selection list in Azure DevOps isn't crowded. .Parameter ScriptPath Path to script to generate YAML templates for .Parameter DeploymentDisplayName Display Name of deployment when pipeline is run. Will default to "Deployment of <scriptfile>" .Parameter Environment Name of environment(s) to deploy to. Will default to "Dev" .Parameter Overwrite Overwrite existing YAML files .Parameter ServiceConnection Name of serviceconnection. Will default to "IteraCCoEMG" .Parameter Wiki Generate Wiki file as well as template files. .Parameter WikiOnly Determines if only a Wiki should be generated. .PARAMETER NoSample Don't add .sample extension to pipeline files .Parameter OutDir Directory to output files to. If omitted file will be written to script location. .Parameter Agent Agent type to use when running pipeline. If none specified, default will be first in ValidateSet. .Example PS> $ScriptPath = "C:\Scripts\AwesomeScript.ps1" PS> New-YAMLTemplate -ScriptPath $ScriptPath -Environment "DEV","TEST" -Overwrite This will generate a template file, a pipeline file and two variable files for deployment of C:\Scripts\AwesomeScript.ps1 to DEV and TEST environments. Existing files will be overwritten, and files placed in C:\Scripts No Wiki file will be created. .Example PS> $ScriptPath = "C:\Scripts\AwesomeScript.ps1" PS> New-YAMLTemplate -ScriptPath $ScriptPath -Wiki -Environment Prod This will generate a template file, a pipeline file, a single varibles files for deployment of C:\Scripts\AwesomeScript.ps1 to Prod environment, as well as a Wiki file. If files already exist the script will return a message stating no files are generated. .Example PS> $ScriptPath = "C:\Scripts\AwesomeScript.ps1" PS> New-YAMLTemplate -ScriptPath $ScriptPath -WikiOnly -OutDir C:\Wikis -OverWrite This will generate a Wiki file only as C:\Wikis\AwesomeScript.md If the file already exist, it will be overwritten. #> # TODO: Fix so Wikis doesn't strip $-signs [CMDLetbinding()] param ( [Parameter(Mandatory = $true)] [ValidateScript({ Get-ChildItem -File -Path $_ })] [String]$ScriptPath, [Parameter(Mandatory = $false)] [String]$DeploymentDisplayName = "Deployment of $(Split-Path -Path $ScriptPath -Leaf)", [Parameter(Mandatory = $false)] [String[]]$Environment = "Test", [Parameter(Mandatory = $false)] [Switch]$Overwrite, [Parameter(Mandatory = $false)] [String]$ServiceConnection = "IteraCCoEMG", [Parameter(Mandatory = $false)] [switch]$WikiOnly, [Parameter(Mandatory = $false)] [switch]$Wiki, [Parameter(Mandatory = $false)] [Switch]$NoSample, [Parameter(Mandatory = $false)] [ValidateScript({ Test-Path -Path $_ })] [String]$OutDir, [Parameter(Mandatory = $false)] [ValidateSet("Mastercard Payment Services", "vmImage: windows-latest", "vmImage: ubuntu-latest")] [String]$Agent ) # Pipeline PowerShell task: https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/powershell?view=azure-devops $ScriptName = Split-Path -Path $ScriptPath -Leaf $ScriptDirectory = Split-Path -Path $ScriptPath -Parent # This weird way of getting the file is necessary to get filesystem name casing. Otherwise whatever casing is passed in the parameter is used. # Should be obsolete after lower-case standards have been decided, except for producing a warning. $ScriptFile = Get-Item -Path "$ScriptDirectory\*.*" | Where-Object { $_.Name -eq $ScriptName } # Retrieve info from comment-based help and parameter definitions. $ScriptHelp = Get-Help -Name $ScriptPath -full $ScriptCommand = (Get-Command -Name $ScriptPath) $ScriptCommandParameters = $ScriptCommand.Parameters $ScriptHelpParameters = $ScriptHelp.parameters $ScriptBaseName = $ScriptFile.BaseName $VariablePrefix = $ScriptBaseName.replace("-", "_").ToLower() # String to prefix variable names, to avoid duplicate names when adding several scripts to same pipeline varaibles file $ScriptExamples = $ScriptHelp.Examples $ScriptSynopsis = ($ScriptHelp.Synopsis | foreach-object { $_ | Out-String } ) -split "`r`n|`n" $ScriptNotes = if ($ScriptHelp.AlertSet.Alert.count -gt 0) { ($ScriptHelp.alertset.alert[0] | foreach-object { $_ | Out-String } ) -split "`r`n|`n" | ForEach-Object { if ( -not [string]::isnullorwhitespace($_)) { $_ } } } $ScriptDescription = ($ScriptHelp.description | foreach-object { $_ | Out-String } ) -split ("`r`n") # Header with a few reminderes, for the variables and pipeline files. soon-to-be obsolete when we all have become pipeline-gurus! $PipelineVariablesHeader = @() $PipelineVariablesHeader += '# Variable names cannot contain hyphens. Use underscores instead. If using double quotes, remember to escape special characters' $PipelineVariablesHeader += '# Booleans, and Numbers, must be passed as ${{variables.<variablename>}} to template to retain data type when received by template.' $PipelineVariablesHeader += '# Booleans still need to be prefixed with $ when passed to script, because Pipelines sucks (https://www.codewrecks.com/post/azdo/pipeline/powershell-boolean/)' $PipelineVariablesHeader += '# Split long strings to multiple lines by using >- , indenting value-lines ONE additional level and NO quotation marks surrounding entire value (https://yaml-multiline.info/ - (folded + strip))' # Get path of script relative to repo if possible. Push-Location -Path (Split-Path -Path $ScriptPath -Parent) -StackName RelativePath try { $RepoScriptPath = & git ls-files --full-name $ScriptPath if ([string]::IsNullOrWhiteSpace($RepoScriptPath)) { $RepoScriptPath = & git ls-files --full-name $ScriptPath --others } if ([string]::IsNullOrWhiteSpace($RepoScriptPath)) { throw "Couldn't run git or repo not found" } else { # We found the script as part of repo, so lets guess the project, repository and direct URL for the Wiki as well $RemoteGit = & git remote -v # Returns something similar to 'origin https://<organization>@dev.azure.com/<organization>/<project>/_git/<repository> (push)' $RemoteURL = "https://" + $RemoteGit.split(" ")[0].split("@")[-1] + "?path=/$RepoScriptPath" $RemotePath = $RemoteGit[0].split("/") $DevOpsProject = $RemotePath[4] $DevOpsRepository = $RemotePath[6] -replace "\(fetch\)|\(push\)", "" } } catch { # If we can't find the script as part of the repo, fallback to the path relative to current location $RepoScriptPath = (Resolve-Path -Path $ScriptPath -Relative).replace("\", "/") Write-Warning "Couldn't find path relative to repository. Is file in a repo or git not installed? Relative references will fall back to $RepoScriptPath" Write-Warning $_.Exception.Message } Pop-Location -StackName RelativePath if ($RepoScriptPath -cmatch "[A-Z]") { # File names should be in lower case in accordance with https://dev.azure.com/itera-dk/Mastercard.PaymentsOnboarding/_git/infrastructure?path=/readme.md Write-Warning "Scriptpath not in all lowercase: $RepoScriptPath" } if ([string]::isnullorwhitespace($OutDir)) { [string]$OutDir = $ScriptFile.DirectoryName.ToString() } $FSTemplateFilePath = Join-Path -Path $OutDir -ChildPath $ScriptFile.Name.Replace(".ps1", ".yml") $FSPipelineFilePath = Join-Path -Path (Split-Path -Path $FSTemplateFilePath -Parent) -ChildPath ("deploy-$(Split-Path -Path $FSTemplateFilePath -Leaf)") $FSVariablesFilePath = @{} foreach ($env in $environment.tolower()) { $FSVariablesFilePath[$env] = $FSPipelineFilePath.Replace(".yml", "-$($env)-vars.yml") #Template for variable files. #ENV# is replace with corresponding environment name. } $FSWikiFilePath = $FSTemplateFilePath.replace(".yml", ".md") # Wiki uses dash'es for spaces, and hex values for dash'es, so replace those in the filename. $FSWikiFilePath = Join-Path (Split-Path -Path $FSWikiFilePath) -ChildPath ((Split-Path -Path $FSWikiFilePath -Leaf).Replace("-", "%2D").Replace(" ", "-")) # Get path to yaml template file, relative to script location $RepoTemplateFilePath = $RepoScriptPath.replace(".ps1", ".yml") if ([string]::IsNullOrWhiteSpace((Split-Path -Path $RepoTemplateFilePath))) { # If script is located in root of repo, we cant split-path it $RepoPipelineFilePath = "/deploy-$(Split-Path -Path $RepoTemplateFilePath -Leaf)" } else { $RepoPipelineFilePath = "/" + (Join-Path -Path (Split-Path -Path $RepoTemplateFilePath) -ChildPath ("deploy-$(Split-Path -Path $RepoTemplateFilePath -Leaf)")).replace("\","/") } # Save variable filenames in hashtable for easy references $RepoVariablesFilePath = @{} foreach ($env in $environment.tolower()) { $RepoVariablesFilePath[$env] = $RepoPipelineFilePath.Replace(".yml", "-$($env)-vars.yml") #Template for variable files. #ENV# is replace with corresponding environment name. } #$RepoWikiFilePath = $RepoTemplateFilePath.replace(".yml", ".md") # Maybe we'll need this one day.... maybe not # Parse the parameters and get necessary values for YAML generation $ScriptParameters = @() foreach ($param in $ScriptHelpParameters.parameter) { $Command = $ScriptCommandParameters[$param.name] $Props = [ordered]@{ "Description" = $param.description "Name" = $param.name "HelpMessage" = ($Command.Attributes | Where-Object { $_.GetType().Name -eq "ParameterAttribute" }).HelpMessage "Type" = $param.type "Required" = $param.required "DefaultValue" = $param.defaultValue "ValidateSet" = ($Command.Attributes | Where-Object { $_.GetType().Name -eq "ValidateSetAttribute" }).ValidValues "ValidateScript" = ($Command.Attributes | Where-Object { $_.GetType().Name -eq "ValidateScriptAttribute" }).scriptblock } # Build a description text to add to variables, and parameters, in YAML files $YAMLHelp = "" if ($props.Description.length -gt 0) { $YAMLHelp += "$((($props.Description | foreach-object {$_.Text}) -join " ") -replace ("`r`n|`n|`r", " "))" } if ($Props.HelpMessage.Length -gt 0) { $YAMLHelp += " Help: $($Props.HelpMessage)" } $YAMLHelp += " Required: $($param.required)" if ($Props.ValidateSet.Count -gt 0) { $YAMLHelp += " ValidateSet: ($(($Props.ValidateSet | ForEach-Object {"'$_'"}) -join ","))" } if ($Props.ValidateScript.Length -gt 0) { $YAMLHelp += " ValidateScript: {$($Props.ValidateScript)}" } if ($YAMLHelp.Length -gt 0) { $Props.add("YAMLHelp", $YAMLHelp.Trim()) } $ScriptParameters += New-Object -TypeName PSObject -Property $Props } if ($ScriptParameters.count -eq 0) { Write-Warning "No parameters found for $ScriptPath. Make sure comment based help is correctly entered: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help?view=powershell-7.2" } # Build the YAMLParameters object containing more YAML specific information (could be done in previous loop... to do someday) $YAMLParameters = @() $ScriptArguments = "" foreach ($param in $ScriptParameters) { $ParamType = $ParamDefaultValue = $null # There are really only 3 parameter types we can use when running Powershell in a pipeline switch ($Param.Type.Name ) { "SwitchParameter" { $ParamType = "boolean" } { $_ -match "Int|Int32|long|byte|double|single" } { $ParamType = "number" } default { $ParamType = "string" } # Undeclared parameters will be of type Object and treated as string } # Not a proper switch, but this is where we figure out the correct default value switch ($Param.DefaultValue) { { $_ -match "\$" } { $ParamDefaultValue = "'' # Scriptet default: $($Param.DefaultValue)" ; break } # If default value contains $ it most likely references another parameter. { (-not ([string]::IsNullOrWhiteSpace($Param.DefaultValue)) -and $ParamType -eq "String") } { $ParamDefaultValue = "'$($param.defaultValue)'" ; break } # Add single quotes around string values { $ParamType -eq "number" } { $ParamDefaultValue = "$($param.defaultValue)" ; break } # No quotes around numbers as that would make it a string { $ParamType -eq "boolean" } { if ($param.defaultvalue -eq $true) { $ParamDefaultValue = "true" } else { $ParamDefaultValue = "false" } ; break } # Set a default value for booleans as well { $Param.ValidateSet.count -gt 0 } { if ($Param.ValidateSet -contains " ") { $ParamDefaultValue = "' '" } else { $ParamDefaultValue = "'$($Param.ValidateSet[0])'" } } default { $ParamDefaultValue = "''" } # If all else fails, set the default value to empty string } $YAMLParameterProps = @{"Name" = $Param.name "YAMLHelp" = $Param.YAMLHelp "Type" = $ParamType "Default" = $ParamDefaultValue "ValidateSet" = $param.validateSet "VariableName" = "$($VariablePrefix)_$($param.name)" # Property to use as variable name in YAML. #ENV# will be replaced with the different environments to deploy to } $YAMLParameters += New-Object -TypeName PSObject -Property $YAMLParameterProps # Define the scriptarguments to pass to the script. The name of the variable will correspond with the name of the parameter if ($ParamType -eq "boolean") { $ScriptArguments += ("-$($Param.Name):`$`${{parameters.$($Param.name)}} ") # Add additional $ to turn "false" into "$false" } elseif ($param.type.name -eq "String") { $ScriptArguments += ("-$($Param.Name) '`${{parameters.$($Param.name)}}' ") # Make sure string values has single quotes around them so spaces and special characters survive } else { #integer type $ScriptArguments += ("-$($Param.Name) `${{parameters.$($Param.name)}} ") # Numbers as-is } if ($YAMLParameters[-1].VariableName.Length -gt $MaxParameterNameLength) { $MaxParameterNameLength = $YAMLParameters[-1].VariableName.Length # Used for padding in pipeline and variables file, to make them less messy. } } $MaxParameterNameLength++ # To account for the colon in the YAML # Initialize PipelineVariables and set the corresponding template as comment # $PipelineVariables contains the content of the variable files for each environment $PipelineVariables = @() $PipelineVariables += " # $(Split-Path -Path $RepoTemplateFilePath -leaf)" # Default template parameters independent of script parameters $TemplateParameters = @() $TemplateParameters += " - name: serviceConnection # The name of the service Connection to use" $TemplateParameters += " type: string" $TemplateParameters += " default: false" # Build the template parameters foreach ($param in $YAMLParameters) { $TemplateParameters += "" $TemplateParameters += " - name: $($param.Name) # $($Param.YAMLHelp)" $TemplateParameters += " type: $($Param.type)" $TemplateParameters += " default: $($Param.Default)" if ($param.validateset) { $TemplateParameters += " values:" foreach ($value in $param.validateset) { if ($param.Type -eq "number") { $TemplateParameters += " - $value" } else { $TemplateParameters += " - '$value'" } } } $PipelineVariables += " $("$($Param.VariableName):".PadRight($MaxParameterNameLength)) $($param.Default) # $($Param.YAMLHelp)" } #region BuildTemplate $Template = @() $Template += "# Template to deploy $($ScriptFile.Name):" $Template += "" # Add script synopsis to template file if available if ($ScriptSynopsis.length -gt 0) { $Template += "# Synopsis:" $Template += $ScriptSynopsis | foreach-object {"#`t $_"} } $Template += "" # Add script description to template file if available if ($ScriptDescription.length -gt 0) { $Template += "# Description:" $Template += $ScriptDescription | ForEach-Object {"#`t $_"} } $Template += "" # Add script notes to template file if available if ($ScriptNotes.length -gt 0) { $Template += "# Notes:" $Template += $ScriptNotes | foreach-object {"#`t $_"} } $Template += "" $Template += "parameters:" $Template += $TemplateParameters $Template += "" $Template += "steps:" $Template += " - task: AzurePowerShell@5" $Template += " displayName: ""PS: $($ScriptFile.Name)""" $Template += " inputs:" $Template += ' azureSubscription: "${{parameters.serviceConnection}}"' $Template += ' scriptType: "FilePath"' $Template += " scriptPath: ""$RepoScriptPath"" # Relative to repo root" $Template += " azurePowerShellVersion: latestVersion" $Template += " scriptArguments: $ScriptArguments" $Template += " pwsh: true # Run in PowerShell Core" #endregion #BuildTemplate # Make variables nicely alligned $MaxEnvLength = $Environment | Group-Object -Property Length | Sort-Object -Property Name -Descending | Select-Object -First 1 -ExpandProperty Name $PadTo = "_ServiceConnectionResources".Length + $MaxEnvLength + " ".Length # Get agent options from ValidateSet for Agent parameter of this script/function $Command = Get-Command -Name $MyInvocation.MyCommand $ValidAgents = ($Command.Parameters["Agent"].Attributes | Where-Object { $_.GetType().Name -eq "ValidateSetAttribute" }).ValidValues if ($Agent) { $SelectedAgent = $Agent } else { $SelectedAgent = $ValidAgents[0] } $AgentOptions = $ValidAgents | Where-Object {$_ -ne $SelectedAgent} #region BuildPipeline $Pipeline = @() $Pipeline += "# https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/?view=azure-pipelines" $Pipeline += "" $Pipeline += "trigger: none" $Pipeline += "" $Pipeline += $PipelineVariablesHeader $Pipeline += "variables:" $Pipeline += " # Pipeline variables" $Pipeline += " $("deployment:".PadRight($PadTo)) '$((($DeploymentDisplayName.ToCharArray() | Where-Object {$_ -match '[\w| ]'}) -join '').replace(" ","_").tolower())' `t`t# Name of deployment" $Pipeline += " $("deploymentDisplayName:".PadRight($PadTo)) '$DeploymentDisplayName' `t# Name of deployment" foreach ($env in $Environment) { $Pipeline += "" $Pipeline += " $("$($env)_ServiceConnectionAD:".PadRight($PadTo)) '$($ServiceConnection)' `t`t# Name of connection to use for AD deployments in $env environment" $Pipeline += " $("$($env)_ServiceConnectionResources:".PadRight($PadTo)) '$($ServiceConnection)' `t`t# Name of connection to use for Azure resource deployments in $env environment" $Pipeline += " $("$($env)_EnvironmentName:".PadRight($PadTo)) '$env'" } $Pipeline += "" $Pipeline += "# Comment/remove incorrect agents" $Pipeline += "pool:" $Pipeline += " $SelectedAgent" foreach ($agnt in $AgentOptions) { $Pipeline += " #$agnt" } $Pipeline += "" $Pipeline += "stages:" foreach ($env in $Environment) { $Pipeline += "# $env" $Pipeline += " - stage: '`${{variables.$($env)_EnvironmentName}}'" $Pipeline += " displayName: '`${{variables.$($env)_EnvironmentName}}'" $Pipeline += " variables:" $Pipeline += " - template: '$($RepoVariablesFilePath[$env])'" $Pipeline += " jobs:" $Pipeline += " - deployment: '`${{variables.deployment}}'" $Pipeline += " displayName: '`${{variables.deploymentDisplayName}}'" $Pipeline += " environment: '`${{variables.$($env)_EnvironmentName}}'" $Pipeline += " strategy:" $Pipeline += " runOnce:" $Pipeline += " deploy:" $Pipeline += " steps:" $Pipeline += " - checkout: self" if ($ScriptSynopsis.Length -gt 0) { $Pipeline += " " + ($ScriptSynopsis | ForEach-Object {"# $_"}) } $Pipeline += " - template: '/$RepoTemplateFilePath' #Template paths should be relative to this file. For absolute path use /path/to/template" $Pipeline += " parameters:" $Pipeline += " $("#serviceConnection:".PadRight($MaxParameterNameLength)) `$`{{variables.$($env)_ServiceConnectionAD}} # Comment/remove the incorrect connection!" $Pipeline += " $("serviceConnection:".PadRight($MaxParameterNameLength)) `$`{{variables.$($env)_ServiceConnectionResources}}" foreach ($param in $YAMLParameters) { $ParamValue = "`${{variables.$($param.VariableName)}}" $Pipeline += " $("$($param.Name):".PadRight($MaxParameterNameLength)) $ParamValue" } $Pipeline += "" } #endregion BuildPipeline #Finally output the files if ($NoSample) { $Suffix = "" } else { $Suffix = ".sample" } try { if ($Wiki -or $WikiOnly) { $ScriptWiki = @("<b>Name:</b> $(Split-Path $ScriptPath -Leaf)" "" "<b>Project:</b> $DevOpsProject" "" "<b>Repository:</b> $DevOpsRepository" "" "<b>Path:</b> <a href=""$RemoteURL""> $RepoScriptPath</a>" "" "<b>Synopsis:</b>" $ScriptSynopsis "" "<b>Description:</b>" $ScriptDescription "" if ($ScriptNotes.Length -gt 0) { "<b>Notes:</b><br>" ($ScriptNotes | Out-String).trim() "" } "<b>Examples</b><br>" ($ScriptExamples | Out-String).trim() "" "<b>Parameters</b>" ($ScriptHelpParameters | Out-String) -split "`r`n|`n" | ForEach-Object {$_.trim()} ) -join "`r`n" $ScriptWiki | Out-File -FilePath $FSWikiFilePath -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite } if (-not $WikiOnly) { $PipelineVariablesHeader += "variables:" $PipelineVariables = $PipelineVariablesHeader + $PipelineVariables foreach ($env in $Environment) { $PipelineVariables | Out-File -FilePath "$($FSVariablesFilePath[$Env])$Suffix" -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite } $Pipeline | Out-File -FilePath "$FSPipelineFilePath$Suffix" -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite $Template | Out-File -FilePath $FSTemplateFilePath -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite } } catch { Write-Error "Unable to write files" Write-Error $_.Exception.Message } } . New-YAMLTemplate -ScriptPath C:\DevOps\home-infrastructure\scripts\products\runbooks\Invoke-Restoretest.ps1 -WikiOnly -Overwrite Function Prompt { if ( (Test-Path (Join-Path -Path "variable:" -ChildPath "PSDebugContext"))) { $nestedPromptLevel++ $BasePrompt = " [DBG] PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) "; } else { $BasePrompt = " PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) "; } (get-date -Format HH:mm:ss).ToString().Replace(".",":") + $BasePrompt # .Link # https://go.microsoft.com/fwlink/?LinkID=225750 # .ExternalHelp System.Management.Automation.dll-help.xml } Function Remove-OldModules { [ CMDLetbinding()] Param () $Modules = Get-Module -ListAvailable | Where-Object {$_.ModuleBase -notmatch "\.vscode"} #Theres probably a reason for vs code specific modules $DuplicateModules = $Modules | Group-Object -Property Name | Where-Object {$_.Count -gt 1} | Select-Object -ExpandProperty Group foreach ($Module in $DuplicateModules) { $RemoveModules = $DuplicateModules | Where-Object {$_.Name -eq $Module.Name} | Sort-Object -Property Version -Descending | Select-Object -Skip 1 foreach ($mod in $RemoveModules) { Write-Host "$($Module.Name) : $($Module.ModuleBase)" Remove-Module -Name $Module.Name -Force -ErrorAction SilentlyContinue Remove-Item -Path $Module.ModuleBase -Recurse -Force -Confirm:$false } } } Function Set-AzTestSetup { [CMDLetbinding()] param( [Parameter(Mandatory =$true)] [String[]]$ResourceGroupName, [string]$Prefix, [int]$NumWinVMs, [int]$NumLinuxVMs, [string]$VMAutoshutdownTime, [string]$WorkspaceName, [string]$AutomationAccountName, [String]$Location, [string]$KeyvaultName, [switch]$PowerOff, [switch]$Force, [switch]$Remove ) foreach ($RG in $ResourceGroupName) { if (-not $PSBoundParameters.ContainsKey("Prefix")) {[string]$Prefix = $RG} if (-not $PSBoundParameters.ContainsKey("NumWinVMs")) {[int]$NumWinVMs = 2} if (-not $PSBoundParameters.ContainsKey("NumLinuxVMs")) {[int]$NumLinuxVMs = 0} if (-not $PSBoundParameters.ContainsKey("VMAutoshutdownTime")) {[string]$VMAutoshutdownTime = "2300"} if (-not $PSBoundParameters.ContainsKey("WorkspaceName")) {[string]$WorkspaceName = ($Prefix + "-workspace")} if (-not $PSBoundParameters.ContainsKey("AutomationAccountName")) {[string]$AutomationAccountName = ($Prefix + "-automation")} if (-not $PSBoundParameters.ContainsKey("Location")) {[String]$Location = "westeurope"} if (-not $PSBoundParameters.ContainsKey("KeyvaultName")) {[string]$KeyvaultName = ($Prefix + "-keyvault")} if ($KeyvaultName.Length -gt 24) { $KeyvaultName = "$($KeyvaultName.Substring(0,15))-keyvault" Write-Host "Keyvault name truncated to: $KeyVaultName" } try { if (Get-AzResourceGroup -Name $RG -ErrorAction SilentlyContinue) { Write-Host "$RG exist" if ($Force -or $Remove) { Write-Host "`tWill be deleted" $WorkSpace = Get-AzOperationalInsightsWorkspace -ResourceGroupName $RG -Name $WorkspaceName -ErrorAction SilentlyContinue $Keyvault = Get-AzKeyVault -VaultName $KeyvaultName -ResourceGroupName $RG -ErrorAction SilentlyContinue if ($null -eq $Keyvault) { $keyvault = Get-AzKeyVault -VaultName $KeyvaultName -InRemovedState -Location $location if ($null -ne $Keyvault) { Write-Host "`tDeleting $KeyvaultName" $null = Remove-AzKeyVault -VaultName $KeyvaultName -InRemovedState -Force -Confirm:$false -Location $Location } } else { Write-Host "`tDeleting $KeyvaultName" Remove-AzKeyVault -VaultName $KeyvaultName -Force -Confirm:$false -Location $location Start-Sleep -Seconds 1 Remove-AzKeyVault -VaultName $KeyvaultName -InRemovedState -Force -Confirm:$false -Location $location } if ($WorkSpace) { Write-Host "`tDeleting Workspace" $workspace | Remove-AzOperationalInsightsWorkspace -ForceDelete -Force -Confirm:$false } Write-Host "`tDeleting Resourcegroup and contained resources" Remove-AzResourceGroup -Name $RG -Force -Confirm:$false } else { Write-Host "Nothing to do" continue } } if ($Remove) { Write-Host "Remove specified. Exiting" continue } Write-Host "Creating $RG" New-AzResourceGroup -Name $RG -Location $Location Write-Host "Creating $AutomationAccountName" New-AzAutomationAccount -ResourceGroupName $RG -Name $AutomationAccountName -Location $Location -Plan Basic -AssignSystemIdentity Write-Host "Creating $KeyvaultName" New-AzKeyVault -Name $KeyvaultName -ResourceGroupName $RG -Location $Location -EnabledForDeployment -EnabledForTemplateDeployment -EnabledForDiskEncryption -SoftDeleteRetentionInDays 7 -Sku Standard Set-AzKeyVaultAccessPolicy -VaultName $KeyvaultName -ResourceGroupName $RG -UserPrincipalName "robert.eriksen_itera.com#EXT#@roedomlan.onmicrosoft.com" -PermissionsToKeys all -PermissionsToSecrets all -PermissionsToCertificates all -PermissionsToStorage all -Confirm:$false Set-AzKeyVaultAccessPolicy -VaultName $KeyvaultName -ResourceGroupName $RG -ObjectId (Get-AzAutomationAccount -ResourceGroupName $RG -Name $AutomationAccountName).Identity.PrincipalId -PermissionsToKeys all -PermissionsToSecrets all -PermissionsToCertificates all -PermissionsToStorage all -Confirm:$false Set-AzKeyVaultAccessPolicy -VaultName $KeyvaultName -ResourceGroupName $RG -ServicePrincipalName 04e7eb7d-da63-4c13-b5ba-04331145fdff -PermissionsToKeys all -PermissionsToSecrets all -PermissionsToCertificates all -PermissionsToStorage all -Confirm:$false Write-Host "Creating $WorkspaceName" New-azOperationalInsightsWorkspace -ResourceGroupName $RG -Name $WorkspaceName -Location $location -Sku pergb2018 $VMCredentials = [pscredential]::new("roe",("Pokemon1234!" | ConvertTo-SecureString -AsPlainText -Force)) # https://www.powershellgallery.com/packages/HannelsToolBox/1.4.0/Content/Functions%5CEnable-AzureVMAutoShutdown.ps1 $ShutdownPolicy = @{} $ShutdownPolicy.Add('status', 'Enabled') $ShutdownPolicy.Add('taskType', 'ComputeVmShutdownTask') $ShutdownPolicy.Add('dailyRecurrence', @{'time'= "$VMAutoshutdownTime"}) $ShutdownPolicy.Add('timeZoneId', "Romance Standard Time") $ShutdownPolicy.Add('notificationSettings', @{status='enabled'; timeInMinutes=30; emailRecipient="robert.eriksen@itera.com" }) $VMPrefix = "$($RG[0])$($RG[-1])" if ($NumWinVMs -gt 0) { (1..$NumWinVMs) | ForEach-Object { $VMName = ([string]"$($VMPrefix)-Win-$( $_)") Write-Host "Deploying $VMName" $null = New-AzVm -ResourceGroupName $RG -Name $VMName -Location $Location -Credential $VMCredentials -VirtualNetworkName "$($RG)-vnet" -SubnetName "$($RG)-Subnet" -SecurityGroupName "$($RG)-nsg" -PublicIpAddressName "$($VMName)-Public-ip" -OpenPorts 80,3389 -Size "Standard_B2s" -Image Win2019Datacenter $vm = Get-AzVM -ResourceGroupName $RG -Name $VMName $vm | Stop-AzVM -Confirm:$false -Force $Disk = Get-AzDisk | Where-Object {$_.ManagedBy -eq $vm.id} $Disk.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new("Standard_LRS") $disk | Update-AzDisk $rgName = $vm.ResourceGroupName $vmName = $vm.Name $location = $vm.Location $VMResourceId = $VM.Id $SubscriptionId = ($vm.Id).Split('/')[2] $ScheduledShutdownResourceId = "/subscriptions/$SubscriptionId/resourceGroups/$rgName/providers/microsoft.devtestlab/schedules/shutdown-computevm-$vmName" if ($VMAutoshutdownTime -ne "Off") { Write-Host "Setting autoshutdown: $VMAutoshutdownTime" $ShutdownPolicy['targetResourceId'] = $VMResourceId $null = New-azResource -Location $location -ResourceId $ScheduledShutdownResourceId -Properties $ShutdownPolicy -Force } if (-not $PowerOff) { Write-Host "Starting VM" $vm | Start-AzVM } } } if ($NumLinuxVMs -gt 0) { (1..$NumLinuxVMs) | ForEach-Object { $VMName = ([string]"$($VMPrefix)-Lin-$($_)") Write-Host "Deploying $VMName" $null = New-AzVm -ResourceGroupName $RG -Name $VMName -Location $Location -Credential $VMCredentials -VirtualNetworkName "$($RG)-vnet" -SubnetName "$($RG)-Subnet" -SecurityGroupName "$($RG)-nsg" -PublicIpAddressName "$($VMName)-Public-ip" -OpenPorts 80,3389 -Size "Standard_B2s" -Image UbuntuLTS $vm = Get-AzVM -ResourceGroupName $RG -Name $VMName $vm | Stop-AzVM -Confirm:$false -Force $Disk = Get-AzDisk | Where-Object {$_.ManagedBy -eq $vm.id} $Disk.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new("Standard_LRS") $disk | Update-AzDisk $rgName = $vm.ResourceGroupName $vmName = $vm.Name $location = $vm.Location $VMResourceId = $VM.Id $SubscriptionId = ($vm.Id).Split('/')[2] $ScheduledShutdownResourceId = "/subscriptions/$SubscriptionId/resourceGroups/$rgName/providers/microsoft.devtestlab/schedules/shutdown-computevm-$vmName" if ($VMAutoshutdownTime -ne "Off") { Write-Host "Setting autoshutdown: $VMAutoshutdownTime" $ShutdownPolicy['targetResourceId'] = $VMResourceId $null = New-azResource -Location $location -ResourceId $ScheduledShutdownResourceId -Properties $ShutdownPolicy -Force } if (-not $PowerOff) { Write-Host "Starting VM" $vm | Start-AzVM } } } } catch { throw $_ } } } Function Set-SystemVariables { #Collect all variables and store them, so userdefined variables can be easily cleared without restarting the PowerShell session New-Variable -Name 'SysVars' -Scope 'Global' -Force $global:SysVars = Get-Variable | Select-Object -ExpandProperty Name $global:SysVars += 'SysVars' } Function Upsert-SNOWIncident { <# .SYNOPSIS (POC) Creates, or updates, a ServiceNow incident .DESCRIPTION Creates, or updates, a ServiceNow incident. For REST tutorials: sign up at https://developer.servicenow.com/dev.do and find "REST API Explorer" in the "All" menu. If an existing incident, with unknown incident number, should be updated the minumum required JSON properties are: caller_id: Email, username or sys_id of caller. short_description: Short description (subject) of incident. A query is performed against ServiceNow looking for incidents matching both caller and subject. If creating a new incident a basic set of properties is: { "assignment_group": "<name of assignment group>", "sys_id / user_name / email": "<sysid / username / e-mail>", "category": "<category>", "subcategory": "<subcategory>", "contact_type": "<contact type>", "short_description": "<subject of incident>", "description": "<detailed description of incident>", "impact": "<1-3>", "urgency": "<1-3>" } A complete list of properties can be found in the ServiceNow REST API Explorer .EXAMPLE PS> $JSON = '{ "caller_id": "a8f98bb0eb32010045e1a5115206fe3a", "short_description": "VM vulnerability findings", }' PS> Upsert-SNOWIncident -Credential $credential -JSON $json This will connect to the default ServiceNow and look for an existing, incident with state of "New", "In Progress" or "On Hold" with the user with sys_id a8f98bb0eb32010045e1a5115206fe3a as caller, and a short description of "VM vulnerability findings". If multiple matches are found, the incident with the oldest creation date will be updated with the information provided in the JSON. All other fields will preserve any existing values. Any required fiels will be mandatory when the incident is edited in ServiceNow. .EXAMPLE PS> $JSON = '{ "assignment_group": "Service Desk", "email": "abraham.lincoln@example.com", "category": "Software", "contact_type": "Virtual Agent", "description": "100000 VMs with vulenrability findings found", "impact": "2", "short_description": "VM vulnerability findings", "subcategory": null, "urgency": "3", "assigned_to" : "beth.anglin@example.com" }' PS> Upsert-SNOWIncident -Credential $credential -JSON $json -UpdateNewest This will connect to the default ServiceNow and look for an existing, incident with state of "New", "In Progress" or "On Hold" with "abraham.lincoln@example.com" as caller and a short description of "VM vulnerability findings". If multiple matches are found, the incident with the newest creation date will be updated with the information provided in the JSON. All other fields will preserve any existing values. Any required fiels will be mandatory when the incident is edited in ServiceNow. .EXAMPLE PS> $JSON = '{ "assignment_group": "Service Desk", "email": "abraham.lincoln@example.com", "category": "Software", "contact_type": "Virtual Agent", "description": "100000 VMs with vulenrability findings found", "impact": "2", "short_description": "VM vulnerability findings", "subcategory": null, "urgency": "3", "assigned_to" : "beth.anglin@example.com" }' PS> Upsert-SNOWIncident -Credential $credential -JSON $json -AlwaysCreateNew This will connect to the default ServiceNow create a new incident with the information provided in the JSON. All other fields will preserve any existing values. Any required fiels will be mandatory when the incident is edited in ServiceNow. .PARAMETER IncidentNumber If updating a known incident .PARAMETER JSON JSON containing ServiceNow properties (see Description for details) .PARAMETER UpdateNewest Will look for existing incident matching Caller and ShortDescription and add Description to newest (based on creation timestamp) incident's work notes. If omitted oldest incident will be updated. If no incident is found a new will be created. .PARAMETER AlwaysCreateNew Always create a new incident, even if similar already exist .Parameter Credential Credentials to connect to ServiceNow with .PARAMETER SNOWInstance Name of ServiceNow Instance. #> [CMDLetbinding()] Param ( [Parameter(Mandatory = $false)] [string]$IncidentNumber, [Parameter(Mandatory = $true)] [String]$JSON, [Parameter(Mandatory = $false)] [Switch]$UpdateNewest, [Parameter(Mandatory = $false)] [Switch]$AlwaysCreateNew, [Parameter(Mandatory = $true)] [pscredential]$Credential, [Parameter(Mandatory = $false)] [String]$SNOWInstance = "dev61671" ) # Build auth header $UserName = $Credential.UserName $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $UserName, $Credential.GetNetworkCredential().Password))) # Set proper headers $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" $headers.Add('Authorization', ('Basic {0}' -f $base64AuthInfo)) $headers.Add('Accept', 'application/json') $headers.Add('Content-Type', 'application/json') $JSONHash = $JSON | ConvertFrom-Json -AsHashtable foreach ($key in $JSONHash.Keys) { New-Variable -Name $key -Value $JSONHash[$key] } if (($sys_id -or $user_name -or $email) -and (-not $caller_id)) { # While the correct caller can be set on new incidents using username/emailaddress, We need the sys_id value to query for existing incident to update Write-Verbose "Looking for caller sys_id" $Query = @() if ($sys_id) { $Query += "sys_id=$sys_id" } if ($user_name) { $Query += "user_name=$user_name" } if ($email) { $Query += "email=$email" } $Query = $Query -join "^" # ^ = AND, ^OR = OR $URL = "https://$SNOWInstance.service-now.com/api/now/table/sys_user?sysparm_query=$Query&sysparm_limit=1" $User = (Invoke-RestMethod -Uri $URL -Method get -Headers $headers).result[0] $caller_id = $User.sys_id } # If we need to update an existing incident but haven't got the incident number, find it based on Caller and ShortDescription if ([string]::IsNullOrWhiteSpace($IncidentNumber) -and (-not $AlwaysCreateNew)) { $IncidentQuery = "stateIN1,2,3^short_description=$short_description" if ($caller_id) { $IncidentQuery += "^caller_id=$caller_id" } $IncidentQuery = $IncidentQuery.Replace("=", "%3D").Replace(" ", "%20").Replace(",", "%2C").Replace("^", "%5E") $URL = "https://$SNOWInstance.service-now.com/api/now/table/incident?sysparm_query=$IncidentQuery" $Incidents = (Invoke-RestMethod -Uri $URL -Method get -Headers $headers).Result | Select-Object *, @{N = "CreatedOn"; E = { Get-Date $_.sys_created_on } } | Sort-Object -Property CreatedOn -Descending if ($null -ne $Incidents) { if ($UpdateNewest) { $Incident = $Incidents[0] } else { $Incident = $Incidents[-1] } } } elseif (-not [String]::IsNullOrWhiteSpace($IncidentNumber)) { $URL = "https://$SNOWInstance.service-now.com/api/now/table/incident?sysparm_query=number=$IncidentNumber" $Incident = (Invoke-RestMethod -Uri $URL -Method get -Headers $headers).Result } if ($Incident) { #Update existing incident $comments = @("Updated: $(Get-Date -Format "dd. MMM yyyy HH:mm:ss")" $JSONHash["comments"] ) if (-not ($incident.description -eq $JSONHash["description"])) { $comments += @( "" "Previous description:" $Incident.description ) } $comments = $comments -join "`r`n" $JSONHash["comments"] = $comments $URL = "https://$SNOWInstance.service-now.com/api/now/table/incident/$($Incident.sys_id)" $Result = Invoke-RestMethod -Uri $URL -Method put -Headers $headers -Body ($JSONHash | ConvertTo-Json) } else { #Create new incident Write-Verbose "Creating new incident" $JSONHash["caller_id"] = $caller_id $URL = "https://$SNOWInstance.service-now.com/api/now/table/incident" $Result = Invoke-RestMethod -Uri $URL -Method post -Headers $headers -Body ($JSONHash | ConvertTo-Json) } return $Result } # Add default functionality to user profile $ProfilePath = $profile.CurrentUserAllHosts $ProfileContent = @(Get-Content -Path $ProfilePath -ErrorAction SilentlyContinue) $AddToProfile = @('# Added by module: roe.Misc ') if (-not ($ProfileContent-match "^(\s+)?Import-Module -Name roe.Misc *")) { Write-Host "Module roe.Misc : First import - Adding Import-Module roe.Misc to $profilepath" $AddToProfile += @( "Import-Module -Name roe.Misc -Verbose 4>&1 | Where-Object {`$_ -notmatch 'Exporting'}" ) } if (-not ($ProfileContent-match "^(\s+)?Prompt*")) { Write-Host "Module roe.Misc : First import - Adding Prompt to $profilepath" $AddToProfile += @( "Prompt" ) } if (-not ($ProfileContent -match "^(\s+)?Set-SystemVariables*")) { Write-Host "Module roe.Misc : First import - Adding Set-SystemVariables to $profilepath" $AddToProfile += @( "Set-SystemVariables" ) } if ($AddToProfile.count -gt 1) { if (-not (Test-Path -Path $ProfilePath -ErrorAction SilentlyContinue)) { $null = New-Item -Path $ProfilePath -Force -ItemType File } $ProfileContent += $AddToProfile $ProfileContent | Set-Content -Path $ProfilePath -Force } |