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 Write-Verbose "Updating version: $($Version)" $VersionString = $Version.ToString().Split(".") $VersionString[-1] = [int]$VersionString[-1] + 1 $Version =[version]($VersionString -join ".") Write-Verbose "New version: $($Version)" # 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 $ManifestHash['Copyright'] = "(c) Robert Eriksen. Almost all rights reserved :) $(Get-Date -format 'yyyy-MM-dd HH:mm:ss') $((Get-TimeZone).Id)" 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 $IgnoreVariables = @("PSCmdlet", "IgnoreVariables") if (Get-Variable -Name SysVars -ErrorAction SilentlyContinue) { $UserVars = get-childitem variable: | Where-Object {$SysVars -notcontains $_.Name} ForEach ($var in ($UserVars | Where-Object {$_.Name -notin $IgnoreVariables})) { 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 URL for, and optionally downloads, NuGet packages in PSGallery. Can be used as substitute for Install-Module in circumstances where it is not possible to install the required PackageProviders. Function returns PSCustomObject containing these properties: Name: Name of module Author: Author of module Version: Version of module URI: Direct link to NuGet package on PowerShell Gallery Description: Description of module Properties: Properties of NuGet Packaget NuGet: Search result from PowerShellGallery Dependencies: Name and version of dependencies FilePath: Path to module .psd1 file if module has been downloaded ImportOrder: Order of which modules should be imported (dependencies first) .Example PS> $Modules = Find-NugetPackage -Name Az.AKS -IncludeDependencies -Download -DownloadPath C:\temp\Modules PS> $Modules | Sort-Object -Property ImportOrder | ForEach-Object {Import-Module $_.FilePath} This will find the Az.AKS module, and any dependencies. Download them to C:\Temp\Modules, and then import them in the correct order. Az.AKS is dependent on Az.Accounts. Az.Accounts will get ImportOrder -1 and Az.AKS will get ImportOrder 0 .PARAMETER Name Name of module to find .Parameter All [Experimental] Get all versions of module .PARAMETER Version Get specific version of module .Parameter IncludeDependencies Get info for all dependecies for module .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 Download Download found modules .Parameter DownloadPath Used with Download switch. If no DownloadPath is specified users module directory will be used. If DownloadPath does not exist, it will be created #> # 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)] [Switch]$Download, [PArameter(Mandatory = $false)] [String]$DownloadPath ) try { # 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: # Az.Accounts:[2.7.1, ): # (the lonely '[' is not a typo) 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') } try { $ImportOrder = Get-Variable -Name ImportOrder -ValueOnly -Scope 1 -ErrorAction Stop $ImportOrder = $ImportOrder - 1 } catch { [int]$ImportOrder = 0 } $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 "FilePath" = $false # Will point to local destination if -DownloadTo is used "ImportOrder" = $ImportOrder } Write-Verbose "Finished collecting for $($pkgName) $($pkgVersion)" $Result += New-Object -TypeName PSObject -Property $Props } if ($IncludeDependencies) { # When function is called by itself/self-referenced, 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 -ErrorAction Stop } 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 ($Download) { if ([string]::IsNullOrWhiteSpace($DownloadPath)) { $DownloadPath = @(($env:PSModulePath).split(";") | Where-Object {$_ -match [regex]::escape($env:userprofile)})[0] if ($null -eq $DownloadPath) { throw "No PS Modules Path found under current userprofile: $($env:PSModulePath)" } } if (-not (Test-Path -Path $DownloadPath -PathType Container -ErrorAction SilentlyContinue)) { $null = New-Item -Path $DownloadPath -Force -ItemType Directory -ErrorAction Stop if (-not (Test-Path -Path $DownloadPath -PathType Container -ErrorAction SilentlyContinue)) { throw "Unable to create directory: $DownloadPath" } } Write-Verbose "Downloading to: $DownloadPath" $DownloadCounter = 1 foreach ($Module in $Result) { #$ZipFile = Join-Path $DownloadTo -ChildPath $($Module.Name) -AdditionalChildPath "$($Module.version).zip" $ZipFile = Join-Path $DownloadPath -ChildPath $($Module.Name) $ZipFile = Join-Path $ZipFile -ChildPath "$($Module.Version).zip" Write-Verbose "Downloading to Zipfile: $ZipFile" $ExtractDir = $ZipFile.replace(".zip", "") if (-not (Test-Path -Path $ExtractDir -ErrorAction SilentlyContinue)) { $null = New-Item -Path $ExtractDir -ItemType Directory -Force } Write-Verbose "Extracting to $ExtractDir" Write-Verbose "$($DownloadCounter)/$($Result.count) : Downloading $($Module.Name) $($Module.Version) to $ZipFile" Invoke-WebRequest -Uri $Module.Uri -OutFile $ZipFile Write-Verbose "Download complete" Write-Verbose "Extracting archive" Expand-Archive -Path $ZipFile -DestinationPath $ExtractDir -Force Write-Verbose "Extraction done" Write-Verbose "Deleting $ZipFile" Remove-Item -Path $ZipFile Write-Verbose "File deleted" $Module.FilePath = "$ExtractDir\$($Module.Name).psd1" $DownloadCounter++ } } return $Result } catch { throw $_ } } function Find-NugetPackage2 { <# .SYNOPSIS Finds URL for NuGet package in PSGallery, and optionally downloads. .DESCRIPTION Finds URL for, and optionally downloads, NuGet packages in PSGallery. Can be used as substitute for Install-Module in circumstances where it is not possible to install the required PackageProviders. Function returns PSCustomObject containing these properties: Name: Name of module Author: Author of module Version: Version of module URI: Direct link to NuGet package on PowerShell Gallery Description: Description of module Properties: Properties of NuGet Packaget NuGet: Search result from PowerShellGallery Dependencies: Name and version of dependencies FilePath: Path to module .psd1 file if module has been downloaded ImportOrder: Order of which modules should be imported (dependencies first) .Example PS> $Modules = Find-NugetPackage -Name Az.AKS -IncludeDependencies -Download -DownloadPath C:\temp\Modules PS> $Modules | Sort-Object -Property ImportOrder | ForEach-Object {Import-Module $_.FilePath} This will find the Az.AKS module, and any dependencies. Download them to C:\Temp\Modules, and then import them in the correct order. Az.AKS is dependent on Az.Accounts. Az.Accounts will get ImportOrder -1 and Az.AKS will get ImportOrder 0 .PARAMETER Name Name of module to find .Parameter All [Experimental] Get all versions of module .PARAMETER Version Get specific version of module .Parameter IncludeDependencies Get info for all dependecies for module .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 dependency modules will be returned. .Parameter Download Download found modules .Parameter DownloadPath Used with Download switch. If no DownloadPath is specified users module directory will be used. If DownloadPath does not exist, it will be created #> # 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]$Download, [PArameter(Mandatory = $false)] [String]$DownloadPath ) try { # Return stuff in this $Result = @() # Use this to check for parent scope at end of function. If not parent scope can be detected prepare to filter, optionally download, and return data. $FindNugetPackageChild = $true $PackageSourceUrl = "https://www.powershellgallery.com/api/v2/" # Get searchresults for all requested modules Write-Verbose "Starting processing $($Name.count) modules: $($Name)" $local:NUPKG = @() foreach ($module in $name) { #Figure out which version to find if ($PSBoundParameters.ContainsKey("Version")) { Write-Verbose "Searching for version [$version] of [$module]" $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$module' and Version eq '$Version'" } elseif ($PSBoundParameters.ContainsKey("All")) { Write-Verbose "Searching for all versions of [$module] module" $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$module'" } else { Write-Verbose "Searching for latest [$module] module" $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$module' and IsLatestVersion" } $NUPKG += @(Invoke-RestMethod $URI -Verbose:$false) if ($Null -eq $NUPKG) { Write-Warning "No result for module $Name" } } $local:Dependencies = @() foreach ($pkg in $NUPKG) { Write-Verbose "" Write-Verbose "Getting details for $($pkg.properties.Id) $($pkg.properties.Version)" $PkgDependencies = $pkg.properties.Dependencies.split("|") $PkgDependencies = $PkgDependencies | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } Write-Verbose "$($PkgDependencies.count) dependencies found" foreach ($Dependency in $PkgDependencies) { # Dependency will be formatted similar to: # Az.Accounts:[2.7.1, ): # (the lonely '[' is not a typo) try { $DepName = $Dependency.split(":")[0] $DepVersion = (($Dependency.split(":")[1].split(",")[0] | Select-String -Pattern "\d|\." -AllMatches).Matches | ForEach-Object { $_.Value }) -join "" Write-Verbose "Registering dependency: $($DepName) $($DepVersion)" $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') } # Try to guess the order modules must be imported. If this is nested call, its because this is a dependency module so set import order lower than parent module try { # Scope 1 is parent scope. If the variable can be found there, this is a nested call $ImportOrder = Get-Variable -Name ImportOrder -ValueOnly -Scope 1 -ErrorAction Stop $ImportOrder = $ImportOrder - 1 } catch { # If not, this is the original module. Leave an import order for any dependencies to refer to [int]$ImportOrder = 0 } Write-Verbose "Setting ImportOrder to $ImportOrder" $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 "FilePath" = $false # Will point to local destination if -DownloadTo is used "ImportOrder" = $ImportOrder } Write-Verbose "Finished collecting details for $($pkgName) $($pkgVersion)" $Result += New-Object -TypeName PSObject -Property $Props Write-Verbose "" } if ($IncludeDependencies) { # Get latest version of the dependencies we know of so far $DependencyNames = ($Dependencies | Group-Object -Property Module).Name Write-Verbose "Preparing collection of details for $($DependencyNames.count) unique dependencies for $($Name.count) modules" $CurrentDependencies = @{} foreach ($depname in $DependencyNames) { $CurrentDependencies.Add($depname,($Dependencies | Where-Object {$_.Module -eq $depname} | Sort-Object -Property version -Descending | Select-Object -First 1 -ExpandProperty Version)) } try { # See if we can retrieve $KnownDependencies from parent scope, in case this is a nested call. $KnownDependencies = Get-Variable -Name KnownDependencies -ValueOnly -Scope 1 -ErrorAction Stop $GetDependencies = @{} # If we already know of a newer version of a dependency theres no need to get an older version. foreach ($key in $CurrentDependencies.Keys) { if ($KnownDependencies[$key] -lt $CurrentDependencies[$key]) { Write-Verbose "Adding dependency $($key) version $($CurrentDependencies[$key]) to list of dependencies to collect details for" $KnownDependencies[$key] = $CurrentDependencies[$key] $GetDependencies[$key] = $CurrentDependencies[$key] } else { Write-Verbose "Dependency $($key) already added version $($KnownDependencies[$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()} Write-Verbose "Calling nested call for $($GetDependencies.Keys.count) dependency modules for $($name)" foreach ($module in $GetModules) { # Should probably just ignore versions all together, and go for the latest version every time $Result += Find-Nugetpackage2 -Name $module -Version $GetDependencies[$module] -IncludeDependencies -Verbose:$PSBoundParameters.ContainsKey("Verbose") } Write-Verbose "Finished getting $($GetDependencies.Keys.count) dependency modules for $($name)" } try { $null = Get-Variable -Name FindNugetPackageChild -ValueOnly -Scope 1 -ErrorAction Stop Write-Verbose "Returning to parent" # Do nothing. This is a nested call, so we're still collecting info } catch { Write-Verbose "Filtering dependencies for latest version" # No FindNugetPackageChild variable. We must be the top-most parent # Only keep latest version of each dependecy. We do sort of a filtering when collecting info, but if lower version is observed before a higher version both will be initially collected. $ResultNames = ($Result | Group-Object -Property name).Name $Result = foreach ($name in $ResultNames) { $Result | Where-Object {$_.Name -eq $name} | Sort-Object -Property version -Descending | Select-Object -First 1} if ($Download) { Write-Verbose "Starting download of $($Result.count) modules: $Result" if ([string]::IsNullOrWhiteSpace($DownloadPath)) { $DownloadPath = @(($env:PSModulePath).split(";") | Where-Object {$_ -match [regex]::escape($env:userprofile)})[0] if ($null -eq $DownloadPath) { throw "No PS Modules Path found under current userprofile: $($env:PSModulePath)" } } if (-not (Test-Path -Path $DownloadPath -PathType Container -ErrorAction SilentlyContinue)) { Write-Verbose "Creating download path: $DownloadPath" $null = New-Item -Path $DownloadPath -Force -ItemType Directory -ErrorAction Stop if (-not (Test-Path -Path $DownloadPath -PathType Container -ErrorAction SilentlyContinue)) { throw "Unable to create directory: $DownloadPath" } } Write-Verbose "Downloading to: $DownloadPath" $DownloadCounter = 1 foreach ($Module in $Result) { #$ZipFile = Join-Path $DownloadTo -ChildPath $($Module.Name) -AdditionalChildPath "$($Module.version).zip" $ZipFile = Join-Path $DownloadPath -ChildPath $($Module.Name) $ZipFile = Join-Path $ZipFile -ChildPath "$($Module.Version).zip" Write-Verbose "Downloading to Zipfile: $ZipFile" $ExtractDir = $ZipFile.replace(".zip", "") if (-not (Test-Path -Path $ExtractDir -ErrorAction SilentlyContinue)) { $null = New-Item -Path $ExtractDir -ItemType Directory -Force } Write-Verbose "Extracting to $ExtractDir" Write-Verbose "$($DownloadCounter)/$($Result.count) : Downloading $($Module.Name) $($Module.Version) to $ZipFile" Invoke-WebRequest -Uri $Module.Uri -OutFile $ZipFile Write-Verbose "Download complete" Write-Verbose "Extracting archive" Expand-Archive -Path $ZipFile -DestinationPath $ExtractDir -Force Write-Verbose "Extraction done" Write-Verbose "Deleting $ZipFile" Remove-Item -Path $ZipFile Write-Verbose "File deleted" $Module.FilePath = "$ExtractDir\$($Module.Name).psd1" $DownloadCounter++ } } } return $Result } catch { throw $_ } } 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, 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 } } # Is there an actual $Weekday number $number in the selected month, or is it out of bounds. $NumberWithinRange = ($number -ge -$DaysOfMonth.$Weekday.count -and $number -le $DaysOfMonth.$Weekday.Count -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($_)) { $_ } } } $ScriptLinks = if ($ScriptHelp.relatedLinks.navigationLink.count -gt 0) { $ScriptHelp.relatedLinks.navigationLink | foreach-object {"[$($_.Uri)]($($_.Uri))"}} $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() "" } "" if ($ScriptLinks.count -gt 0) { "<b>Links:</b><br>" ($ScriptLinks | 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 } } 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 Creates, or updates, a ServiceNow incident .DESCRIPTION Creates, or updates, a ServiceNow incident. If no other parameters are specified the oldest incident, matching user_name/email and short_description will be updated. For REST API tutorials/documentation sign up for a developer instance at https://developer.servicenow.com/dev.do and find "REST API Explorer" in the "All" menu. Or create a dummy incident and look at the properties of the returned incident. If an existing incident, with unknown incident number, should be updated the minumum recommended JSON properties are: Email or user_name of caller. short_description: Short description (subject) of incident. A query is performed against ServiceNow, looking for incidents matching short description and, if available, caller. If neither email, or user_name, is included in JSON the username of provided credentials will be used. If none can be found, a new incident will be created. If creating a new incident a, suggested, basic set of properties is: { "assignment_group": "<name of assignment group>", "user_name / email": "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>", "comments" : "public comment", "work_notes" : "internal comment" } A complete list of properties can be found in the ServiceNow REST API Explorer. Property values will should be entered, as they appear in the GUI, with respect for datatype (string/number). Any unknown properties, or properties with incorrect datatypes, will be ignored, and the field left blank in ServiceNow. In case of mandatory fields, the field will require modification before a user is able to edit the incident. If no user_name or email property is included, and a new incident is to be created, the username of the supplied credentials will be used. .EXAMPLE PS> $JSON = '{ "caller_id": "a8f98bb0eb32010045e1a5115206fe3a", "short_description": "VM vulnerability findings", }' PS> Upsert-SNOWIncident -Credential $credential -JSON $json -SNOWInstance MyHelpDesk This will connect to the MyHelpDesk ServiceNow instance, 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 -SNOWInstance MyHelpDesk This will connect to the MyHelpDesk ServiceNow instance, 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 -SNOWInstance MyHelpDesk -Attachment C:\Temp\Report.CSV This will connect to the MyHelpDesk ServiceNow instance, create a new incident with the information provided in the JSON and attach C:\Temp\Report.csv. 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 provide the incident number here. .PARAMETER JSON JSON containing ServiceNow properties (see Description for details) .Parameter AddTimestampToComments Add timestamp to comments field when updating existing incident. If a new incident is created no timestamp is added to comments. .Parameter Attachment Files to attach to incident. .Parameter ContentType If a specific content type should be used for upload. If omitted a content-type will be guesstimated based on extension. .PARAMETER UpdateNewest Based on creation timestamp. Will look for existing incident matching Caller and ShortDescription and add Description to the newest 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 AppendDescription Appends provided description to existing description if updating existing incident. .Parameter NoRunbookInfo Don't add runbook information to work notes, if called from automation account job. .Parameter Credential Credentials to connect to ServiceNow with .PARAMETER SNOWInstance Name of ServiceNow Instance (<SNOWInstance>.service-now.com). #> [CMDLetbinding()] Param ( [Parameter(Mandatory = $false)] [string]$IncidentNumber, [Parameter(Mandatory = $true)] [String]$JSON, [Parameter(Mandatory = $false)] [Switch]$AddTimestampToComments, [Parameter(Mandatory = $false)] [String[]]$Attachment, [Parameter(Mandatory = $false)] [String]$ContentType, [Parameter(Mandatory = $false)] [Switch]$UpdateNewest, [Parameter(Mandatory = $false)] [Switch]$AlwaysCreateNew, [Parameter(Mandatory = $false)] [Switch]$AppendDescription, [Parameter(Mandatory = $false)] [Switch]$NoRunbookInfo, [Parameter(Mandatory = $true)] [pscredential]$Credential, [Parameter(Mandatory = $true)] [String]$SNOWInstance ) # Find out if we're runnning in Automation Account if ($null -ne (Get-ChildItem ENV:AUTOMATION_ASSET_SANDBOX_ID -ErrorAction SilentlyContinue)) { Write-Verbose "Running in Automation Account" $IsAutomationAccount = $true } else { $IsAutomationAccount = $false } if ($IsAutomationAccount -and (-not $NoRunbookInfo)) { # If running in AA, get runbook info, unless disabled by NoRunbookInfo switch Write-Verbose "Running in Automation Account - Getting Runbookinfo" $Context = Get-AZContext $AllSubscriptions = Get-AzSubscription | Where-Object {$_.TenantId -eq $Context.Tenant.Id} foreach ($sub in $AllSubscriptions) { Select-AzSubscription -SubscriptionObject $sub $ThisSubscription = $sub try { $AutomationAccounts = Get-AzResource -ResourceType Microsoft.Automation/automationAccounts -ErrorAction Stop | Get-AzAutomationAccount -ErrorAction Stop } catch { $RunbookInfo = "Unknown Automation Account: $($ThisSubscription.Name) / $($ENV:Username) / $($Source)" } $JobId = $PSPrivateMetaData.JobId foreach ($AA in $AutomationAccounts) { $ThisJob = $AA | Get-AzAutomationJob -id $JobId -ErrorAction SilentlyContinue if ($null -ne $ThisJob) { Write-Verbose "Found jobid: $JobId" $RunbookInfo = "Runbook: $($ThisSubscription.Name) / $($ThisJob.ResourceGroupName) / $($ThisJob.AutomationAccountName) / $($ThisJob.RunbookName)" break } } if ($null -ne $ThisJob) { break # Break out of Foreach sub loop if we found our job } } } elseif (-not $NoRunbookInfo) { if ([string]::IsNullOrWhiteSpace($PSCMDLet.MyInvocation.ScriptName)) { $Source = "PS> $($PSCMDLet.MyInvocation.Line)" } else { $Source = $PSCMDLet.MyInvocation.ScriptName } $RunbookInfo = "Script: $((Get-WmiObject -Namespace root\cimv2 -Class Win32_ComputerSystem).Domain)\$($ENV:Computername) / $($ENV:Username) / $($Source)" } # 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') # Convert JSON to Hash table, and create variables from each property to make things easier. $JSONObject = $JSON | ConvertFrom-Json $JSONHash = @{} foreach ($prop in $JSONObject.psobject.properties.name) { New-Variable -Name $prop -Value $JSONObject.$Prop $JSONHash.Add($prop, $JSONObject.$Prop) } # If no caller id (username or email) is specified in JSON, fallback to credentials username if (-not $user_name -and -not $email) { $user_name = $UserName } # If we want to check for existing incident to update if ( -not $AlwaysCreateNew) { # 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 ($user_name) { $Query += "name=$user_name" } if ($email) { $Query += "email=$email" } $Query = "GOTO$($Query -join "^OR")".Replace("=", "%3D").Replace(" ", "%20").Replace(",", "%2C").Replace("^", "%5E") # ^ = 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] if ($null -eq $user) { Write-Warning ("No user found matching '$user_name' $(if ($email) {"or '$email'"})") } else { $caller_id = $User.sys_id } } else { if ($user_name) { $caller_id = "$user_name" } elseif ($email) { $caller_id = "$email" } } # If we need to update an existing incident but haven't got the incident number, find it based on ShortDescription and, if available, caller if ([string]::IsNullOrWhiteSpace($IncidentNumber) -and (-not $AlwaysCreateNew)) { $IncidentQuery = "stateIN1,2,3^short_description=$($short_description)" # State 1,2,3 = New, In Progress, On Hold 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 if ($AddTimestampToComments) { $comments = @("Updated: $(Get-Date -Format "dd. MMM yyyy HH:mm:ss")" $JSONHash["comments"] ) } else { $comments = @($JSONHash["comments"]) } if (-not ($incident.description -eq $JSONHash["description"])) { $comments += @( "" "Previous description:" $Incident.description ) } if ($AppendDescription) { $JSONHash["description"] = $Incident.description + [environment]::newLine + $JSONHash["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" if (-not $caller_id) { $caller_id = $Credential.UserName } # Add runtime info to make it easier to find source of tickets if ($null -ne $RunbookInfo) { if ($null -eq $JSONHash["work_notes"]) { $JSONHash.add("work_notes", $RunbookInfo) } else { $JSONHash["work_notes"] += ([environment]::NewLine + $RunbookInfo) } } $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) } foreach ($File in $Attachment) { $FileType = $File.split(".")[-1] # Not really sure how important this is. JPG seems perfectly functional even if uploaded as zip, but upload fails if no content-type is specified. # Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types # Not all content types are supported, depending on Service-Now instance configuration. if (-not $ContentType) { switch ($FileType) { "3g2" { $ContentType = "video/3gpp2" } "3gp" { $ContentType = "video/3gpp" } "7z" { $ContentType = "application/x-7z-compressed" } "aac" { $ContentType = "audio/aac" } "abw" { $ContentType = "application/x-abiword" } "arc" { $ContentType = "application/x-freearc" } "avi" { $ContentType = "video/x-msvideo" } "avif" { $ContentType = "image/avif" } "azw" { $ContentType = "application/vnd.amazon.ebook" } "bin" { $ContentType = "application/octet-stream" } "bmp" { $ContentType = "image/bmp" } "bz" { $ContentType = "application/x-bzip" } "bz2" { $ContentType = "application/x-bzip2" } "cda" { $ContentType = "application/x-cdf" } "csh" { $ContentType = "application/x-csh" } "css" { $ContentType = "text/css" } "csv" { $ContentType = "text/csv" } "doc" { $ContentType = "application/msword" } "docx" { $ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" } "eot" { $ContentType = "application/vnd.ms-fontobject" } "epub" { $ContentType = "application/epub+zip" } "gif" { $ContentType = "image/gif" } "gz" { $ContentType = "application/gzip" } "htm" { $ContentType = "text/html" } "ico" { $ContentType = "image/vnd.microsoft.icon" } "ics" { $ContentType = "text/calendar" } "jar" { $ContentType = "application/java-archive" } "jpeg" { $ContentType = "image/jpeg" } "js" { $ContentType = "text/javascript)" } "json" { $ContentType = "application/json" } "jsonld" { $ContentType = "application/ld+json" } "mid" { $ContentType = "audio/x-midi" } "mjs" { $ContentType = "text/javascript" } "mp3" { $ContentType = "audio/mpeg" } "mp4" { $ContentType = "video/mp4" } "mpeg" { $ContentType = "video/mpeg" } "mpkg" { $ContentType = "application/vnd.apple.installer+xml" } "odp" { $ContentType = "application/vnd.oasis.opendocument.presentation" } "ods" { $ContentType = "application/vnd.oasis.opendocument.spreadsheet" } "odt" { $ContentType = "application/vnd.oasis.opendocument.text" } "oga" { $ContentType = "audio/ogg" } "ogv" { $ContentType = "video/ogg" } "ogx" { $ContentType = "application/ogg" } "opus" { $ContentType = "audio/opus" } "otf" { $ContentType = "font/otf" } "pdf" { $ContentType = "application/pdf" } "php" { $ContentType = "application/x-httpd-php" } "png" { $ContentType = "image/png" } "ppt" { $ContentType = "application/vnd.ms-powerpoint" } "pptx" { $ContentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation" } "rar" { $ContentType = "application/vnd.rar" } "rtf" { $ContentType = "application/rtf" } "sh" { $ContentType = "application/x-sh" } "svg" { $ContentType = "image/svg+xml" } "swf" { $ContentType = "application/x-shockwave-flash" } "tar" { $ContentType = "application/x-tar" } "tif" { $ContentType = "image/tiff" } "ts" { $ContentType = "video/mp2t" } "ttf" { $ContentType = "font/ttf" } "txt" { $ContentType = "text/plain" } "vsd" { $ContentType = "application/vnd.visio" } "wav" { $ContentType = "audio/wav" } "weba" { $ContentType = "audio/webm" } "webm" { $ContentType = "video/webm" } "webp" { $ContentType = "image/webp" } "woff" { $ContentType = "font/woff" } "woff2" { $ContentType = "font/woff2" } "xhtml" { $ContentType = "application/xhtml+xml" } "xls" { $ContentType = "application/vnd.ms-excel" } "xlsx" { $ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } "xml" { $ContentType = "application/xml" } "xul" { $ContentType = "application/vnd.mozilla.xul+xml" } "zip" { $ContentType = "application/zip" } default { $ContentType = "text/plain" } } } $headers["Content-Type"] = $ContentType Write-Verbose "Uploading '$File' using Content-Type '$ContentType'" $URL = "https://$SNOWInstance.service-now.com/api/now/attachment/file?table_name=incident&table_sys_id=$($Result.result.sys_id)&file_name=$(Split-Path -Path $File -Leaf)" $null = Invoke-RestMethod -Method Post -Uri $URL -Headers $headers -InFile $File } 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 } |