From 6c1d2707a4337f1fa107304ba4d2855e428072ae Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 7 Apr 2021 10:25:29 +0200 Subject: [PATCH 01/28] initial upload --- .gitattributes | 2 + .github/FUNDING.yml | 13 + .github/workflows/build.yml | 23 + .github/workflows/validate.yml | 15 + .gitignore | 22 + LICENSE | 6 +- README.md | 32 +- VMDeploy.Guest/VMDeploy.Guest.psd1 | 83 + VMDeploy.Guest/VMDeploy.Guest.psm1 | 85 + VMDeploy.Guest/bin/readme.md | 7 + VMDeploy.Guest/changelog.md | 5 + .../en-us/about_VMDeploy.Guest.help.txt | 11 + VMDeploy.Guest/en-us/strings.psd1 | 5 + VMDeploy.Guest/functions/readme.md | 7 + .../internal/configurations/configuration.ps1 | 15 + .../internal/configurations/readme.md | 14 + VMDeploy.Guest/internal/functions/readme.md | 7 + .../internal/scriptblocks/scriptblocks.ps1 | 12 + VMDeploy.Guest/internal/scripts/license.ps1 | 21 + .../internal/scripts/postimport.ps1 | 26 + VMDeploy.Guest/internal/scripts/preimport.ps1 | 14 + VMDeploy.Guest/internal/scripts/strings.ps1 | 8 + VMDeploy.Guest/internal/tepp/assignment.ps1 | 4 + VMDeploy.Guest/internal/tepp/example.tepp.ps1 | 4 + VMDeploy.Guest/internal/tepp/readme.md | 23 + VMDeploy.Guest/readme.md | 17 + VMDeploy.Guest/tests/functions/readme.md | 7 + .../general/FileIntegrity.Exceptions.ps1 | 37 + .../tests/general/FileIntegrity.Tests.ps1 | 95 + .../tests/general/Help.Exceptions.ps1 | 26 + VMDeploy.Guest/tests/general/Help.Tests.ps1 | 147 + .../tests/general/Manifest.Tests.ps1 | 62 + .../tests/general/PSScriptAnalyzer.Tests.ps1 | 40 + .../tests/general/strings.Exceptions.ps1 | 19 + .../tests/general/strings.Tests.ps1 | 25 + VMDeploy.Guest/tests/pester.ps1 | 113 + VMDeploy.Guest/tests/readme.md | 31 + .../xml/VMDeploy.Guest.Format.ps1xml | 31 + .../xml/VMDeploy.Guest.Types.ps1xml | 37 + VMDeploy.Guest/xml/readme.md | 43 + azure-pipelines.yml | 20 + build/AzureFunction.readme.md | 35 + build/vsts-build.ps1 | 120 + build/vsts-createFunctionClientModule.ps1 | 201 ++ build/vsts-packageFunction.ps1 | 143 + build/vsts-prerequisites.ps1 | 25 + build/vsts-validate.ps1 | 7 + install.ps1 | 2431 +++++++++++++++++ library/VMDeploy.Guest/VMDeploy.Guest.sln | 25 + .../VMDeploy.Guest/VMDeploy.Guest/Class1.cs | 8 + .../VMDeploy.Guest/VMDeploy.Guest.csproj | 21 + 51 files changed, 4226 insertions(+), 4 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 VMDeploy.Guest/VMDeploy.Guest.psd1 create mode 100644 VMDeploy.Guest/VMDeploy.Guest.psm1 create mode 100644 VMDeploy.Guest/bin/readme.md create mode 100644 VMDeploy.Guest/changelog.md create mode 100644 VMDeploy.Guest/en-us/about_VMDeploy.Guest.help.txt create mode 100644 VMDeploy.Guest/en-us/strings.psd1 create mode 100644 VMDeploy.Guest/functions/readme.md create mode 100644 VMDeploy.Guest/internal/configurations/configuration.ps1 create mode 100644 VMDeploy.Guest/internal/configurations/readme.md create mode 100644 VMDeploy.Guest/internal/functions/readme.md create mode 100644 VMDeploy.Guest/internal/scriptblocks/scriptblocks.ps1 create mode 100644 VMDeploy.Guest/internal/scripts/license.ps1 create mode 100644 VMDeploy.Guest/internal/scripts/postimport.ps1 create mode 100644 VMDeploy.Guest/internal/scripts/preimport.ps1 create mode 100644 VMDeploy.Guest/internal/scripts/strings.ps1 create mode 100644 VMDeploy.Guest/internal/tepp/assignment.ps1 create mode 100644 VMDeploy.Guest/internal/tepp/example.tepp.ps1 create mode 100644 VMDeploy.Guest/internal/tepp/readme.md create mode 100644 VMDeploy.Guest/readme.md create mode 100644 VMDeploy.Guest/tests/functions/readme.md create mode 100644 VMDeploy.Guest/tests/general/FileIntegrity.Exceptions.ps1 create mode 100644 VMDeploy.Guest/tests/general/FileIntegrity.Tests.ps1 create mode 100644 VMDeploy.Guest/tests/general/Help.Exceptions.ps1 create mode 100644 VMDeploy.Guest/tests/general/Help.Tests.ps1 create mode 100644 VMDeploy.Guest/tests/general/Manifest.Tests.ps1 create mode 100644 VMDeploy.Guest/tests/general/PSScriptAnalyzer.Tests.ps1 create mode 100644 VMDeploy.Guest/tests/general/strings.Exceptions.ps1 create mode 100644 VMDeploy.Guest/tests/general/strings.Tests.ps1 create mode 100644 VMDeploy.Guest/tests/pester.ps1 create mode 100644 VMDeploy.Guest/tests/readme.md create mode 100644 VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml create mode 100644 VMDeploy.Guest/xml/VMDeploy.Guest.Types.ps1xml create mode 100644 VMDeploy.Guest/xml/readme.md create mode 100644 azure-pipelines.yml create mode 100644 build/AzureFunction.readme.md create mode 100644 build/vsts-build.ps1 create mode 100644 build/vsts-createFunctionClientModule.ps1 create mode 100644 build/vsts-packageFunction.ps1 create mode 100644 build/vsts-prerequisites.ps1 create mode 100644 build/vsts-validate.ps1 create mode 100644 install.ps1 create mode 100644 library/VMDeploy.Guest/VMDeploy.Guest.sln create mode 100644 library/VMDeploy.Guest/VMDeploy.Guest/Class1.cs create mode 100644 library/VMDeploy.Guest/VMDeploy.Guest/VMDeploy.Guest.csproj diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a5df2c7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ddf3214 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: + FriedrichWeinmann +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b7d0793 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +on: + push: + branches: + - main + +jobs: + build: + + runs-on: windows-2019 + + steps: + - uses: actions/checkout@v1 + - name: Install Prerequisites + run: .\build\vsts-prerequisites.ps1 + shell: powershell + - name: Validate + run: .\build\vsts-validate.ps1 + shell: powershell + - name: Build + run: .\build\vsts-build.ps1 -ApiKey $env:APIKEY + shell: powershell + env: + APIKEY: ${{ secrets.ApiKey }} \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..0b516ce --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,15 @@ +on: [pull_request] + +jobs: + validate: + + runs-on: windows-2019 + + steps: + - uses: actions/checkout@v1 + - name: Install Prerequisites + run: .\build\vsts-prerequisites.ps1 + shell: powershell + - name: Validate + run: .\build\vsts-validate.ps1 + shell: powershell \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3df33ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ + +# ignore the settings folder and files for VSCode and PSS +.vscode/* +*.psproj +*TempPoint* + +# Ignore staging info from Visual Studio +library/VMDeploy.Guest/.vs/* +library/VMDeploy.Guest/VMDeploy.Guest/bin/* +library/VMDeploy.Guest/VMDeploy.Guest/obj/* + +# ignore PowerShell Studio MetaData +VMDeploy.Guest/VMDeploy.Guest.psproj +VMDeploy.Guest/VMDeploy.Guest.psproj.bak +VMDeploy.Guest/VMDeploy.Guest.psprojs +VMDeploy.Guest/VMDeploy.Guest.psproj + +# ignore the TestResults +TestResults/* + +# ignore the publishing Directory +publish/* \ No newline at end of file diff --git a/LICENSE b/LICENSE index c823f58..623cdf8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -MIT License +MIT License -Copyright (c) 2021 VMDeployment +Copyright (c) 2021 Friedrich Weinmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 2cf300d..1f96aa1 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -# VMDeploy.Guest \ No newline at end of file +# Description + +Insert a useful description for the VMDeploy.Guest project here. + +Remember, it's the first thing a visitor will see. + +# Project Setup Instructions +## Working with the layout + +- Don't touch the psm1 file +- Place functions you export in `functions/` (can have subfolders) +- Place private/internal functions invisible to the user in `internal/functions` (can have subfolders) +- Don't add code directly to the `postimport.ps1` or `preimport.ps1`. + Those files are designed to import other files only. +- When adding files & folders, make sure they are covered by either `postimport.ps1` or `preimport.ps1`. + This adds them to both the import and the build sequence. + +## Setting up CI/CD + +> To create a PR validation pipeline, set up tasks like this: + +- Install Prerequisites (PowerShell Task; VSTS-Prerequisites.ps1) +- Validate (PowerShell Task; VSTS-Validate.ps1) +- Publish Test Results (Publish Test Results; NUnit format; Run no matter what) + +> To create a build/publish pipeline, set up tasks like this: + +- Install Prerequisites (PowerShell Task; VSTS-Prerequisites.ps1) +- Validate (PowerShell Task; VSTS-Validate.ps1) +- Build (PowerShell Task; VSTS-Build.ps1) +- Publish Test Results (Publish Test Results; NUnit format; Run no matter what) diff --git a/VMDeploy.Guest/VMDeploy.Guest.psd1 b/VMDeploy.Guest/VMDeploy.Guest.psd1 new file mode 100644 index 0000000..b1d29bc --- /dev/null +++ b/VMDeploy.Guest/VMDeploy.Guest.psd1 @@ -0,0 +1,83 @@ +@{ + # Script module or binary module file associated with this manifest + RootModule = 'VMDeploy.Guest.psm1' + + # Version number of this module. + ModuleVersion = '1.0.0' + + # ID used to uniquely identify this module + GUID = '254b0808-8c89-47f9-b9fa-97e865a56d1e' + + # Author of this module + Author = 'Friedrich Weinmann' + + # Company or vendor of this module + CompanyName = 'Microsoft' + + # Copyright statement for this module + Copyright = 'Copyright (c) 2021 Friedrich Weinmann' + + # Description of the functionality provided by this module + Description = 'Configure the guest OS as part of the VMDeployment Process' + + # Minimum version of the Windows PowerShell engine required by this module + PowerShellVersion = '5.0' + + # Modules that must be imported into the global environment prior to importing + # this module + RequiredModules = @( + @{ ModuleName='PSFramework'; ModuleVersion='1.6.193' } + ) + + # Assemblies that must be loaded prior to importing this module + # RequiredAssemblies = @('bin\VMDeploy.Guest.dll') + + # Type files (.ps1xml) to be loaded when importing this module + # TypesToProcess = @('xml\VMDeploy.Guest.Types.ps1xml') + + # Format files (.ps1xml) to be loaded when importing this module + # FormatsToProcess = @('xml\VMDeploy.Guest.Format.ps1xml') + + # Functions to export from this module + FunctionsToExport = '' + + # Cmdlets to export from this module + CmdletsToExport = '' + + # Variables to export from this module + VariablesToExport = '' + + # Aliases to export from this module + AliasesToExport = '' + + # List of all modules packaged with this module + ModuleList = @() + + # List of all files packaged with this module + FileList = @() + + # Private data to pass to the module specified in ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + + #Support for PowerShellGet galleries. + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + } # End of PSData hashtable + + } # End of PrivateData hashtable +} \ No newline at end of file diff --git a/VMDeploy.Guest/VMDeploy.Guest.psm1 b/VMDeploy.Guest/VMDeploy.Guest.psm1 new file mode 100644 index 0000000..82f630f --- /dev/null +++ b/VMDeploy.Guest/VMDeploy.Guest.psm1 @@ -0,0 +1,85 @@ +$script:ModuleRoot = $PSScriptRoot +$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\VMDeploy.Guest.psd1").ModuleVersion + +# Detect whether at some level dotsourcing was enforced +$script:doDotSource = Get-PSFConfigValue -FullName VMDeploy.Guest.Import.DoDotSource -Fallback $false +if ($VMDeployGuest_dotsourcemodule) { $script:doDotSource = $true } + +<# +Note on Resolve-Path: +All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. +This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. +Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. +This is important when testing for paths. +#> + +# Detect whether at some level loading individual module files, rather than the compiled module was enforced +$importIndividualFiles = Get-PSFConfigValue -FullName VMDeploy.Guest.Import.IndividualFiles -Fallback $false +if ($VMDeployGuest_importIndividualFiles) { $importIndividualFiles = $true } +if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } +if ("" -eq '') { $importIndividualFiles = $true } + +function Import-ModuleFile +{ + <# + .SYNOPSIS + Loads files into the module on module import. + + .DESCRIPTION + This helper function is used during module initialization. + It should always be dotsourced itself, in order to proper function. + + This provides a central location to react to files being imported, if later desired + + .PARAMETER Path + The path to the file to load + + .EXAMPLE + PS C:\> . Import-ModuleFile -File $function.FullName + + Imports the file stored in $function according to import policy + #> + [CmdletBinding()] + Param ( + [string] + $Path + ) + + $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath + if ($doDotSource) { . $resolvedPath } + else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } +} + +#region Load individual files +if ($importIndividualFiles) +{ + # Execute Preimport actions + foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { + . Import-ModuleFile -Path $path + } + + # Import all internal functions + foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) + { + . Import-ModuleFile -Path $function.FullName + } + + # Import all public functions + foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) + { + . Import-ModuleFile -Path $function.FullName + } + + # Execute Postimport actions + foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { + . Import-ModuleFile -Path $path + } + + # End it here, do not load compiled code below + return +} +#endregion Load individual files + +#region Load compiled code +"" +#endregion Load compiled code \ No newline at end of file diff --git a/VMDeploy.Guest/bin/readme.md b/VMDeploy.Guest/bin/readme.md new file mode 100644 index 0000000..3379e65 --- /dev/null +++ b/VMDeploy.Guest/bin/readme.md @@ -0,0 +1,7 @@ +# bin folder + +The bin folder exists to store binary data. And scripts related to the type system. + +This may include your own C#-based library, third party libraries you want to include (watch the license!), or a script declaring type accelerators (effectively aliases for .NET types) + +For more information on Type Accelerators, see the help on Set-PSFTypeAlias \ No newline at end of file diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md new file mode 100644 index 0000000..ea9aaeb --- /dev/null +++ b/VMDeploy.Guest/changelog.md @@ -0,0 +1,5 @@ +# Changelog +## 1.0.0 (2021-04-07) + - New: Some Stuff + - Upd: Moar Stuff + - Fix: Much Stuff \ No newline at end of file diff --git a/VMDeploy.Guest/en-us/about_VMDeploy.Guest.help.txt b/VMDeploy.Guest/en-us/about_VMDeploy.Guest.help.txt new file mode 100644 index 0000000..5be3a53 --- /dev/null +++ b/VMDeploy.Guest/en-us/about_VMDeploy.Guest.help.txt @@ -0,0 +1,11 @@ +TOPIC + about_VMDeploy.Guest + +SHORT DESCRIPTION + Explains how to use the VMDeploy.Guest powershell module + +LONG DESCRIPTION + + +KEYWORDS + VMDeploy.Guest \ No newline at end of file diff --git a/VMDeploy.Guest/en-us/strings.psd1 b/VMDeploy.Guest/en-us/strings.psd1 new file mode 100644 index 0000000..7014c33 --- /dev/null +++ b/VMDeploy.Guest/en-us/strings.psd1 @@ -0,0 +1,5 @@ +# This is where the strings go, that are written by +# Write-PSFMessage, Stop-PSFFunction or the PSFramework validation scriptblocks +@{ + 'key' = 'Value' +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/readme.md b/VMDeploy.Guest/functions/readme.md new file mode 100644 index 0000000..280e32c --- /dev/null +++ b/VMDeploy.Guest/functions/readme.md @@ -0,0 +1,7 @@ +# Functions + +This is the folder where the functions go. + +Depending on the complexity of the module, it is recommended to subdivide them into subfolders. + +The module will pick up all .ps1 files recursively \ No newline at end of file diff --git a/VMDeploy.Guest/internal/configurations/configuration.ps1 b/VMDeploy.Guest/internal/configurations/configuration.ps1 new file mode 100644 index 0000000..dd71b45 --- /dev/null +++ b/VMDeploy.Guest/internal/configurations/configuration.ps1 @@ -0,0 +1,15 @@ +<# +This is an example configuration file + +By default, it is enough to have a single one of them, +however if you have enough configuration settings to justify having multiple copies of it, +feel totally free to split them into multiple files. +#> + +<# +# Example Configuration +Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" +#> + +Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." +Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." \ No newline at end of file diff --git a/VMDeploy.Guest/internal/configurations/readme.md b/VMDeploy.Guest/internal/configurations/readme.md new file mode 100644 index 0000000..d6c3210 --- /dev/null +++ b/VMDeploy.Guest/internal/configurations/readme.md @@ -0,0 +1,14 @@ +# Configurations + +Through the `PSFramework` you have a simple method that allows you to ... + + - Publish settings + - With onboard documentation + - Input validation + - Scripts that run on change of settings + - That can be discovered and updated by the user + - That can be administrated by policy & DSC + +The configuration system is a bit too complex to describe in a help file, you can however visit us at http://psframework.org for detailed guidance. + +An example can be seen in the attached ps1 file \ No newline at end of file diff --git a/VMDeploy.Guest/internal/functions/readme.md b/VMDeploy.Guest/internal/functions/readme.md new file mode 100644 index 0000000..c7074e5 --- /dev/null +++ b/VMDeploy.Guest/internal/functions/readme.md @@ -0,0 +1,7 @@ +# Functions + +This is the folder where the internal functions go. + +Depending on the complexity of the module, it is recommended to subdivide them into subfolders. + +The module will pick up all .ps1 files recursively \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scriptblocks/scriptblocks.ps1 b/VMDeploy.Guest/internal/scriptblocks/scriptblocks.ps1 new file mode 100644 index 0000000..c3b720a --- /dev/null +++ b/VMDeploy.Guest/internal/scriptblocks/scriptblocks.ps1 @@ -0,0 +1,12 @@ +<# +Stored scriptblocks are available in [PsfValidateScript()] attributes. +This makes it easier to centrally provide the same scriptblock multiple times, +without having to maintain it in separate locations. + +It also prevents lengthy validation scriptblocks from making your parameter block +hard to read. + +Set-PSFScriptblock -Name 'VMDeploy.Guest.ScriptBlockName' -Scriptblock { + +} +#> \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/license.ps1 b/VMDeploy.Guest/internal/scripts/license.ps1 new file mode 100644 index 0000000..6915745 --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/license.ps1 @@ -0,0 +1,21 @@ +New-PSFLicense -Product 'VMDeploy.Guest' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2021-04-07") -Text @" +Copyright (c) 2021 Friedrich Weinmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +"@ \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/postimport.ps1 b/VMDeploy.Guest/internal/scripts/postimport.ps1 new file mode 100644 index 0000000..81583ec --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/postimport.ps1 @@ -0,0 +1,26 @@ +<# +Add all things you want to run after importing the main function code + +WARNING: ONLY provide paths to files! + +After building the module, this file will be completely ignored, adding anything but paths to files ... +- Will not work after publishing +- Could break the build process +#> + +$moduleRoot = Split-Path (Split-Path $PSScriptRoot) + +# Load Configurations +(Get-ChildItem "$moduleRoot\internal\configurations\*.ps1" -ErrorAction Ignore).FullName + +# Load Scriptblocks +(Get-ChildItem "$moduleRoot\internal\scriptblocks\*.ps1" -ErrorAction Ignore).FullName + +# Load Tab Expansion +(Get-ChildItem "$moduleRoot\internal\tepp\*.tepp.ps1" -ErrorAction Ignore).FullName + +# Load Tab Expansion Assignment +"$moduleRoot\internal\tepp\assignment.ps1" + +# Load License +"$moduleRoot\internal\scripts\license.ps1" \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/preimport.ps1 b/VMDeploy.Guest/internal/scripts/preimport.ps1 new file mode 100644 index 0000000..475843b --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/preimport.ps1 @@ -0,0 +1,14 @@ +<# +Add all things you want to run before importing the main function code. + +WARNING: ONLY provide paths to files! + +After building the module, this file will be completely ignored, adding anything but paths to files ... +- Will not work after publishing +- Could break the build process +#> + +$moduleRoot = Split-Path (Split-Path $PSScriptRoot) + +# Load the strings used in messages +"$moduleRoot\internal\scripts\strings.ps1" \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/strings.ps1 b/VMDeploy.Guest/internal/scripts/strings.ps1 new file mode 100644 index 0000000..f723b86 --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/strings.ps1 @@ -0,0 +1,8 @@ +<# +This file loads the strings documents from the respective language folders. +This allows localizing messages and errors. +Load psd1 language files for each language you wish to support. +Partial translations are acceptable - when missing a current language message, +it will fallback to English or another available language. +#> +Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'VMDeploy.Guest' -Language 'en-US' \ No newline at end of file diff --git a/VMDeploy.Guest/internal/tepp/assignment.ps1 b/VMDeploy.Guest/internal/tepp/assignment.ps1 new file mode 100644 index 0000000..ada4919 --- /dev/null +++ b/VMDeploy.Guest/internal/tepp/assignment.ps1 @@ -0,0 +1,4 @@ +<# +# Example: +Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name VMDeploy.Guest.alcohol +#> \ No newline at end of file diff --git a/VMDeploy.Guest/internal/tepp/example.tepp.ps1 b/VMDeploy.Guest/internal/tepp/example.tepp.ps1 new file mode 100644 index 0000000..7d2a091 --- /dev/null +++ b/VMDeploy.Guest/internal/tepp/example.tepp.ps1 @@ -0,0 +1,4 @@ +<# +# Example: +Register-PSFTeppScriptblock -Name "VMDeploy.Guest.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } +#> \ No newline at end of file diff --git a/VMDeploy.Guest/internal/tepp/readme.md b/VMDeploy.Guest/internal/tepp/readme.md new file mode 100644 index 0000000..d8b2e71 --- /dev/null +++ b/VMDeploy.Guest/internal/tepp/readme.md @@ -0,0 +1,23 @@ +# Tab Expansion + +## Description + +Modern Tab Expansion was opened to users with the module `Tab Expansion Plus Plus` (TEPP). + +It allows you to define, what options a user is offered when tabbing through input options. This can save a lot of time for the user and is considered a key element in user experience. + +The `PSFramework` offers a simplified way of offering just this, as the two example files show. + +## Concept + +Custom tab completion is defined in two steps: + + - Define a scriptblock that is run when the user hits `TAB` and provides the strings that are his options. + - Assign that scriptblock to the parameter of a command. You can assign the same scriptblock multiple times. + +## Structure + +Import order matters. In order to make things work with the default scaffold, follow those rules: + + - All scriptfiles _defining_ completion scriptblocks like this: `*.tepp.ps1` + - Put all your completion assignments in `assignment.ps1` \ No newline at end of file diff --git a/VMDeploy.Guest/readme.md b/VMDeploy.Guest/readme.md new file mode 100644 index 0000000..84181ea --- /dev/null +++ b/VMDeploy.Guest/readme.md @@ -0,0 +1,17 @@ +# PSFModule guidance + +This is a finished module layout optimized for implementing the PSFramework. + +If you don't care to deal with the details, this is what you need to do to get started seeing results: + + - Add the functions you want to publish to `/functions/` + - Update the `FunctionsToExport` node in the module manifest (VMDeploy.Guest.psd1). All functions you want to publish should be in a list. + - Add internal helper functions the user should not see to `/internal/functions/` + + ## Path Warning + + > If you want your module to be compatible with Linux and MacOS, keep in mind that those OS are case sensitive for paths and files. + + `Import-ModuleFile` is preconfigured to resolve the path of the files specified, so it will reliably convert weird path notations the system can't handle. + Content imported through that command thus need not mind the path separator. + If you want to make sure your code too will survive OS-specific path notations, get used to using `Resolve-path` or the more powerful `Resolve-PSFPath`. \ No newline at end of file diff --git a/VMDeploy.Guest/tests/functions/readme.md b/VMDeploy.Guest/tests/functions/readme.md new file mode 100644 index 0000000..f2b2ef0 --- /dev/null +++ b/VMDeploy.Guest/tests/functions/readme.md @@ -0,0 +1,7 @@ +# Description + +This is where the function tests go. + +Make sure to put them in folders reflecting the actual module structure. + +It is not necessary to differentiate between internal and public functions here. \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/FileIntegrity.Exceptions.ps1 b/VMDeploy.Guest/tests/general/FileIntegrity.Exceptions.ps1 new file mode 100644 index 0000000..e385e41 --- /dev/null +++ b/VMDeploy.Guest/tests/general/FileIntegrity.Exceptions.ps1 @@ -0,0 +1,37 @@ +# List of forbidden commands +$global:BannedCommands = @( + 'Write-Host' + 'Write-Verbose' + 'Write-Warning' + 'Write-Error' + 'Write-Output' + 'Write-Information' + 'Write-Debug' + + # Use CIM instead where possible + 'Get-WmiObject' + 'Invoke-WmiMethod' + 'Register-WmiEvent' + 'Remove-WmiObject' + 'Set-WmiInstance' + + # Use Get-WinEvent instead + 'Get-EventLog' +) + +<# + Contains list of exceptions for banned cmdlets. + Insert the file names of files that may contain them. + + Example: + "Write-Host" = @('Write-PSFHostColor.ps1','Write-PSFMessage.ps1') +#> +$global:MayContainCommand = @{ + "Write-Host" = @() + "Write-Verbose" = @() + "Write-Warning" = @() + "Write-Error" = @() + "Write-Output" = @() + "Write-Information" = @() + "Write-Debug" = @() +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/FileIntegrity.Tests.ps1 b/VMDeploy.Guest/tests/general/FileIntegrity.Tests.ps1 new file mode 100644 index 0000000..89e6c9c --- /dev/null +++ b/VMDeploy.Guest/tests/general/FileIntegrity.Tests.ps1 @@ -0,0 +1,95 @@ +$moduleRoot = (Resolve-Path "$global:testroot\..").Path + +. "$global:testroot\general\FileIntegrity.Exceptions.ps1" + +Describe "Verifying integrity of module files" { + BeforeAll { + function Get-FileEncoding + { + <# + .SYNOPSIS + Tests a file for encoding. + + .DESCRIPTION + Tests a file for encoding. + + .PARAMETER Path + The file to test + #> + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)] + [Alias('FullName')] + [string] + $Path + ) + + if ($PSVersionTable.PSVersion.Major -lt 6) + { + [byte[]]$byte = get-content -Encoding byte -ReadCount 4 -TotalCount 4 -Path $Path + } + else + { + [byte[]]$byte = Get-Content -AsByteStream -ReadCount 4 -TotalCount 4 -Path $Path + } + + if ($byte[0] -eq 0xef -and $byte[1] -eq 0xbb -and $byte[2] -eq 0xbf) { 'UTF8 BOM' } + elseif ($byte[0] -eq 0xfe -and $byte[1] -eq 0xff) { 'Unicode' } + elseif ($byte[0] -eq 0 -and $byte[1] -eq 0 -and $byte[2] -eq 0xfe -and $byte[3] -eq 0xff) { 'UTF32' } + elseif ($byte[0] -eq 0x2b -and $byte[1] -eq 0x2f -and $byte[2] -eq 0x76) { 'UTF7' } + else { 'Unknown' } + } + } + + Context "Validating PS1 Script files" { + $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.ps1" | Where-Object FullName -NotLike "$moduleRoot\tests\*" + + foreach ($file in $allFiles) + { + $name = $file.FullName.Replace("$moduleRoot\", '') + + It "[$name] Should have UTF8 encoding with Byte Order Mark" -TestCases @{ file = $file } { + Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' + } + + It "[$name] Should have no trailing space" -TestCases @{ file = $file } { + ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0}).LineNumber | Should -BeNullOrEmpty + } + + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors) + + It "[$name] Should have no syntax errors" -TestCases @{ parseErrors = $parseErrors } { + $parseErrors | Should -BeNullOrEmpty + } + + foreach ($command in $global:BannedCommands) + { + if ($global:MayContainCommand["$command"] -notcontains $file.Name) + { + It "[$name] Should not use $command" -TestCases @{ tokens = $tokens; command = $command } { + $tokens | Where-Object Text -EQ $command | Should -BeNullOrEmpty + } + } + } + } + } + + Context "Validating help.txt help files" { + $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.help.txt" | Where-Object FullName -NotLike "$moduleRoot\tests\*" + + foreach ($file in $allFiles) + { + $name = $file.FullName.Replace("$moduleRoot\", '') + + It "[$name] Should have UTF8 encoding" -TestCases @{ file = $file } { + Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' + } + + It "[$name] Should have no trailing space" -TestCases @{ file = $file } { + ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0 } | Measure-Object).Count | Should -Be 0 + } + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/Help.Exceptions.ps1 b/VMDeploy.Guest/tests/general/Help.Exceptions.ps1 new file mode 100644 index 0000000..f9c9bd7 --- /dev/null +++ b/VMDeploy.Guest/tests/general/Help.Exceptions.ps1 @@ -0,0 +1,26 @@ +# List of functions that should be ignored +$global:FunctionHelpTestExceptions = @( + +) + +<# + List of arrayed enumerations. These need to be treated differently. Add full name. + Example: + + "Sqlcollaborative.Dbatools.Connection.ManagementConnectionType[]" +#> +$global:HelpTestEnumeratedArrays = @( + +) + +<# + Some types on parameters just fail their validation no matter what. + For those it becomes possible to skip them, by adding them to this hashtable. + Add by following this convention: = @() + Example: + + "Get-DbaCmObject" = @("DoNotUse") +#> +$global:HelpTestSkipParameterType = @{ + +} diff --git a/VMDeploy.Guest/tests/general/Help.Tests.ps1 b/VMDeploy.Guest/tests/general/Help.Tests.ps1 new file mode 100644 index 0000000..7914d4b --- /dev/null +++ b/VMDeploy.Guest/tests/general/Help.Tests.ps1 @@ -0,0 +1,147 @@ +<# + .NOTES + The original test this is based upon was written by June Blender. + After several rounds of modifications it stands now as it is, but the honor remains hers. + + Thank you June, for all you have done! + + .DESCRIPTION + This test evaluates the help for all commands in a module. + + .PARAMETER SkipTest + Disables this test. + + .PARAMETER CommandPath + List of paths under which the script files are stored. + This test assumes that all functions have their own file that is named after themselves. + These paths are used to search for commands that should exist and be tested. + Will search recursively and accepts wildcards, make sure only functions are found + + .PARAMETER ModuleName + Name of the module to be tested. + The module must already be imported + + .PARAMETER ExceptionsFile + File in which exceptions and adjustments are configured. + In it there should be two arrays and a hashtable defined: + $global:FunctionHelpTestExceptions + $global:HelpTestEnumeratedArrays + $global:HelpTestSkipParameterType + These can be used to tweak the tests slightly in cases of need. + See the example file for explanations on each of these usage and effect. +#> +[CmdletBinding()] +Param ( + [switch] + $SkipTest, + + [string[]] + $CommandPath = @("$global:testroot\..\functions", "$global:testroot\..\internal\functions"), + + [string] + $ModuleName = "VMDeploy.Guest", + + [string] + $ExceptionsFile = "$global:testroot\general\Help.Exceptions.ps1" +) +if ($SkipTest) { return } +. $ExceptionsFile + +$includedNames = (Get-ChildItem $CommandPath -Recurse -File | Where-Object Name -like "*.ps1").BaseName +$commandTypes = @('Cmdlet', 'Function') +if ($PSVersionTable.PSEdition -eq 'Desktop' ) { $commandTypes += 'Workflow' } +$commands = Get-Command -Module (Get-Module $ModuleName) -CommandType $commandTypes | Where-Object Name -In $includedNames + +## When testing help, remember that help is cached at the beginning of each session. +## To test, restart session. + + +foreach ($command in $commands) { + $commandName = $command.Name + + # Skip all functions that are on the exclusions list + if ($global:FunctionHelpTestExceptions -contains $commandName) { continue } + + # The module-qualified command fails on Microsoft.PowerShell.Archive cmdlets + $Help = Get-Help $commandName -ErrorAction SilentlyContinue + + Describe "Test help for $commandName" { + + # If help is not found, synopsis in auto-generated help is the syntax diagram + It "should not be auto-generated" -TestCases @{ Help = $Help } { + $Help.Synopsis | Should -Not -BeLike '*`[``]*' + } + + # Should be a description for every function + It "gets description for $commandName" -TestCases @{ Help = $Help } { + $Help.Description | Should -Not -BeNullOrEmpty + } + + # Should be at least one example + It "gets example code from $commandName" -TestCases @{ Help = $Help } { + ($Help.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty + } + + # Should be at least one example description + It "gets example help from $commandName" -TestCases @{ Help = $Help } { + ($Help.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty + } + + Context "Test parameter help for $commandName" { + + $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable' + + $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common + $parameterNames = $parameters.Name + $HelpParameterNames = $Help.Parameters.Parameter.Name | Sort-Object -Unique + foreach ($parameter in $parameters) { + $parameterName = $parameter.Name + $parameterHelp = $Help.parameters.parameter | Where-Object Name -EQ $parameterName + + # Should be a description for every parameter + It "gets help for parameter: $parameterName : in $commandName" -TestCases @{ parameterHelp = $parameterHelp } { + $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty + } + + $codeMandatory = $parameter.IsMandatory.toString() + It "help for $parameterName parameter in $commandName has correct Mandatory value" -TestCases @{ parameterHelp = $parameterHelp; codeMandatory = $codeMandatory } { + $parameterHelp.Required | Should -Be $codeMandatory + } + + if ($HelpTestSkipParameterType[$commandName] -contains $parameterName) { continue } + + $codeType = $parameter.ParameterType.Name + + if ($parameter.ParameterType.IsEnum) { + # Enumerations often have issues with the typename not being reliably available + $names = $parameter.ParameterType::GetNames($parameter.ParameterType) + # Parameter type in Help should match code + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { + $parameterHelp.parameterValueGroup.parameterValue | Should -be $names + } + } + elseif ($parameter.ParameterType.FullName -in $HelpTestEnumeratedArrays) { + # Enumerations often have issues with the typename not being reliably available + $names = [Enum]::GetNames($parameter.ParameterType.DeclaredMembers[0].ReturnType) + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { + $parameterHelp.parameterValueGroup.parameterValue | Should -be $names + } + } + else { + # To avoid calling Trim method on a null object. + $helpType = if ($parameterHelp.parameterValue) { $parameterHelp.parameterValue.Trim() } + # Parameter type in Help should match code + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ helpType = $helpType; codeType = $codeType } { + $helpType | Should -be $codeType + } + } + } + foreach ($helpParm in $HelpParameterNames) { + # Shouldn't find extra parameters in help. + It "finds help parameter in code: $helpParm" -TestCases @{ helpParm = $helpParm; parameterNames = $parameterNames } { + $helpParm -in $parameterNames | Should -Be $true + } + } + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/Manifest.Tests.ps1 b/VMDeploy.Guest/tests/general/Manifest.Tests.ps1 new file mode 100644 index 0000000..255e0e5 --- /dev/null +++ b/VMDeploy.Guest/tests/general/Manifest.Tests.ps1 @@ -0,0 +1,62 @@ +Describe "Validating the module manifest" { + $moduleRoot = (Resolve-Path "$global:testroot\..").Path + $manifest = ((Get-Content "$moduleRoot\VMDeploy.Guest.psd1") -join "`n") | Invoke-Expression + Context "Basic resources validation" { + $files = Get-ChildItem "$moduleRoot\functions" -Recurse -File | Where-Object Name -like "*.ps1" + It "Exports all functions in the public folder" -TestCases @{ files = $files; manifest = $manifest } { + + $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '<=').InputObject + $functions | Should -BeNullOrEmpty + } + It "Exports no function that isn't also present in the public folder" -TestCases @{ files = $files; manifest = $manifest } { + $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '=>').InputObject + $functions | Should -BeNullOrEmpty + } + + It "Exports none of its internal functions" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { + $files = Get-ChildItem "$moduleRoot\internal\functions" -Recurse -File -Filter "*.ps1" + $files | Where-Object BaseName -In $manifest.FunctionsToExport | Should -BeNullOrEmpty + } + } + + Context "Individual file validation" { + It "The root module file exists" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { + Test-Path "$moduleRoot\$($manifest.RootModule)" | Should -Be $true + } + + foreach ($format in $manifest.FormatsToProcess) + { + It "The file $format should exist" -TestCases @{ moduleRoot = $moduleRoot; format = $format } { + Test-Path "$moduleRoot\$format" | Should -Be $true + } + } + + foreach ($type in $manifest.TypesToProcess) + { + It "The file $type should exist" -TestCases @{ moduleRoot = $moduleRoot; type = $type } { + Test-Path "$moduleRoot\$type" | Should -Be $true + } + } + + foreach ($assembly in $manifest.RequiredAssemblies) + { + if ($assembly -like "*.dll") { + It "The file $assembly should exist" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { + Test-Path "$moduleRoot\$assembly" | Should -Be $true + } + } + else { + It "The file $assembly should load from the GAC" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { + { Add-Type -AssemblyName $assembly } | Should -Not -Throw + } + } + } + + foreach ($tag in $manifest.PrivateData.PSData.Tags) + { + It "Tags should have no spaces in name" -TestCases @{ tag = $tag } { + $tag -match " " | Should -Be $false + } + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/PSScriptAnalyzer.Tests.ps1 b/VMDeploy.Guest/tests/general/PSScriptAnalyzer.Tests.ps1 new file mode 100644 index 0000000..0acb18c --- /dev/null +++ b/VMDeploy.Guest/tests/general/PSScriptAnalyzer.Tests.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +Param ( + [switch] + $SkipTest, + + [string[]] + $CommandPath = @("$global:testroot\..\functions", "$global:testroot\..\internal\functions") +) + +if ($SkipTest) { return } + +$global:__pester_data.ScriptAnalyzer = New-Object System.Collections.ArrayList + +Describe 'Invoking PSScriptAnalyzer against commandbase' { + $commandFiles = foreach ($path in $CommandPath) { Get-ChildItem -Path $path -Recurse | Where-Object Name -like "*.ps1" } + $scriptAnalyzerRules = Get-ScriptAnalyzerRule + + foreach ($file in $commandFiles) + { + Context "Analyzing $($file.BaseName)" { + $analysis = Invoke-ScriptAnalyzer -Path $file.FullName -ExcludeRule PSAvoidTrailingWhitespace, PSShouldProcess + + forEach ($rule in $scriptAnalyzerRules) + { + It "Should pass $rule" -TestCases @{ analysis = $analysis; rule = $rule } { + If ($analysis.RuleName -contains $rule) + { + $analysis | Where-Object RuleName -EQ $rule -outvariable failures | ForEach-Object { $null = $global:__pester_data.ScriptAnalyzer.Add($_) } + + 1 | Should -Be 0 + } + else + { + 0 | Should -Be 0 + } + } + } + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 b/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 new file mode 100644 index 0000000..0a11c98 --- /dev/null +++ b/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 @@ -0,0 +1,19 @@ +$exceptions = @{ } + +<# +A list of entries that MAY be in the language files, without causing the tests to fail. +This is commonly used in modules that generate localized messages straight from C#. +Specify the full key as it is written in the language files, do not prepend the modulename, +as you would have to in C# code. + +Example: +$exceptions['LegalSurplus'] = @( + 'Exception.Streams.FailedCreate' + 'Exception.Streams.FailedDispose' +) +#> +$exceptions['LegalSurplus'] = @( + +) + +$exceptions \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/strings.Tests.ps1 b/VMDeploy.Guest/tests/general/strings.Tests.ps1 new file mode 100644 index 0000000..910224a --- /dev/null +++ b/VMDeploy.Guest/tests/general/strings.Tests.ps1 @@ -0,0 +1,25 @@ +<# +.DESCRIPTION + This test verifies, that all strings that have been used, + are listed in the language files and thus have a message being displayed. + + It also checks, whether the language files have orphaned entries that need cleaning up. +#> + + + +Describe "Testing localization strings" { + $moduleRoot = (Get-Module VMDeploy.Guest).ModuleBase + $stringsResults = Export-PSMDString -ModuleRoot $moduleRoot + $exceptions = & "$global:testroot\general\strings.Exceptions.ps1" + + foreach ($stringEntry in $stringsResults) { + if ($stringEntry.String -eq "key") { continue } # Skipping the template default entry + It "Should be used & have text: $($stringEntry.String)" -TestCases @{ stringEntry = $stringEntry } { + if ($exceptions.LegalSurplus -notcontains $stringEntry.String) { + $stringEntry.Surplus | Should -BeFalse + } + $stringEntry.Text | Should -Not -BeNullOrEmpty + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/pester.ps1 b/VMDeploy.Guest/tests/pester.ps1 new file mode 100644 index 0000000..c810491 --- /dev/null +++ b/VMDeploy.Guest/tests/pester.ps1 @@ -0,0 +1,113 @@ +param ( + $TestGeneral = $true, + + $TestFunctions = $true, + + [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] + [Alias('Show')] + $Output = "None", + + $Include = "*", + + $Exclude = "" +) + +Write-PSFMessage -Level Important -Message "Starting Tests" + +Write-PSFMessage -Level Important -Message "Importing Module" + +$global:testroot = $PSScriptRoot +$global:__pester_data = @{ } + +Remove-Module VMDeploy.Guest -ErrorAction Ignore +Import-Module "$PSScriptRoot\..\VMDeploy.Guest.psd1" +Import-Module "$PSScriptRoot\..\VMDeploy.Guest.psm1" -Force + +# Need to import explicitly so we can use the configuration class +Import-Module Pester + +Write-PSFMessage -Level Important -Message "Creating test result folder" +$null = New-Item -Path "$PSScriptRoot\..\.." -Name TestResults -ItemType Directory -Force + +$totalFailed = 0 +$totalRun = 0 + +$testresults = @() +$config = [PesterConfiguration]::Default +$config.TestResult.Enabled = $true + +#region Run General Tests +if ($TestGeneral) +{ + Write-PSFMessage -Level Important -Message "Modules imported, proceeding with general tests" + foreach ($file in (Get-ChildItem "$PSScriptRoot\general" | Where-Object Name -like "*.Tests.ps1")) + { + if ($file.Name -notlike $Include) { continue } + if ($file.Name -like $Exclude) { continue } + + Write-PSFMessage -Level Significant -Message " Executing $($file.Name)" + $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\..\TestResults" "TEST-$($file.BaseName).xml" + $config.Run.Path = $file.FullName + $config.Run.PassThru = $true + $config.Output.Verbosity = $Output + $results = Invoke-Pester -Configuration $config + foreach ($result in $results) + { + $totalRun += $result.TotalCount + $totalFailed += $result.FailedCount + $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { + $testresults += [pscustomobject]@{ + Block = $_.Block + Name = "It $($_.Name)" + Result = $_.Result + Message = $_.ErrorRecord.DisplayErrorMessage + } + } + } + } +} +#endregion Run General Tests + +$global:__pester_data.ScriptAnalyzer | Out-Host + +#region Test Commands +if ($TestFunctions) +{ + Write-PSFMessage -Level Important -Message "Proceeding with individual tests" + foreach ($file in (Get-ChildItem "$PSScriptRoot\functions" -Recurse -File | Where-Object Name -like "*Tests.ps1")) + { + if ($file.Name -notlike $Include) { continue } + if ($file.Name -like $Exclude) { continue } + + Write-PSFMessage -Level Significant -Message " Executing $($file.Name)" + $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\..\TestResults" "TEST-$($file.BaseName).xml" + $config.Run.Path = $file.FullName + $config.Run.PassThru = $true + $config.Output.Verbosity = $Output + $results = Invoke-Pester -Configuration $config + foreach ($result in $results) + { + $totalRun += $result.TotalCount + $totalFailed += $result.FailedCount + $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { + $testresults += [pscustomobject]@{ + Block = $_.Block + Name = "It $($_.Name)" + Result = $_.Result + Message = $_.ErrorRecord.DisplayErrorMessage + } + } + } + } +} +#endregion Test Commands + +$testresults | Sort-Object Describe, Context, Name, Result, Message | Format-List + +if ($totalFailed -eq 0) { Write-PSFMessage -Level Critical -Message "All $totalRun tests executed without a single failure!" } +else { Write-PSFMessage -Level Critical -Message "$totalFailed tests out of $totalRun tests failed!" } + +if ($totalFailed -gt 0) +{ + throw "$totalFailed / $totalRun tests failed!" +} \ No newline at end of file diff --git a/VMDeploy.Guest/tests/readme.md b/VMDeploy.Guest/tests/readme.md new file mode 100644 index 0000000..43bb2fa --- /dev/null +++ b/VMDeploy.Guest/tests/readme.md @@ -0,0 +1,31 @@ +# Description + +This is the folder, where all the tests go. + +Those are subdivided in two categories: + + - General + - Function + +## General Tests + +General tests are function generic and test for general policies. + +These test scan answer questions such as: + + - Is my module following my style guides? + - Does any of my scripts have a syntax error? + - Do my scripts use commands I do not want them to use? + - Do my commands follow best practices? + - Do my commands have proper help? + +Basically, these allow a general module health check. + +These tests are already provided as part of the template. + +## Function Tests + +A healthy module should provide unit and integration tests for the commands & components it ships. +Only then can be guaranteed, that they will actually perform as promised. + +However, as each such test must be specific to the function it tests, there cannot be much in the way of templates. \ No newline at end of file diff --git a/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml b/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml new file mode 100644 index 0000000..3b881a0 --- /dev/null +++ b/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml @@ -0,0 +1,31 @@ + + + + + + Foo.Bar + + Foo.Bar + + + + + + + + + + + + Foo + + + Bar + + + + + + + + \ No newline at end of file diff --git a/VMDeploy.Guest/xml/VMDeploy.Guest.Types.ps1xml b/VMDeploy.Guest/xml/VMDeploy.Guest.Types.ps1xml new file mode 100644 index 0000000..d911fcb --- /dev/null +++ b/VMDeploy.Guest/xml/VMDeploy.Guest.Types.ps1xml @@ -0,0 +1,37 @@ + + + + + Deserialized.Foo.Bar + + + PSStandardMembers + + + + TargetTypeForDeserialization + + + Foo.Bar + + + + + + + + Foo.Bar + + + SerializationData + + PSFramework.Serialization.SerializationTypeConverter + GetSerializationData + + + + + PSFramework.Serialization.SerializationTypeConverter + + + \ No newline at end of file diff --git a/VMDeploy.Guest/xml/readme.md b/VMDeploy.Guest/xml/readme.md new file mode 100644 index 0000000..bc88e53 --- /dev/null +++ b/VMDeploy.Guest/xml/readme.md @@ -0,0 +1,43 @@ +# XML + +This is the folder where project XML files go, notably: + + - Format XML + - Type Extension XML + +External help files should _not_ be placed in this folder! + +## Notes on Files and Naming + +There should be only one format file and one type extension file per project, as importing them has a notable impact on import times. + + - The Format XML should be named `VMDeploy.Guest.Format.ps1xml` + - The Type Extension XML should be named `VMDeploy.Guest.Types.ps1xml` + +## Tools + +### New-PSMDFormatTableDefinition + +This function will take an input object and generate format xml for an auto-sized table. + +It provides a simple way to get started with formats. + +### Get-PSFTypeSerializationData + +``` +C# Warning! +This section is only interest if you're using C# together with PowerShell. +``` + +This function generates type extension XML that allows PowerShell to convert types written in C# to be written to file and restored from it without being 'Deserialized'. Also works for jobs or remoting, if both sides have the `PSFramework` module and type extension loaded. + +In order for a class to be eligible for this, it needs to conform to the following rules: + + - Have the `[Serializable]` attribute + - Be public + - Have an empty constructor + - Allow all public properties/fields to be set (even if setting it doesn't do anything) without throwing an exception. + +``` +non-public properties and fields will be lost in this process! +``` \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..08292b5 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,20 @@ +pool: + name: Hosted VS2017 +steps: +- task: PowerShell@2 + displayName: Prerequisites + inputs: + targetType: filePath + filePath: './build/vsts-prerequisites.ps1' + +- task: PowerShell@2 + displayName: Validate + inputs: + targetType: filePath + filePath: './build/vsts-validate.ps1' + +- task: PublishTestResults@2 + displayName: 'Publish Test Results **/TEST-*.xml' + inputs: + testResultsFormat: NUnit + condition: always() diff --git a/build/AzureFunction.readme.md b/build/AzureFunction.readme.md new file mode 100644 index 0000000..434e135 --- /dev/null +++ b/build/AzureFunction.readme.md @@ -0,0 +1,35 @@ +# Setting up the release pipeline: + +## Preliminary + +Setting up a release pipeline, set the trigger to do continuous integration against the master branch only. +In Stage 1 set up a tasksequence: + +## 1) PowerShell Task: Prerequisites + +Have it execute `vsts-prerequisites.ps1` + +## 2) PowerShell Task: Validate + +Have it execute `vsts-prerequisites.ps1` + +## 3) PowerShell Task: Build + +Have it execute `vsts-build.ps1`. +The task requires two parameters: + + - `-LocalRepo` + - `-WorkingDirectory $(System.DefaultWorkingDirectory)/_�name�` + +## 4) Publish Test Results + +Configure task to pick up nunit type of tests (rather than the default junit). +Configure task to execute, even if previous steps failed or the task sequence was cancelled. + +## 5) PowerShell Task: Package Function + +Have it execute `vsts-packageFunction.ps1` + +## 6) Azure Function AppDeploy + +Configure to publish to the correct function app. \ No newline at end of file diff --git a/build/vsts-build.ps1 b/build/vsts-build.ps1 new file mode 100644 index 0000000..a776ee2 --- /dev/null +++ b/build/vsts-build.ps1 @@ -0,0 +1,120 @@ +<# +This script publishes the module to the gallery. +It expects as input an ApiKey authorized to publish the module. + +Insert any build steps you may need to take before publishing it here. +#> +param ( + $ApiKey, + + $WorkingDirectory, + + $Repository = 'PSGallery', + + [switch] + $LocalRepo, + + [switch] + $SkipPublish, + + [switch] + $AutoVersion +) + +#region Handle Working Directory Defaults +if (-not $WorkingDirectory) +{ + if ($env:RELEASE_PRIMARYARTIFACTSOURCEALIAS) + { + $WorkingDirectory = Join-Path -Path $env:SYSTEM_DEFAULTWORKINGDIRECTORY -ChildPath $env:RELEASE_PRIMARYARTIFACTSOURCEALIAS + } + else { $WorkingDirectory = $env:SYSTEM_DEFAULTWORKINGDIRECTORY } +} +if (-not $WorkingDirectory) { $WorkingDirectory = Split-Path $PSScriptRoot } +#endregion Handle Working Directory Defaults + +# Prepare publish folder +Write-PSFMessage -Level Important -Message "Creating and populating publishing directory" +$publishDir = New-Item -Path $WorkingDirectory -Name publish -ItemType Directory -Force +Copy-Item -Path "$($WorkingDirectory)\VMDeploy.Guest" -Destination $publishDir.FullName -Recurse -Force + +#region Gather text data to compile +$text = @() +$processed = @() + +# Gather Stuff to run before +foreach ($filePath in (& "$($PSScriptRoot)\..\VMDeploy.Guest\internal\scripts\preimport.ps1")) +{ + if ([string]::IsNullOrWhiteSpace($filePath)) { continue } + + $item = Get-Item $filePath + if ($item.PSIsContainer) { continue } + if ($item.FullName -in $processed) { continue } + $text += [System.IO.File]::ReadAllText($item.FullName) + $processed += $item.FullName +} + +# Gather commands +Get-ChildItem -Path "$($publishDir.FullName)\VMDeploy.Guest\internal\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) +} +Get-ChildItem -Path "$($publishDir.FullName)\VMDeploy.Guest\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) +} + +# Gather stuff to run afterwards +foreach ($filePath in (& "$($PSScriptRoot)\..\VMDeploy.Guest\internal\scripts\postimport.ps1")) +{ + if ([string]::IsNullOrWhiteSpace($filePath)) { continue } + + $item = Get-Item $filePath + if ($item.PSIsContainer) { continue } + if ($item.FullName -in $processed) { continue } + $text += [System.IO.File]::ReadAllText($item.FullName) + $processed += $item.FullName +} +#endregion Gather text data to compile + +#region Update the psm1 file +$fileData = Get-Content -Path "$($publishDir.FullName)\VMDeploy.Guest\VMDeploy.Guest.psm1" -Raw +$fileData = $fileData.Replace('""', '""') +$fileData = $fileData.Replace('""', ($text -join "`n`n")) +[System.IO.File]::WriteAllText("$($publishDir.FullName)\VMDeploy.Guest\VMDeploy.Guest.psm1", $fileData, [System.Text.Encoding]::UTF8) +#endregion Update the psm1 file + +#region Updating the Module Version +if ($AutoVersion) +{ + Write-PSFMessage -Level Important -Message "Updating module version numbers." + try { [version]$remoteVersion = (Find-Module 'VMDeploy.Guest' -Repository $Repository -ErrorAction Stop).Version } + catch + { + Stop-PSFFunction -Message "Failed to access $($Repository)" -EnableException $true -ErrorRecord $_ + } + if (-not $remoteVersion) + { + Stop-PSFFunction -Message "Couldn't find VMDeploy.Guest on repository $($Repository)" -EnableException $true + } + $newBuildNumber = $remoteVersion.Build + 1 + [version]$localVersion = (Import-PowerShellDataFile -Path "$($publishDir.FullName)\VMDeploy.Guest\VMDeploy.Guest.psd1").ModuleVersion + Update-ModuleManifest -Path "$($publishDir.FullName)\VMDeploy.Guest\VMDeploy.Guest.psd1" -ModuleVersion "$($localVersion.Major).$($localVersion.Minor).$($newBuildNumber)" +} +#endregion Updating the Module Version + +#region Publish +if ($SkipPublish) { return } +if ($LocalRepo) +{ + # Dependencies must go first + Write-PSFMessage -Level Important -Message "Creating Nuget Package for module: PSFramework" + New-PSMDModuleNugetPackage -ModulePath (Get-Module -Name PSFramework).ModuleBase -PackagePath . + Write-PSFMessage -Level Important -Message "Creating Nuget Package for module: VMDeploy.Guest" + New-PSMDModuleNugetPackage -ModulePath "$($publishDir.FullName)\VMDeploy.Guest" -PackagePath . +} +else +{ + # Publish to Gallery + Write-PSFMessage -Level Important -Message "Publishing the VMDeploy.Guest module to $($Repository)" + Publish-Module -Path "$($publishDir.FullName)\VMDeploy.Guest" -NuGetApiKey $ApiKey -Force -Repository $Repository +} +#endregion Publish \ No newline at end of file diff --git a/build/vsts-createFunctionClientModule.ps1 b/build/vsts-createFunctionClientModule.ps1 new file mode 100644 index 0000000..a7bc9ed --- /dev/null +++ b/build/vsts-createFunctionClientModule.ps1 @@ -0,0 +1,201 @@ + +<# + .SYNOPSIS + Build script that generates a client module for REST API endpoints of a Azure PowerShell Functions project. + + .DESCRIPTION + Build script that generates a client module for REST API endpoints of a Azure PowerShell Functions project. + + .PARAMETER ApiKey + The API key to use to publish the module to a Nuget Repository + + .PARAMETER WorkingDirectory + The root folder from which to build the module. + + .PARAMETER Repository + The name of the repository to publish to. + Defaults to PSGallery. + + .PARAMETER LocalRepo + Instead of publishing to a gallery, drop a nuget package in the root folder. + This package can then be picked up in a later step for publishing to Azure Artifacts. + + .PARAMETER ModuleName + The name to give to the client module. + By default, the client module will be named '.Client'. + + .PARAMETER IncludeFormat + Include the format xml of the source module for the client module. + + .PARAMETER IncludeType + Include the type extension xml of the source module for the client module. + + .PARAMETER IncludeAssembly + Include the binaries of the source module for the client module. +#> +param ( + $ApiKey, + + $WorkingDirectory, + + $Repository = 'PSGallery', + + [switch] + $LocalRepo, + + $ModuleName, + + [switch] + $IncludeFormat, + + [switch] + $IncludeType, + + [switch] + $IncludeAssembly +) + +#region Handle Working Directory Defaults +if (-not $WorkingDirectory) +{ + if ($env:RELEASE_PRIMARYARTIFACTSOURCEALIAS) + { + $WorkingDirectory = Join-Path -Path $env:SYSTEM_DEFAULTWORKINGDIRECTORY -ChildPath $env:RELEASE_PRIMARYARTIFACTSOURCEALIAS + } + else { $WorkingDirectory = $env:SYSTEM_DEFAULTWORKINGDIRECTORY } +} +#endregion Handle Working Directory Defaults + +Write-PSFMessage -Level Host -Message 'Starting Build: Client Module' +$parentModule = 'VMDeploy.Guest' +if (-not $ModuleName) { $ModuleName = 'VMDeploy.Guest.Client' } +Write-PSFMessage -Level Host -Message 'Creating Folder Structure' +$workingRoot = New-Item -Path $WorkingDirectory -Name $ModuleName -ItemType Directory +$publishRoot = Join-Path -Path $WorkingDirectory -ChildPath 'publish\VMDeploy.Guest' +Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\clientModule\functions" -Destination "$($workingRoot.FullName)\" -Recurse +Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\clientModule\internal" -Destination "$($workingRoot.FullName)\" -Recurse +Copy-Item -Path "$($publishRoot)\en-us" -Destination "$($workingRoot.FullName)\" -Recurse +$functionFolder = Get-Item -Path "$($workingRoot.FullName)\functions" + +#region Create Functions +$encoding = [PSFEncoding]'utf8' +$functionsText = Get-Content -Path "$($WorkingDirectory)\azFunctionResources\clientModule\function.ps1" -Raw + +Write-PSFMessage -Level Host -Message 'Creating Functions' +foreach ($functionSourceFile in (Get-ChildItem -Path "$($publishRoot)\functions" -Recurse -Filter '*.ps1')) +{ + Write-PSFMessage -Level Host -Message " Processing function: $($functionSourceFile.BaseName)" + $condensedName = $functionSourceFile.BaseName -replace '-', '' + + #region Load Overrides + $override = @{ } + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).psd1") + { + $override = Import-PowerShellDataFile -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).psd1" + } + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($condensedName).psd1") + { + $override = Import-PowerShellDataFile -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($condensedName).psd1" + } + if ($override.NoClientFunction) + { + Write-PSFMessage -Level Host -Message " Override 'NoClientFunction' detected, skipping!" + continue + } + + # If there is an definition override, use it and continue + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).ps1") + { + Write-PSFMessage -Level Host -Message " Override function definition detected, using override" + Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).ps1" -Destination $functionFolder.FullName + continue + } + + # Figure out the Rest Method to use + $methodName = 'Post' + if ($override.RestMethods) + { + $methodName = $override.RestMethods | Where-Object { $_ -ne 'Get' } | Select-Object -First 1 + } + + #endregion Load Overrides + + $currentFunctionsText = $functionsText -replace '%functionname%', $functionSourceFile.BaseName -replace '%condensedname%', $condensedName -replace '%method%', $methodName + + $parsedFunction = Read-PSMDScript -Path $functionSourceFile.FullName + $functionAst = $parsedFunction.Ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] + } | Select-Object -First 1 + + $end = $functionAst.Body.ParamBlock.Extent.EndOffSet + $start = $functionAst.Body.Extent.StartOffSet + 1 + $currentFunctionsText = $currentFunctionsText.Replace('%parameter%', $functionAst.Body.Extent.Text.SubString(1, ($end - $start))) + + Write-PSFMessage -Level Host -Message " Creating file: $($functionFolder.FullName)\$($functionSourceFile.Name)" + [System.IO.File]::WriteAllText("$($functionFolder.FullName)\$($functionSourceFile.Name)", $currentFunctionsText, $encoding) +} +$functionsToExport = (Get-ChildItem -Path $functionFolder.FullName -Recurse -Filter *.ps1).BaseName | Sort-Object +#endregion Create Functions + +#region Create Core Module Files +# Get Manifest of published version, in order to catch build-phase changes such as module version. +$originalManifestData = Import-PowerShellDataFile -Path "$publishRoot\VMDeploy.Guest.psd1" +$prereqHash = @{ + ModuleName = 'PSFramework' + ModuleVersion = (Get-Module PSFramework).Version +} +$paramNewModuleManifest = @{ + Path = ('{0}\{1}.psd1' -f $workingRoot.FullName, $ModuleName) + FunctionsToExport = $functionsToExport + CompanyName = $originalManifestData.CompanyName + Author = $originalManifestData.Author + Description = $originalManifestData.Description + ModuleVersion = $originalManifestData.ModuleVersion + RootModule = ('{0}.psm1' -f $ModuleName) + Copyright = $originalManifestData.Copyright + TypesToProcess = @() + FormatsToProcess = @() + RequiredAssemblies = @() + RequiredModules = @($prereqHash) + CompatiblePSEditions = 'Core', 'Desktop' + PowerShellVersion = '5.1' +} + +if ($IncludeAssembly) { $paramNewModuleManifest.RequiredAssemblies = $originalManifestData.RequiredAssemblies } +if ($IncludeFormat) { $paramNewModuleManifest.FormatsToProcess = $originalManifestData.FormatsToProcess } +if ($IncludeType) { $paramNewModuleManifest.TypesToProcess = $originalManifestData.TypesToProcess } +Write-PSFMessage -Level Host -Message "Creating Module Manifest for module: $ModuleName" +New-ModuleManifest @paramNewModuleManifest + +Write-PSFMessage -Level Host -Message "Copying additional module files" +Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\clientModule\moduleroot.psm1" -Destination "$($workingRoot.FullName)\$($ModuleName).psm1" +Copy-Item -Path "$($WorkingDirectory)\LICENSE" -Destination "$($workingRoot.FullName)\" +#endregion Create Core Module Files + +#region Transfer Additional Content +if ($IncludeAssembly) +{ + Copy-Item -Path "$publishRoot\bin" -Destination "$($workingRoot.FullName)\" -Recurse +} +if ($IncludeFormat -or $IncludeType) +{ + Copy-Item -Path "$publishRoot\xml" -Destination "$($workingRoot.FullName)\" -Recurse +} +#endregion Transfer Additional Content + +#region Publish +if ($LocalRepo) +{ + # Dependencies must go first + Write-PSFMessage -Level Important -Message "Creating Nuget Package for module: PSFramework" + New-PSMDModuleNugetPackage -ModulePath (Get-Module -Name PSFramework).ModuleBase -PackagePath . -WarningAction SilentlyContinue + Write-PSFMessage -Level Important -Message "Creating Nuget Package for module: VMDeploy.Guest" + New-PSMDModuleNugetPackage -ModulePath $workingRoot.FullName -PackagePath . -EnableException +} +else +{ + # Publish to Gallery + Write-PSFMessage -Level Important -Message "Publishing the VMDeploy.Guest module to $($Repository)" + Publish-Module -Path $workingRoot.FullName -NuGetApiKey $ApiKey -Force -Repository $Repository +} +#endregion Publish \ No newline at end of file diff --git a/build/vsts-packageFunction.ps1 b/build/vsts-packageFunction.ps1 new file mode 100644 index 0000000..31b2dfd --- /dev/null +++ b/build/vsts-packageFunction.ps1 @@ -0,0 +1,143 @@ + +<# + .SYNOPSIS + Packages an Azure Functions project, ready to release. + + .DESCRIPTION + Packages an Azure Functions project, ready to release. + Should be part of the release pipeline, after ensuring validation. + + Look into the 'AzureFunctionRest' template for generating functions for the module if you do. + + .PARAMETER WorkingDirectory + The root folder to work from. + + .PARAMETER Repository + The name of the repository to use for gathering dependencies from. +#> +param ( + $WorkingDirectory = "$($env:SYSTEM_DEFAULTWORKINGDIRECTORY)\_VMDeploy.Guest", + + $Repository = 'PSGallery', + + [switch] + $IncludeAZ +) + +$moduleName = 'VMDeploy.Guest' + +# Prepare Paths +Write-PSFMessage -Level Host -Message "Creating working folders" +$moduleRoot = Join-Path -Path $WorkingDirectory -ChildPath 'publish' +$workingRoot = New-Item -Path $WorkingDirectory -Name 'working' -ItemType Directory +$modulesFolder = New-Item -Path $workingRoot.FullName -Name Modules -ItemType Directory + +# Fill out the modules folder +Write-PSFMessage -Level Host -Message "Transfering built module data into working directory" +Copy-Item -Path "$moduleRoot\$moduleName" -Destination $modulesFolder.FullName -Recurse -Force +foreach ($dependency in (Import-PowerShellDataFile -Path "$moduleRoot\$moduleName\$moduleName.psd1").RequiredModules) +{ + $param = @{ + Repository = $Repository + Name = $dependency.ModuleName + Path = $modulesFolder.FullName + } + if ($dependency -is [string]) { $param['Name'] = $dependency } + if ($dependency.RequiredVersion) + { + $param['RequiredVersion'] = $dependency.RequiredVersion + } + Write-PSFMessage -Level Host -Message "Preparing Dependency: $($param['Name'])" + Save-Module @param +} + +# Generate function configuration +Write-PSFMessage -Level Host -Message 'Generating function configuration' +$runTemplate = Get-Content -Path "$($WorkingDirectory)\azFunctionResources\run.ps1" -Raw +foreach ($functionSourceFile in (Get-ChildItem -Path "$($moduleRoot)\$moduleName\functions" -Recurse -Filter '*.ps1')) +{ + Write-PSFMessage -Level Host -Message " Processing function: $functionSourceFile" + $condensedName = $functionSourceFile.BaseName -replace '-', '' + $functionFolder = New-Item -Path $workingRoot.FullName -Name $condensedName -ItemType Directory + + #region Load Overrides + $override = @{ } + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).psd1") + { + $override = Import-PowerShellDataFile -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).psd1" + } + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($condensedName).psd1") + { + $override = Import-PowerShellDataFile -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($condensedName).psd1" + } + #endregion Load Overrides + + #region Create Function Configuration + $restMethods = 'get', 'post' + if ($override.RestMethods) { $restMethods = $override.RestMethods } + + Set-Content -Path "$($functionFolder.FullName)\function.json" -Value @" +{ + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "Request", + "methods": [ + "$($restMethods -join "`", + `"")" + ] + }, + { + "type": "http", + "direction": "out", + "name": "Response" + } + ], + "disabled": false +} +"@ + #endregion Create Function Configuration + + #region Override Function Configuration + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).json") + { + Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($functionSourceFile.BaseName).json" -Destination "$($functionFolder.FullName)\function.json" -Force + } + if (Test-Path -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($condensedName).json") + { + Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\functionOverride\$($condensedName).json" -Destination "$($functionFolder.FullName)\function.json" -Force + } + #endregion Override Function Configuration + + # Generate the run.ps1 file + $runText = $runTemplate -replace '%functionname%', $functionSourceFile.BaseName + $runText | Set-Content -Path "$($functionFolder.FullName)\run.ps1" -Encoding UTF8 +} + +# Transfer common files +Write-PSFMessage -Level Host -Message "Transfering core function data" +if ($IncludeAZ) +{ + Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\host-az.json" -Destination "$($workingroot.FullName)\host.json" + Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\requirements.psd1" -Destination "$($workingroot.FullName)\" +} +else +{ + Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\host.json" -Destination "$($workingroot.FullName)\" +} +Copy-Item -Path "$($WorkingDirectory)\azFunctionResources\local.settings.json" -Destination "$($workingroot.FullName)\" + +# Build the profile file +$text = @() +$text += Get-Content -Path "$($WorkingDirectory)\azFunctionResources\profile.ps1" -Raw +foreach ($functionFile in (Get-ChildItem "$($WorkingDirectory)\azFunctionResources\profileFunctions" -Recurse)) +{ + $text += Get-Content -Path $functionFile.FullName -Raw +} +$text -join "`n`n" | Set-Content "$($workingroot.FullName)\profile.ps1" + +# Zip It +Write-PSFMessage -Level Host -Message "Creating function archive in '$($WorkingDirectory)\$moduleName.zip'" +Compress-Archive -Path "$($workingroot.FullName)\*" -DestinationPath "$($WorkingDirectory)\$moduleName.zip" -Force \ No newline at end of file diff --git a/build/vsts-prerequisites.ps1 b/build/vsts-prerequisites.ps1 new file mode 100644 index 0000000..26cc5b4 --- /dev/null +++ b/build/vsts-prerequisites.ps1 @@ -0,0 +1,25 @@ +param ( + [string] + $Repository = 'PSGallery' +) + +$modules = @("Pester", "PSFramework", "PSModuleDevelopment", "PSScriptAnalyzer") + +# Automatically add missing dependencies +$data = Import-PowerShellDataFile -Path "$PSScriptRoot\..\VMDeploy.Guest\VMDeploy.Guest.psd1" +foreach ($dependency in $data.RequiredModules) { + if ($dependency -is [string]) { + if ($modules -contains $dependency) { continue } + $modules += $dependency + } + else { + if ($modules -contains $dependency.ModuleName) { continue } + $modules += $dependency.ModuleName + } +} + +foreach ($module in $modules) { + Write-Host "Installing $module" -ForegroundColor Cyan + Install-Module $module -Force -SkipPublisherCheck -Repository $Repository + Import-Module $module -Force -PassThru +} \ No newline at end of file diff --git a/build/vsts-validate.ps1 b/build/vsts-validate.ps1 new file mode 100644 index 0000000..c6f936e --- /dev/null +++ b/build/vsts-validate.ps1 @@ -0,0 +1,7 @@ +# Guide for available variables and working with secrets: +# https://docs.microsoft.com/en-us/vsts/build-release/concepts/definitions/build/variables?tabs=powershell + +# Needs to ensure things are Done Right and only legal commits to master get built + +# Run internal pester tests +& "$PSScriptRoot\..\VMDeploy.Guest\tests\pester.ps1" \ No newline at end of file diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..58c715c --- /dev/null +++ b/install.ps1 @@ -0,0 +1,2431 @@ +<# + .SYNOPSIS + Installs the VMDeploy.Guest Module from github + + .DESCRIPTION + This script installs the VMDeploy.Guest Module from github. + + It does so by ... + - downloading the specified branch as zip to $env:TEMP + - Unpacking that zip file to a folder in $env:TEMP + - Moving that content to a module folder in either program files (default) or the user profile + + .PARAMETER Branch + The branch to install. Installs master by default. + Unknown branches will terminate the script in error. + + .PARAMETER UserMode + The downloaded module will be moved to the user profile, rather than program files. + + .PARAMETER Scope + By default, the downloaded module will be moved to program files. + Setting this to 'CurrentUser' installs to the userprofile of the current user. + + .PARAMETER Force + The install script will overwrite an existing module. +#> +[CmdletBinding()] +Param ( + [string] + $Branch = "master", + + [switch] + $UserMode, + + [ValidateSet('AllUsers', 'CurrentUser')] + [string] + $Scope = "AllUsers", + + [switch] + $Force +) + +#region Configuration for cloning script +# Name of the module that is being cloned +$ModuleName = "VMDeploy.Guest" + +# Base path to the github repository +$BaseUrl = "https://github.com//VMDeploy.Guest" + +# If the module is in a subfolder of the cloned repository, specify relative path here. Empty string to skip. +$SubFolder = "VMDeploy.Guest" +#endregion Configuration for cloning script + +#region Parameter Calculation +$doUserMode = $false +if ($UserMode) { $doUserMode = $true } +if ($install_CurrentUser) { $doUserMode = $true } +if ($Scope -eq 'CurrentUser') { $doUserMode = $true } + +if ($install_Branch) { $Branch = $install_Branch } +#endregion Parameter Calculation + +#region Utility Functions +function Compress-Archive +{ + <# + .SYNOPSIS + Creates an archive, or zipped file, from specified files and folders. + + .DESCRIPTION + The Compress-Archive cmdlet creates a zipped (or compressed) archive file from one or more specified files or folders. An archive file allows multiple files to be packaged, and optionally compressed, into a single zipped file for easier distribution and storage. An archive file can be compressed by using the compression algorithm specified by the CompressionLevel parameter. + + Because Compress-Archive relies upon the Microsoft .NET Framework API System.IO.Compression.ZipArchive to compress files, the maximum file size that you can compress by using Compress-Archive is currently 2 GB. This is a limitation of the underlying API. + + .PARAMETER Path + Specifies the path or paths to the files that you want to add to the archive zipped file. This parameter can accept wildcard characters. Wildcard characters allow you to add all files in a folder to your zipped archive file. To specify multiple paths, and include files in multiple locations in your output zipped file, use commas to separate the paths. + + .PARAMETER LiteralPath + Specifies the path or paths to the files that you want to add to the archive zipped file. Unlike the Path parameter, the value of LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose each escape character in single quotation marks, to instruct Windows PowerShell not to interpret any characters as escape sequences. To specify multiple paths, and include files in multiple locations in your output zipped file, use commas to separate the paths. + + .PARAMETER DestinationPath + Specifies the path to the archive output file. This parameter is required. The specified DestinationPath value should include the desired name of the output zipped file; it specifies either the absolute or relative path to the zipped file. If the file name specified in DestinationPath does not have a .zip file name extension, the cmdlet adds a .zip file name extension. + + .PARAMETER CompressionLevel + Specifies how much compression to apply when you are creating the archive file. Faster compression requires less time to create the file, but can result in larger file sizes. The acceptable values for this parameter are: + + - Fastest. Use the fastest compression method available to decrease processing time; this can result in larger file sizes. + - NoCompression. Do not compress the source files. + - Optimal. Processing time is dependent on file size. + + If this parameter is not specified, the command uses the default value, Optimal. + + .PARAMETER Update + Updates the specified archive by replacing older versions of files in the archive with newer versions of files that have the same names. You can also add this parameter to add files to an existing archive. + + .PARAMETER Force + @{Text=} + + .PARAMETER Confirm + Prompts you for confirmation before running the cmdlet. + + .PARAMETER WhatIf + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + .EXAMPLE + Example 1: Create an archive file + + PS C:\>Compress-Archive -LiteralPath C:\Reference\Draftdoc.docx, C:\Reference\Images\diagram2.vsd -CompressionLevel Optimal -DestinationPath C:\Archives\Draft.Zip + + This command creates a new archive file, Draft.zip, by compressing two files, Draftdoc.docx and diagram2.vsd, specified by the LiteralPath parameter. The compression level specified for this operation is Optimal. + + .EXAMPLE + Example 2: Create an archive with wildcard characters + + PS C:\>Compress-Archive -Path C:\Reference\* -CompressionLevel Fastest -DestinationPath C:\Archives\Draft + + This command creates a new archive file, Draft.zip, in the C:\Archives folder. Note that though the file name extension .zip was not added to the value of the DestinationPath parameter, Windows PowerShell appends this to the specified archive file name automatically. The new archive file contains every file in the C:\Reference folder, because a wildcard character was used in place of specific file names in the Path parameter. The specified compression level is Fastest, which might result in a larger output file, but compresses a large number of files faster. + + .EXAMPLE + Example 3: Update an existing archive file + + PS C:\>Compress-Archive -Path C:\Reference\* -Update -DestinationPath C:\Archives\Draft.Zip + + This command updates an existing archive file, Draft.Zip, in the C:\Archives folder. The command is run to update Draft.Zip with newer versions of existing files that came from the C:\Reference folder, and also to add new files that have been added to C:\Reference since Draft.Zip was initially created. + + .EXAMPLE + Example 4: Create an archive from an entire folder + + PS C:\>Compress-Archive -Path C:\Reference -DestinationPath C:\Archives\Draft + + This command creates an archive from an entire folder, C:\Reference. Note that though the file name extension .zip was not added to the value of the DestinationPath parameter, Windows PowerShell appends this to the specified archive file name automatically. + #> + [CmdletBinding(DefaultParameterSetName = "Path", SupportsShouldProcess = $true, HelpUri = "http://go.microsoft.com/fwlink/?LinkID=393252")] + param + ( + [parameter (mandatory = $true, Position = 0, ParameterSetName = "Path", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [parameter (mandatory = $true, Position = 0, ParameterSetName = "PathWithForce", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [parameter (mandatory = $true, Position = 0, ParameterSetName = "PathWithUpdate", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [ValidateNotNullOrEmpty()] + [string[]] + $Path, + + [parameter (mandatory = $true, ParameterSetName = "LiteralPath", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $true)] + [parameter (mandatory = $true, ParameterSetName = "LiteralPathWithForce", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $true)] + [parameter (mandatory = $true, ParameterSetName = "LiteralPathWithUpdate", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $true)] + [ValidateNotNullOrEmpty()] + [Alias("PSPath")] + [string[]] + $LiteralPath, + + [parameter (mandatory = $true, + Position = 1, + ValueFromPipeline = $false, + ValueFromPipelineByPropertyName = $false)] + [ValidateNotNullOrEmpty()] + [string] + $DestinationPath, + + [parameter ( + mandatory = $false, + ValueFromPipeline = $false, + ValueFromPipelineByPropertyName = $false)] + [ValidateSet("Optimal", "NoCompression", "Fastest")] + [string] + $CompressionLevel = "Optimal", + + [parameter(mandatory = $true, ParameterSetName = "PathWithUpdate", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] + [parameter(mandatory = $true, ParameterSetName = "LiteralPathWithUpdate", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] + [switch] + $Update = $false, + + [parameter(mandatory = $true, ParameterSetName = "PathWithForce", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] + [parameter(mandatory = $true, ParameterSetName = "LiteralPathWithForce", ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] + [switch] + $Force = $false + ) + + BEGIN + { + Add-Type -AssemblyName System.IO.Compression -ErrorAction Ignore + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Ignore + + $zipFileExtension = ".zip" + + $LocalizedData = ConvertFrom-StringData @' +PathNotFoundError=The path '{0}' either does not exist or is not a valid file system path. +ExpandArchiveInValidDestinationPath=The path '{0}' is not a valid file system directory path. +InvalidZipFileExtensionError={0} is not a supported archive file format. {1} is the only supported archive file format. +ArchiveFileIsReadOnly=The attributes of the archive file {0} is set to 'ReadOnly' hence it cannot be updated. If you intend to update the existing archive file, remove the 'ReadOnly' attribute on the archive file else use -Force parameter to override and create a new archive file. +ZipFileExistError=The archive file {0} already exists. Use the -Update parameter to update the existing archive file or use the -Force parameter to overwrite the existing archive file. +DuplicatePathFoundError=The input to {0} parameter contains a duplicate path '{1}'. Provide a unique set of paths as input to {2} parameter. +ArchiveFileIsEmpty=The archive file {0} is empty. +CompressProgressBarText=The archive file '{0}' creation is in progress... +ExpandProgressBarText=The archive file '{0}' expansion is in progress... +AppendArchiveFileExtensionMessage=The archive file path '{0}' supplied to the DestinationPath patameter does not include .zip extension. Hence .zip is appended to the supplied DestinationPath path and the archive file would be created at '{1}'. +AddItemtoArchiveFile=Adding '{0}'. +CreateFileAtExpandedPath=Created '{0}'. +InvalidArchiveFilePathError=The archive file path '{0}' specified as input to the {1} parameter is resolving to multiple file system paths. Provide a unique path to the {2} parameter where the archive file has to be created. +InvalidExpandedDirPathError=The directory path '{0}' specified as input to the DestinationPath parameter is resolving to multiple file system paths. Provide a unique path to the Destination parameter where the archive file contents have to be expanded. +FileExistsError=Failed to create file '{0}' while expanding the archive file '{1}' contents as the file '{2}' already exists. Use the -Force parameter if you want to overwrite the existing directory '{3}' contents when expanding the archive file. +DeleteArchiveFile=The partially created archive file '{0}' is deleted as it is not usable. +InvalidDestinationPath=The destination path '{0}' does not contain a valid archive file name. +PreparingToCompressVerboseMessage=Preparing to compress... +PreparingToExpandVerboseMessage=Preparing to expand... +'@ + + #region Utility Functions + function GetResolvedPathHelper + { + param + ( + [string[]] + $path, + + [boolean] + $isLiteralPath, + + [System.Management.Automation.PSCmdlet] + $callerPSCmdlet + ) + + $resolvedPaths = @() + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach ($currentPath in $path) + { + try + { + if ($isLiteralPath) + { + $currentResolvedPaths = Resolve-Path -LiteralPath $currentPath -ErrorAction Stop + } + else + { + $currentResolvedPaths = Resolve-Path -Path $currentPath -ErrorAction Stop + } + } + catch + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $currentPath) + $exception = New-Object System.InvalidOperationException $errorMessage, $_.Exception + $errorRecord = CreateErrorRecordHelper "ArchiveCmdletPathNotFound" $null ([System.Management.Automation.ErrorCategory]::InvalidArgument) $exception $currentPath + $callerPSCmdlet.ThrowTerminatingError($errorRecord) + } + + foreach ($currentResolvedPath in $currentResolvedPaths) + { + $resolvedPaths += $currentResolvedPath.ProviderPath + } + } + + $resolvedPaths + } + + function Add-CompressionAssemblies + { + + if ($PSEdition -eq "Desktop") + { + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + } + } + + function IsValidFileSystemPath + { + param + ( + [string[]] + $path + ) + + $result = $true; + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach ($currentPath in $path) + { + if (!([System.IO.File]::Exists($currentPath) -or [System.IO.Directory]::Exists($currentPath))) + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $currentPath) + ThrowTerminatingErrorHelper "PathNotFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $currentPath + } + } + + return $result; + } + + + function ValidateDuplicateFileSystemPath + { + param + ( + [string] + $inputParameter, + + [string[]] + $path + ) + + $uniqueInputPaths = @() + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach ($currentPath in $path) + { + $currentInputPath = $currentPath.ToUpper() + if ($uniqueInputPaths.Contains($currentInputPath)) + { + $errorMessage = ($LocalizedData.DuplicatePathFoundError -f $inputParameter, $currentPath, $inputParameter) + ThrowTerminatingErrorHelper "DuplicatePathFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $currentPath + } + else + { + $uniqueInputPaths += $currentInputPath + } + } + } + + function CompressionLevelMapper + { + param + ( + [string] + $compressionLevel + ) + + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::Optimal + + # CompressionLevel format is already validated at the cmdlet layer. + switch ($compressionLevel.ToString()) + { + "Fastest" + { + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::Fastest + } + "NoCompression" + { + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::NoCompression + } + } + + return $compressionLevelFormat + } + + function CompressArchiveHelper + { + param + ( + [string[]] + $sourcePath, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $isUpdateMode + ) + + $numberOfItemsArchived = 0 + $sourceFilePaths = @() + $sourceDirPaths = @() + + foreach ($currentPath in $sourcePath) + { + $result = Test-Path -LiteralPath $currentPath -PathType Leaf + if ($result -eq $true) + { + $sourceFilePaths += $currentPath + } + else + { + $sourceDirPaths += $currentPath + } + } + + # The Soure Path contains one or more directory (this directory can have files under it) and no files to be compressed. + if ($sourceFilePaths.Count -eq 0 -and $sourceDirPaths.Count -gt 0) + { + $currentSegmentWeight = 100/[double]$sourceDirPaths.Count + $previousSegmentWeight = 0 + foreach ($currentSourceDirPath in $sourceDirPaths) + { + $count = CompressSingleDirHelper $currentSourceDirPath $destinationPath $compressionLevel $true $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + $previousSegmentWeight += $currentSegmentWeight + } + } + + # The Soure Path contains only files to be compressed. + elseIf ($sourceFilePaths.Count -gt 0 -and $sourceDirPaths.Count -eq 0) + { + # $previousSegmentWeight is equal to 0 as there are no prior segments. + # $currentSegmentWeight is set to 100 as all files have equal weightage. + $previousSegmentWeight = 0 + $currentSegmentWeight = 100 + + $numberOfItemsArchived = CompressFilesHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $previousSegmentWeight $currentSegmentWeight + } + # The Soure Path contains one or more files and one or more directories (this directory can have files under it) to be compressed. + elseif ($sourceFilePaths.Count -gt 0 -and $sourceDirPaths.Count -gt 0) + { + # each directory is considered as an individual segments & all the individual files are clubed in to a separate sgemnet. + $currentSegmentWeight = 100/[double]($sourceDirPaths.Count + 1) + $previousSegmentWeight = 0 + + foreach ($currentSourceDirPath in $sourceDirPaths) + { + $count = CompressSingleDirHelper $currentSourceDirPath $destinationPath $compressionLevel $true $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + $previousSegmentWeight += $currentSegmentWeight + } + + $count = CompressFilesHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + } + + return $numberOfItemsArchived + } + + function CompressFilesHelper + { + param + ( + [string[]] + $sourceFilePaths, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $isUpdateMode, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight + ) + + $numberOfItemsArchived = ZipArchiveHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $null $previousSegmentWeight $currentSegmentWeight + + return $numberOfItemsArchived + } + + function CompressSingleDirHelper + { + param + ( + [string] + $sourceDirPath, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $useParentDirAsRoot, + + [bool] + $isUpdateMode, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight + ) + + [System.Collections.Generic.List[System.String]]$subDirFiles = @() + + if ($useParentDirAsRoot) + { + $sourceDirInfo = New-Object -TypeName System.IO.DirectoryInfo -ArgumentList $sourceDirPath + $sourceDirFullName = $sourceDirInfo.Parent.FullName + + # If the directory is present at the drive level the DirectoryInfo.Parent include '\' example: C:\ + # On the other hand if the directory exists at a deper level then DirectoryInfo.Parent + # has just the path (without an ending '\'). example C:\source + if ($sourceDirFullName.Length -eq 3) + { + $modifiedSourceDirFullName = $sourceDirFullName + } + else + { + $modifiedSourceDirFullName = $sourceDirFullName + "\" + } + } + else + { + $sourceDirFullName = $sourceDirPath + $modifiedSourceDirFullName = $sourceDirFullName + "\" + } + + $dirContents = Get-ChildItem -LiteralPath $sourceDirPath -Recurse + foreach ($currentContent in $dirContents) + { + $isContainer = $currentContent -is [System.IO.DirectoryInfo] + if (!$isContainer) + { + $subDirFiles.Add($currentContent.FullName) + } + else + { + # The currentContent points to a directory. + # We need to check if the directory is an empty directory, if so such a + # directory has to be explictly added to the archive file. + # if there are no files in the directory the GetFiles() API returns an empty array. + $files = $currentContent.GetFiles() + if ($files.Count -eq 0) + { + $subDirFiles.Add($currentContent.FullName + "\") + } + } + } + + $numberOfItemsArchived = ZipArchiveHelper $subDirFiles.ToArray() $destinationPath $compressionLevel $isUpdateMode $modifiedSourceDirFullName $previousSegmentWeight $currentSegmentWeight + + return $numberOfItemsArchived + } + + function ZipArchiveHelper + { + param + ( + [System.Collections.Generic.List[System.String]] + $sourcePaths, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $isUpdateMode, + + [string] + $modifiedSourceDirFullName, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight + ) + + $numberOfItemsArchived = 0 + $fileMode = [System.IO.FileMode]::Create + $result = Test-Path -LiteralPath $DestinationPath -PathType Leaf + if ($result -eq $true) + { + $fileMode = [System.IO.FileMode]::Open + } + + Add-CompressionAssemblies + + try + { + # At this point we are sure that the archive file has write access. + $archiveFileStreamArgs = @($destinationPath, $fileMode) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Update, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + $currentEntryCount = 0 + $progressBarStatus = ($LocalizedData.CompressProgressBarText -f $destinationPath) + $bufferSize = 4kb + $buffer = New-Object Byte[] $bufferSize + + foreach ($currentFilePath in $sourcePaths) + { + if ($modifiedSourceDirFullName -ne $null -and $modifiedSourceDirFullName.Length -gt 0) + { + $index = $currentFilePath.IndexOf($modifiedSourceDirFullName, [System.StringComparison]::OrdinalIgnoreCase) + $currentFilePathSubString = $currentFilePath.Substring($index, $modifiedSourceDirFullName.Length) + $relativeFilePath = $currentFilePath.Replace($currentFilePathSubString, "").Trim() + } + else + { + $relativeFilePath = [System.IO.Path]::GetFileName($currentFilePath) + } + + # Update mode is selected. + # Check to see if archive file already contains one or more zip files in it. + if ($isUpdateMode -eq $true -and $zipArchive.Entries.Count -gt 0) + { + $entryToBeUpdated = $null + + # Check if the file already exists in the archive file. + # If so replace it with new file from the input source. + # If the file does not exist in the archive file then default to + # create mode and create the entry in the archive file. + + foreach ($currentArchiveEntry in $zipArchive.Entries) + { + if ($currentArchiveEntry.FullName -eq $relativeFilePath) + { + $entryToBeUpdated = $currentArchiveEntry + break + } + } + + if ($entryToBeUpdated -ne $null) + { + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + $entryToBeUpdated.Delete() + } + } + + $compression = CompressionLevelMapper $compressionLevel + + # If a directory needs to be added to an archive file, + # by convention the .Net API's expect the path of the diretcory + # to end with '\' to detect the path as an directory. + if (!$relativeFilePath.EndsWith("\", [StringComparison]::OrdinalIgnoreCase)) + { + try + { + try + { + $currentFileStream = [System.IO.File]::Open($currentFilePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) + } + catch + { + # Failed to access the file. Write a non terminating error to the pipeline + # and move on with the remaining files. + $exception = $_.Exception + if ($null -ne $_.Exception -and + $null -ne $_.Exception.InnerException) + { + $exception = $_.Exception.InnerException + } + $errorRecord = CreateErrorRecordHelper "CompressArchiveUnauthorizedAccessError" $null ([System.Management.Automation.ErrorCategory]::PermissionDenied) $exception $currentFilePath + Write-Error -ErrorRecord $errorRecord + } + + if ($null -ne $currentFileStream) + { + $srcStream = New-Object System.IO.BinaryReader $currentFileStream + + $currentArchiveEntry = $zipArchive.CreateEntry($relativeFilePath, $compression) + + # Updating the File Creation time so that the same timestamp would be retained after expanding the compressed file. + # At this point we are sure that Get-ChildItem would succeed. + $currentArchiveEntry.LastWriteTime = (Get-Item -LiteralPath $currentFilePath).LastWriteTime + + $destStream = New-Object System.IO.BinaryWriter $currentArchiveEntry.Open() + + while ($numberOfBytesRead = $srcStream.Read($buffer, 0, $bufferSize)) + { + $destStream.Write($buffer, 0, $numberOfBytesRead) + $destStream.Flush() + } + + $numberOfItemsArchived += 1 + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + } + } + finally + { + If ($null -ne $currentFileStream) + { + $currentFileStream.Dispose() + } + If ($null -ne $srcStream) + { + $srcStream.Dispose() + } + If ($null -ne $destStream) + { + $destStream.Dispose() + } + } + } + else + { + $currentArchiveEntry = $zipArchive.CreateEntry("$relativeFilePath", $compression) + $numberOfItemsArchived += 1 + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + } + + if ($null -ne $addItemtoArchiveFileMessage) + { + Write-Verbose $addItemtoArchiveFileMessage + } + + $currentEntryCount += 1 + ProgressBarHelper "Compress-Archive" $progressBarStatus $previousSegmentWeight $currentSegmentWeight $sourcePaths.Count $currentEntryCount + } + } + finally + { + If ($null -ne $zipArchive) + { + $zipArchive.Dispose() + } + + If ($null -ne $archiveFileStream) + { + $archiveFileStream.Dispose() + } + + # Complete writing progress. + Write-Progress -Activity "Compress-Archive" -Completed + } + + return $numberOfItemsArchived + } + +<############################################################################################ +# ValidateArchivePathHelper: This is a helper function used to validate the archive file +# path & its file format. The only supported archive file format is .zip +############################################################################################> + function ValidateArchivePathHelper + { + param + ( + [string] + $archiveFile + ) + + if ([System.IO.File]::Exists($archiveFile)) + { + $extension = [system.IO.Path]::GetExtension($archiveFile) + + # Invalid file extension is specifed for the zip file. + if ($extension -ne $zipFileExtension) + { + $errorMessage = ($LocalizedData.InvalidZipFileExtensionError -f $extension, $zipFileExtension) + ThrowTerminatingErrorHelper "NotSupportedArchiveFileExtension" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $extension + } + } + else + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $archiveFile) + ThrowTerminatingErrorHelper "PathNotFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $archiveFile + } + } + +<############################################################################################ +# ExpandArchiveHelper: This is a helper function used to expand the archive file contents +# to the specified directory. +############################################################################################> + function ExpandArchiveHelper + { + param + ( + [string] + $archiveFile, + + [string] + $expandedDir, + + [ref] + $expandedItems, + + [boolean] + $force, + + [boolean] + $isVerbose, + + [boolean] + $isConfirm + ) + + Add-CompressionAssemblies + + try + { + # The existance of archive file has already been validated by ValidateArchivePathHelper + # before calling this helper function. + $archiveFileStreamArgs = @($archiveFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Read, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + if ($zipArchive.Entries.Count -eq 0) + { + $archiveFileIsEmpty = ($LocalizedData.ArchiveFileIsEmpty -f $archiveFile) + Write-Verbose $archiveFileIsEmpty + return + } + + $currentEntryCount = 0 + $progressBarStatus = ($LocalizedData.ExpandProgressBarText -f $archiveFile) + + # The archive entries can either be empty directories or files. + foreach ($currentArchiveEntry in $zipArchive.Entries) + { + $currentArchiveEntryPath = Join-Path -Path $expandedDir -ChildPath $currentArchiveEntry.FullName + $extension = [system.IO.Path]::GetExtension($currentArchiveEntryPath) + + # The current archive entry is an empty directory + # The FullName of the Archive Entry representing a directory would end with a trailing '\'. + if ($extension -eq [string]::Empty -and + $currentArchiveEntryPath.EndsWith("\", [StringComparison]::OrdinalIgnoreCase)) + { + $pathExists = Test-Path -LiteralPath $currentArchiveEntryPath + + # The current archive entry expects an empty directory. + # Check if the existing directory is empty. If its not empty + # then it means that user has added this directory by other means. + if ($pathExists -eq $false) + { + New-Item $currentArchiveEntryPath -ItemType Directory -Confirm:$isConfirm | Out-Null + + if (Test-Path -LiteralPath $currentArchiveEntryPath -PathType Container) + { + $addEmptyDirectorytoExpandedPathMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentArchiveEntryPath) + Write-Verbose $addEmptyDirectorytoExpandedPathMessage + + $expandedItems.Value += $currentArchiveEntryPath + } + } + } + else + { + try + { + $currentArchiveEntryFileInfo = New-Object -TypeName System.IO.FileInfo -ArgumentList $currentArchiveEntryPath + $parentDirExists = Test-Path -LiteralPath $currentArchiveEntryFileInfo.DirectoryName -PathType Container + + # If the Parent directory of the current entry in the archive file does not exist, then create it. + if ($parentDirExists -eq $false) + { + New-Item $currentArchiveEntryFileInfo.DirectoryName -ItemType Directory -Confirm:$isConfirm | Out-Null + + if (!(Test-Path -LiteralPath $currentArchiveEntryFileInfo.DirectoryName -PathType Container)) + { + # The directory referred by $currentArchiveEntryFileInfo.DirectoryName was not successfully created. + # This could be because the user has specified -Confirm paramter when Expand-Archive was invoked + # and authorization was not provided when confirmation was prompted. In such a scenario, + # we skip the current file in the archive and continue with the remaining archive file contents. + Continue + } + + $expandedItems.Value += $currentArchiveEntryFileInfo.DirectoryName + } + + $hasNonTerminatingError = $false + + # Check if the file in to which the current archive entry contents + # would be expanded already exists. + if ($currentArchiveEntryFileInfo.Exists) + { + if ($force) + { + Remove-Item -LiteralPath $currentArchiveEntryFileInfo.FullName -Force -ErrorVariable ev -Verbose:$isVerbose -Confirm:$isConfirm + if ($ev -ne $null) + { + $hasNonTerminatingError = $true + } + + if (Test-Path -LiteralPath $currentArchiveEntryFileInfo.FullName -PathType Leaf) + { + # The file referred by $currentArchiveEntryFileInfo.FullName was not successfully removed. + # This could be because the user has specified -Confirm paramter when Expand-Archive was invoked + # and authorization was not provided when confirmation was prompted. In such a scenario, + # we skip the current file in the archive and continue with the remaining archive file contents. + Continue + } + } + else + { + # Write non-terminating error to the pipeline. + $errorMessage = ($LocalizedData.FileExistsError -f $currentArchiveEntryFileInfo.FullName, $archiveFile, $currentArchiveEntryFileInfo.FullName, $currentArchiveEntryFileInfo.FullName) + $errorRecord = CreateErrorRecordHelper "ExpandArchiveFileExists" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidOperation) $null $currentArchiveEntryFileInfo.FullName + Write-Error -ErrorRecord $errorRecord + $hasNonTerminatingError = $true + } + } + + if (!$hasNonTerminatingError) + { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($currentArchiveEntry, $currentArchiveEntryPath, $false) + + # Add the expanded file path to the $expandedItems array, + # to keep track of all the expanded files created while expanding the archive file. + # If user enters CTRL + C then at that point of time, all these expanded files + # would be deleted as part of the clean up process. + $expandedItems.Value += $currentArchiveEntryPath + + $addFiletoExpandedPathMessage = ($LocalizedData.CreateFileAtExpandedPath -f $currentArchiveEntryPath) + Write-Verbose $addFiletoExpandedPathMessage + } + } + finally + { + If ($null -ne $destStream) + { + $destStream.Dispose() + } + + If ($null -ne $srcStream) + { + $srcStream.Dispose() + } + } + } + + $currentEntryCount += 1 + # $currentSegmentWeight is Set to 100 giving equal weightage to each file that is getting expanded. + # $previousSegmentWeight is set to 0 as there are no prior segments. + $previousSegmentWeight = 0 + $currentSegmentWeight = 100 + ProgressBarHelper "Expand-Archive" $progressBarStatus $previousSegmentWeight $currentSegmentWeight $zipArchive.Entries.Count $currentEntryCount + } + } + finally + { + If ($null -ne $zipArchive) + { + $zipArchive.Dispose() + } + + If ($null -ne $archiveFileStream) + { + $archiveFileStream.Dispose() + } + + # Complete writing progress. + Write-Progress -Activity "Expand-Archive" -Completed + } + } + +<############################################################################################ +# ProgressBarHelper: This is a helper function used to display progress message. +# This function is used by both Compress-Archive & Expand-Archive to display archive file +# creation/expansion progress. +############################################################################################> + function ProgressBarHelper + { + param + ( + [string] + $cmdletName, + + [string] + $status, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight, + + [int] + $totalNumberofEntries, + + [int] + $currentEntryCount + ) + + if ($currentEntryCount -gt 0 -and + $totalNumberofEntries -gt 0 -and + $previousSegmentWeight -ge 0 -and + $currentSegmentWeight -gt 0) + { + $entryDefaultWeight = $currentSegmentWeight/[double]$totalNumberofEntries + + $percentComplete = $previousSegmentWeight + ($entryDefaultWeight * $currentEntryCount) + Write-Progress -Activity $cmdletName -Status $status -PercentComplete $percentComplete + } + } + +<############################################################################################ +# CSVHelper: This is a helper function used to append comma after each path specifid by +# the SourcePath array. This helper function is used to display all the user supplied paths +# in the WhatIf message. +############################################################################################> + function CSVHelper + { + param + ( + [string[]] + $sourcePath + ) + + # SourcePath has already been validated by the calling funcation. + if ($sourcePath.Count -gt 1) + { + $sourcePathInCsvFormat = "`n" + for ($currentIndex = 0; $currentIndex -lt $sourcePath.Count; $currentIndex++) + { + if ($currentIndex -eq $sourcePath.Count - 1) + { + $sourcePathInCsvFormat += $sourcePath[$currentIndex] + } + else + { + $sourcePathInCsvFormat += $sourcePath[$currentIndex] + "`n" + } + } + } + else + { + $sourcePathInCsvFormat = $sourcePath + } + + return $sourcePathInCsvFormat + } + +<############################################################################################ +# ThrowTerminatingErrorHelper: This is a helper function used to throw terminating error. +############################################################################################> + function ThrowTerminatingErrorHelper + { + param + ( + [string] + $errorId, + + [string] + $errorMessage, + + [System.Management.Automation.ErrorCategory] + $errorCategory, + + [object] + $targetObject, + + [Exception] + $innerException + ) + + if ($innerException -eq $null) + { + $exception = New-object System.IO.IOException $errorMessage + } + else + { + $exception = New-Object System.IO.IOException $errorMessage, $innerException + } + + $exception = New-Object System.IO.IOException $errorMessage + $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $targetObject + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + +<############################################################################################ +# CreateErrorRecordHelper: This is a helper function used to create an ErrorRecord +############################################################################################> + function CreateErrorRecordHelper + { + param + ( + [string] + $errorId, + + [string] + $errorMessage, + + [System.Management.Automation.ErrorCategory] + $errorCategory, + + [Exception] + $exception, + + [object] + $targetObject + ) + + if ($null -eq $exception) + { + $exception = New-Object System.IO.IOException $errorMessage + } + + $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $targetObject + return $errorRecord + } + #endregion Utility Functions + + $inputPaths = @() + $destinationParentDir = [system.IO.Path]::GetDirectoryName($DestinationPath) + if ($null -eq $destinationParentDir) + { + $errorMessage = ($LocalizedData.InvalidDestinationPath -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + if ($destinationParentDir -eq [string]::Empty) + { + $destinationParentDir = '.' + } + + $achiveFileName = [system.IO.Path]::GetFileName($DestinationPath) + $destinationParentDir = GetResolvedPathHelper $destinationParentDir $false $PSCmdlet + + if ($destinationParentDir.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidArchiveFilePathError -f $DestinationPath, "DestinationPath", "DestinationPath") + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + IsValidFileSystemPath $destinationParentDir | Out-Null + $DestinationPath = Join-Path -Path $destinationParentDir -ChildPath $achiveFileName + + # GetExtension API does not validate for the actual existance of the path. + $extension = [system.IO.Path]::GetExtension($DestinationPath) + + # If user does not specify .Zip extension, we append it. + If ($extension -eq [string]::Empty) + { + $DestinationPathWithOutExtension = $DestinationPath + $DestinationPath = $DestinationPathWithOutExtension + $zipFileExtension + $appendArchiveFileExtensionMessage = ($LocalizedData.AppendArchiveFileExtensionMessage -f $DestinationPathWithOutExtension, $DestinationPath) + Write-Verbose $appendArchiveFileExtensionMessage + } + else + { + # Invalid file extension is specified for the zip file to be created. + if ($extension -ne $zipFileExtension) + { + $errorMessage = ($LocalizedData.InvalidZipFileExtensionError -f $extension, $zipFileExtension) + ThrowTerminatingErrorHelper "NotSupportedArchiveFileExtension" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $extension + } + } + + $archiveFileExist = Test-Path -LiteralPath $DestinationPath -PathType Leaf + + if ($archiveFileExist -and ($Update -eq $false -and $Force -eq $false)) + { + $errorMessage = ($LocalizedData.ZipFileExistError -f $DestinationPath) + ThrowTerminatingErrorHelper "ArchiveFileExists" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + # If archive file already exists and if -Update is specified, then we check to see + # if we have write access permission to update the existing archive file. + if ($archiveFileExist -and $Update -eq $true) + { + $item = Get-Item -Path $DestinationPath + if ($item.Attributes.ToString().Contains("ReadOnly")) + { + $errorMessage = ($LocalizedData.ArchiveFileIsReadOnly -f $DestinationPath) + ThrowTerminatingErrorHelper "ArchiveFileIsReadOnly" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidOperation) $DestinationPath + } + } + + $isWhatIf = $psboundparameters.ContainsKey("WhatIf") + if (!$isWhatIf) + { + $preparingToCompressVerboseMessage = ($LocalizedData.PreparingToCompressVerboseMessage) + Write-Verbose $preparingToCompressVerboseMessage + + $progressBarStatus = ($LocalizedData.CompressProgressBarText -f $DestinationPath) + ProgressBarHelper "Compress-Archive" $progressBarStatus 0 100 100 1 + } + } + PROCESS + { + if ($PsCmdlet.ParameterSetName -eq "Path" -or + $PsCmdlet.ParameterSetName -eq "PathWithForce" -or + $PsCmdlet.ParameterSetName -eq "PathWithUpdate") + { + $inputPaths += $Path + } + + if ($PsCmdlet.ParameterSetName -eq "LiteralPath" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithForce" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithUpdate") + { + $inputPaths += $LiteralPath + } + } + END + { + # If archive file already exists and if -Force is specified, we delete the + # existing artchive file and create a brand new one. + if (($PsCmdlet.ParameterSetName -eq "PathWithForce" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithForce") -and $archiveFileExist) + { + Remove-Item -Path $DestinationPath -Force -ErrorAction Stop + } + + # Validate Source Path depeding on parameter set being used. + # The specified source path conatins one or more files or directories that needs + # to be compressed. + $isLiteralPathUsed = $false + if ($PsCmdlet.ParameterSetName -eq "LiteralPath" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithForce" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithUpdate") + { + $isLiteralPathUsed = $true + } + + ValidateDuplicateFileSystemPath $PsCmdlet.ParameterSetName $inputPaths + $resolvedPaths = GetResolvedPathHelper $inputPaths $isLiteralPathUsed $PSCmdlet + IsValidFileSystemPath $resolvedPaths | Out-Null + + $sourcePath = $resolvedPaths; + + # CSVHelper: This is a helper function used to append comma after each path specifid by + # the $sourcePath array. The comma saperated paths are displayed in the -WhatIf message. + $sourcePathInCsvFormat = CSVHelper $sourcePath + if ($pscmdlet.ShouldProcess($sourcePathInCsvFormat)) + { + try + { + # StopProcessing is not avaliable in Script cmdlets. However the pipleline execution + # is terminated when ever 'CTRL + C' is entered by user to terminate the cmdlet execution. + # The finally block is executed whenever pipleline is terminated. + # $isArchiveFileProcessingComplete variable is used to track if 'CTRL + C' is entered by the + # user. + $isArchiveFileProcessingComplete = $false + + $numberOfItemsArchived = CompressArchiveHelper $sourcePath $DestinationPath $CompressionLevel $Update + + $isArchiveFileProcessingComplete = $true + } + finally + { + # The $isArchiveFileProcessingComplete would be set to $false if user has typed 'CTRL + C' to + # terminate the cmdlet execution or if an unhandled exception is thrown. + # $numberOfItemsArchived contains the count of number of files or directories add to the archive file. + # If the newly created archive file is empty then we delete it as its not usable. + if (($isArchiveFileProcessingComplete -eq $false) -or + ($numberOfItemsArchived -eq 0)) + { + $DeleteArchiveFileMessage = ($LocalizedData.DeleteArchiveFile -f $DestinationPath) + Write-Verbose $DeleteArchiveFileMessage + + # delete the partial archive file created. + if (Test-Path $DestinationPath) + { + Remove-Item -LiteralPath $DestinationPath -Force -Recurse -ErrorAction SilentlyContinue + } + } + } + } + } +} + +function Expand-Archive +{ + <# + .SYNOPSIS + Extracts files from a specified archive (zipped) file. + + .DESCRIPTION + The Expand-Archive cmdlet extracts files from a specified zipped archive file to a specified destination folder. An archive file allows multiple files to be packaged, and optionally compressed, into a single zipped file for easier distribution and storage. + + .PARAMETER Path + Specifies the path to the archive file. + + .PARAMETER LiteralPath + Specifies the path to an archive file. Unlike the Path parameter, the value of LiteralPath is used exactly as it is typed. Wildcard characters are not supported. If the path includes escape characters, enclose each escape character in single quotation marks, to instruct Windows PowerShell not to interpret any characters as escape sequences. + + .PARAMETER DestinationPath + Specifies the path to the folder in which you want the command to save extracted files. Enter the path to a folder, but do not specify a file name or file name extension. This parameter is required. + + .PARAMETER Force + Forces the command to run without asking for user confirmation. + + .PARAMETER Confirm + Prompts you for confirmation before running the cmdlet. + + .PARAMETER WhatIf + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + .EXAMPLE + Example 1: Extract the contents of an archive + + PS C:\>Expand-Archive -LiteralPath C:\Archives\Draft.Zip -DestinationPath C:\Reference + + This command extracts the contents of an existing archive file, Draft.zip, into the folder specified by the DestinationPath parameter, C:\Reference. + + .EXAMPLE + Example 2: Extract the contents of an archive in the current folder + + PS C:\>Expand-Archive -Path Draft.Zip -DestinationPath C:\Reference + + This command extracts the contents of an existing archive file in the current folder, Draft.zip, into the folder specified by the DestinationPath parameter, C:\Reference. + #> + [CmdletBinding( + DefaultParameterSetName = "Path", + SupportsShouldProcess = $true, + HelpUri = "http://go.microsoft.com/fwlink/?LinkID=393253")] + param + ( + [parameter ( + mandatory = $true, + Position = 0, + ParameterSetName = "Path", + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Path, + + [parameter ( + mandatory = $true, + ParameterSetName = "LiteralPath", + ValueFromPipelineByPropertyName = $true)] + [ValidateNotNullOrEmpty()] + [Alias("PSPath")] + [string] + $LiteralPath, + + [parameter (mandatory = $false, + Position = 1, + ValueFromPipeline = $false, + ValueFromPipelineByPropertyName = $false)] + [ValidateNotNullOrEmpty()] + [string] + $DestinationPath, + + [parameter (mandatory = $false, + ValueFromPipeline = $false, + ValueFromPipelineByPropertyName = $false)] + [switch] + $Force + ) + + BEGIN + { + Add-Type -AssemblyName System.IO.Compression -ErrorAction Ignore + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Ignore + + $zipFileExtension = ".zip" + + $LocalizedData = ConvertFrom-StringData @' +PathNotFoundError=The path '{0}' either does not exist or is not a valid file system path. +ExpandArchiveInValidDestinationPath=The path '{0}' is not a valid file system directory path. +InvalidZipFileExtensionError={0} is not a supported archive file format. {1} is the only supported archive file format. +ArchiveFileIsReadOnly=The attributes of the archive file {0} is set to 'ReadOnly' hence it cannot be updated. If you intend to update the existing archive file, remove the 'ReadOnly' attribute on the archive file else use -Force parameter to override and create a new archive file. +ZipFileExistError=The archive file {0} already exists. Use the -Update parameter to update the existing archive file or use the -Force parameter to overwrite the existing archive file. +DuplicatePathFoundError=The input to {0} parameter contains a duplicate path '{1}'. Provide a unique set of paths as input to {2} parameter. +ArchiveFileIsEmpty=The archive file {0} is empty. +CompressProgressBarText=The archive file '{0}' creation is in progress... +ExpandProgressBarText=The archive file '{0}' expansion is in progress... +AppendArchiveFileExtensionMessage=The archive file path '{0}' supplied to the DestinationPath patameter does not include .zip extension. Hence .zip is appended to the supplied DestinationPath path and the archive file would be created at '{1}'. +AddItemtoArchiveFile=Adding '{0}'. +CreateFileAtExpandedPath=Created '{0}'. +InvalidArchiveFilePathError=The archive file path '{0}' specified as input to the {1} parameter is resolving to multiple file system paths. Provide a unique path to the {2} parameter where the archive file has to be created. +InvalidExpandedDirPathError=The directory path '{0}' specified as input to the DestinationPath parameter is resolving to multiple file system paths. Provide a unique path to the Destination parameter where the archive file contents have to be expanded. +FileExistsError=Failed to create file '{0}' while expanding the archive file '{1}' contents as the file '{2}' already exists. Use the -Force parameter if you want to overwrite the existing directory '{3}' contents when expanding the archive file. +DeleteArchiveFile=The partially created archive file '{0}' is deleted as it is not usable. +InvalidDestinationPath=The destination path '{0}' does not contain a valid archive file name. +PreparingToCompressVerboseMessage=Preparing to compress... +PreparingToExpandVerboseMessage=Preparing to expand... +'@ + + #region Utility Functions + function GetResolvedPathHelper + { + param + ( + [string[]] + $path, + + [boolean] + $isLiteralPath, + + [System.Management.Automation.PSCmdlet] + $callerPSCmdlet + ) + + $resolvedPaths = @() + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach ($currentPath in $path) + { + try + { + if ($isLiteralPath) + { + $currentResolvedPaths = Resolve-Path -LiteralPath $currentPath -ErrorAction Stop + } + else + { + $currentResolvedPaths = Resolve-Path -Path $currentPath -ErrorAction Stop + } + } + catch + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $currentPath) + $exception = New-Object System.InvalidOperationException $errorMessage, $_.Exception + $errorRecord = CreateErrorRecordHelper "ArchiveCmdletPathNotFound" $null ([System.Management.Automation.ErrorCategory]::InvalidArgument) $exception $currentPath + $callerPSCmdlet.ThrowTerminatingError($errorRecord) + } + + foreach ($currentResolvedPath in $currentResolvedPaths) + { + $resolvedPaths += $currentResolvedPath.ProviderPath + } + } + + $resolvedPaths + } + + function Add-CompressionAssemblies + { + + if ($PSEdition -eq "Desktop") + { + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + } + } + + function IsValidFileSystemPath + { + param + ( + [string[]] + $path + ) + + $result = $true; + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach ($currentPath in $path) + { + if (!([System.IO.File]::Exists($currentPath) -or [System.IO.Directory]::Exists($currentPath))) + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $currentPath) + ThrowTerminatingErrorHelper "PathNotFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $currentPath + } + } + + return $result; + } + + + function ValidateDuplicateFileSystemPath + { + param + ( + [string] + $inputParameter, + + [string[]] + $path + ) + + $uniqueInputPaths = @() + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach ($currentPath in $path) + { + $currentInputPath = $currentPath.ToUpper() + if ($uniqueInputPaths.Contains($currentInputPath)) + { + $errorMessage = ($LocalizedData.DuplicatePathFoundError -f $inputParameter, $currentPath, $inputParameter) + ThrowTerminatingErrorHelper "DuplicatePathFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $currentPath + } + else + { + $uniqueInputPaths += $currentInputPath + } + } + } + + function CompressionLevelMapper + { + param + ( + [string] + $compressionLevel + ) + + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::Optimal + + # CompressionLevel format is already validated at the cmdlet layer. + switch ($compressionLevel.ToString()) + { + "Fastest" + { + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::Fastest + } + "NoCompression" + { + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::NoCompression + } + } + + return $compressionLevelFormat + } + + function CompressArchiveHelper + { + param + ( + [string[]] + $sourcePath, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $isUpdateMode + ) + + $numberOfItemsArchived = 0 + $sourceFilePaths = @() + $sourceDirPaths = @() + + foreach ($currentPath in $sourcePath) + { + $result = Test-Path -LiteralPath $currentPath -PathType Leaf + if ($result -eq $true) + { + $sourceFilePaths += $currentPath + } + else + { + $sourceDirPaths += $currentPath + } + } + + # The Soure Path contains one or more directory (this directory can have files under it) and no files to be compressed. + if ($sourceFilePaths.Count -eq 0 -and $sourceDirPaths.Count -gt 0) + { + $currentSegmentWeight = 100/[double]$sourceDirPaths.Count + $previousSegmentWeight = 0 + foreach ($currentSourceDirPath in $sourceDirPaths) + { + $count = CompressSingleDirHelper $currentSourceDirPath $destinationPath $compressionLevel $true $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + $previousSegmentWeight += $currentSegmentWeight + } + } + + # The Soure Path contains only files to be compressed. + elseIf ($sourceFilePaths.Count -gt 0 -and $sourceDirPaths.Count -eq 0) + { + # $previousSegmentWeight is equal to 0 as there are no prior segments. + # $currentSegmentWeight is set to 100 as all files have equal weightage. + $previousSegmentWeight = 0 + $currentSegmentWeight = 100 + + $numberOfItemsArchived = CompressFilesHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $previousSegmentWeight $currentSegmentWeight + } + # The Soure Path contains one or more files and one or more directories (this directory can have files under it) to be compressed. + elseif ($sourceFilePaths.Count -gt 0 -and $sourceDirPaths.Count -gt 0) + { + # each directory is considered as an individual segments & all the individual files are clubed in to a separate sgemnet. + $currentSegmentWeight = 100/[double]($sourceDirPaths.Count + 1) + $previousSegmentWeight = 0 + + foreach ($currentSourceDirPath in $sourceDirPaths) + { + $count = CompressSingleDirHelper $currentSourceDirPath $destinationPath $compressionLevel $true $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + $previousSegmentWeight += $currentSegmentWeight + } + + $count = CompressFilesHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + } + + return $numberOfItemsArchived + } + + function CompressFilesHelper + { + param + ( + [string[]] + $sourceFilePaths, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $isUpdateMode, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight + ) + + $numberOfItemsArchived = ZipArchiveHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $null $previousSegmentWeight $currentSegmentWeight + + return $numberOfItemsArchived + } + + function CompressSingleDirHelper + { + param + ( + [string] + $sourceDirPath, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $useParentDirAsRoot, + + [bool] + $isUpdateMode, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight + ) + + [System.Collections.Generic.List[System.String]]$subDirFiles = @() + + if ($useParentDirAsRoot) + { + $sourceDirInfo = New-Object -TypeName System.IO.DirectoryInfo -ArgumentList $sourceDirPath + $sourceDirFullName = $sourceDirInfo.Parent.FullName + + # If the directory is present at the drive level the DirectoryInfo.Parent include '\' example: C:\ + # On the other hand if the directory exists at a deper level then DirectoryInfo.Parent + # has just the path (without an ending '\'). example C:\source + if ($sourceDirFullName.Length -eq 3) + { + $modifiedSourceDirFullName = $sourceDirFullName + } + else + { + $modifiedSourceDirFullName = $sourceDirFullName + "\" + } + } + else + { + $sourceDirFullName = $sourceDirPath + $modifiedSourceDirFullName = $sourceDirFullName + "\" + } + + $dirContents = Get-ChildItem -LiteralPath $sourceDirPath -Recurse + foreach ($currentContent in $dirContents) + { + $isContainer = $currentContent -is [System.IO.DirectoryInfo] + if (!$isContainer) + { + $subDirFiles.Add($currentContent.FullName) + } + else + { + # The currentContent points to a directory. + # We need to check if the directory is an empty directory, if so such a + # directory has to be explictly added to the archive file. + # if there are no files in the directory the GetFiles() API returns an empty array. + $files = $currentContent.GetFiles() + if ($files.Count -eq 0) + { + $subDirFiles.Add($currentContent.FullName + "\") + } + } + } + + $numberOfItemsArchived = ZipArchiveHelper $subDirFiles.ToArray() $destinationPath $compressionLevel $isUpdateMode $modifiedSourceDirFullName $previousSegmentWeight $currentSegmentWeight + + return $numberOfItemsArchived + } + + function ZipArchiveHelper + { + param + ( + [System.Collections.Generic.List[System.String]] + $sourcePaths, + + [string] + $destinationPath, + + [string] + $compressionLevel, + + [bool] + $isUpdateMode, + + [string] + $modifiedSourceDirFullName, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight + ) + + $numberOfItemsArchived = 0 + $fileMode = [System.IO.FileMode]::Create + $result = Test-Path -LiteralPath $DestinationPath -PathType Leaf + if ($result -eq $true) + { + $fileMode = [System.IO.FileMode]::Open + } + + Add-CompressionAssemblies + + try + { + # At this point we are sure that the archive file has write access. + $archiveFileStreamArgs = @($destinationPath, $fileMode) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Update, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + $currentEntryCount = 0 + $progressBarStatus = ($LocalizedData.CompressProgressBarText -f $destinationPath) + $bufferSize = 4kb + $buffer = New-Object Byte[] $bufferSize + + foreach ($currentFilePath in $sourcePaths) + { + if ($modifiedSourceDirFullName -ne $null -and $modifiedSourceDirFullName.Length -gt 0) + { + $index = $currentFilePath.IndexOf($modifiedSourceDirFullName, [System.StringComparison]::OrdinalIgnoreCase) + $currentFilePathSubString = $currentFilePath.Substring($index, $modifiedSourceDirFullName.Length) + $relativeFilePath = $currentFilePath.Replace($currentFilePathSubString, "").Trim() + } + else + { + $relativeFilePath = [System.IO.Path]::GetFileName($currentFilePath) + } + + # Update mode is selected. + # Check to see if archive file already contains one or more zip files in it. + if ($isUpdateMode -eq $true -and $zipArchive.Entries.Count -gt 0) + { + $entryToBeUpdated = $null + + # Check if the file already exists in the archive file. + # If so replace it with new file from the input source. + # If the file does not exist in the archive file then default to + # create mode and create the entry in the archive file. + + foreach ($currentArchiveEntry in $zipArchive.Entries) + { + if ($currentArchiveEntry.FullName -eq $relativeFilePath) + { + $entryToBeUpdated = $currentArchiveEntry + break + } + } + + if ($entryToBeUpdated -ne $null) + { + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + $entryToBeUpdated.Delete() + } + } + + $compression = CompressionLevelMapper $compressionLevel + + # If a directory needs to be added to an archive file, + # by convention the .Net API's expect the path of the diretcory + # to end with '\' to detect the path as an directory. + if (!$relativeFilePath.EndsWith("\", [StringComparison]::OrdinalIgnoreCase)) + { + try + { + try + { + $currentFileStream = [System.IO.File]::Open($currentFilePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) + } + catch + { + # Failed to access the file. Write a non terminating error to the pipeline + # and move on with the remaining files. + $exception = $_.Exception + if ($null -ne $_.Exception -and + $null -ne $_.Exception.InnerException) + { + $exception = $_.Exception.InnerException + } + $errorRecord = CreateErrorRecordHelper "CompressArchiveUnauthorizedAccessError" $null ([System.Management.Automation.ErrorCategory]::PermissionDenied) $exception $currentFilePath + Write-Error -ErrorRecord $errorRecord + } + + if ($null -ne $currentFileStream) + { + $srcStream = New-Object System.IO.BinaryReader $currentFileStream + + $currentArchiveEntry = $zipArchive.CreateEntry($relativeFilePath, $compression) + + # Updating the File Creation time so that the same timestamp would be retained after expanding the compressed file. + # At this point we are sure that Get-ChildItem would succeed. + $currentArchiveEntry.LastWriteTime = (Get-Item -LiteralPath $currentFilePath).LastWriteTime + + $destStream = New-Object System.IO.BinaryWriter $currentArchiveEntry.Open() + + while ($numberOfBytesRead = $srcStream.Read($buffer, 0, $bufferSize)) + { + $destStream.Write($buffer, 0, $numberOfBytesRead) + $destStream.Flush() + } + + $numberOfItemsArchived += 1 + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + } + } + finally + { + If ($null -ne $currentFileStream) + { + $currentFileStream.Dispose() + } + If ($null -ne $srcStream) + { + $srcStream.Dispose() + } + If ($null -ne $destStream) + { + $destStream.Dispose() + } + } + } + else + { + $currentArchiveEntry = $zipArchive.CreateEntry("$relativeFilePath", $compression) + $numberOfItemsArchived += 1 + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + } + + if ($null -ne $addItemtoArchiveFileMessage) + { + Write-Verbose $addItemtoArchiveFileMessage + } + + $currentEntryCount += 1 + ProgressBarHelper "Compress-Archive" $progressBarStatus $previousSegmentWeight $currentSegmentWeight $sourcePaths.Count $currentEntryCount + } + } + finally + { + If ($null -ne $zipArchive) + { + $zipArchive.Dispose() + } + + If ($null -ne $archiveFileStream) + { + $archiveFileStream.Dispose() + } + + # Complete writing progress. + Write-Progress -Activity "Compress-Archive" -Completed + } + + return $numberOfItemsArchived + } + +<############################################################################################ +# ValidateArchivePathHelper: This is a helper function used to validate the archive file +# path & its file format. The only supported archive file format is .zip +############################################################################################> + function ValidateArchivePathHelper + { + param + ( + [string] + $archiveFile + ) + + if ([System.IO.File]::Exists($archiveFile)) + { + $extension = [system.IO.Path]::GetExtension($archiveFile) + + # Invalid file extension is specifed for the zip file. + if ($extension -ne $zipFileExtension) + { + $errorMessage = ($LocalizedData.InvalidZipFileExtensionError -f $extension, $zipFileExtension) + ThrowTerminatingErrorHelper "NotSupportedArchiveFileExtension" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $extension + } + } + else + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $archiveFile) + ThrowTerminatingErrorHelper "PathNotFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $archiveFile + } + } + +<############################################################################################ +# ExpandArchiveHelper: This is a helper function used to expand the archive file contents +# to the specified directory. +############################################################################################> + function ExpandArchiveHelper + { + param + ( + [string] + $archiveFile, + + [string] + $expandedDir, + + [ref] + $expandedItems, + + [boolean] + $force, + + [boolean] + $isVerbose, + + [boolean] + $isConfirm + ) + + Add-CompressionAssemblies + + try + { + # The existance of archive file has already been validated by ValidateArchivePathHelper + # before calling this helper function. + $archiveFileStreamArgs = @($archiveFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Read, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + if ($zipArchive.Entries.Count -eq 0) + { + $archiveFileIsEmpty = ($LocalizedData.ArchiveFileIsEmpty -f $archiveFile) + Write-Verbose $archiveFileIsEmpty + return + } + + $currentEntryCount = 0 + $progressBarStatus = ($LocalizedData.ExpandProgressBarText -f $archiveFile) + + # The archive entries can either be empty directories or files. + foreach ($currentArchiveEntry in $zipArchive.Entries) + { + $currentArchiveEntryPath = Join-Path -Path $expandedDir -ChildPath $currentArchiveEntry.FullName + $extension = [system.IO.Path]::GetExtension($currentArchiveEntryPath) + + # The current archive entry is an empty directory + # The FullName of the Archive Entry representing a directory would end with a trailing '\'. + if ($extension -eq [string]::Empty -and + $currentArchiveEntryPath.EndsWith("\", [StringComparison]::OrdinalIgnoreCase)) + { + $pathExists = Test-Path -LiteralPath $currentArchiveEntryPath + + # The current archive entry expects an empty directory. + # Check if the existing directory is empty. If its not empty + # then it means that user has added this directory by other means. + if ($pathExists -eq $false) + { + New-Item $currentArchiveEntryPath -ItemType Directory -Confirm:$isConfirm | Out-Null + + if (Test-Path -LiteralPath $currentArchiveEntryPath -PathType Container) + { + $addEmptyDirectorytoExpandedPathMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentArchiveEntryPath) + Write-Verbose $addEmptyDirectorytoExpandedPathMessage + + $expandedItems.Value += $currentArchiveEntryPath + } + } + } + else + { + try + { + $currentArchiveEntryFileInfo = New-Object -TypeName System.IO.FileInfo -ArgumentList $currentArchiveEntryPath + $parentDirExists = Test-Path -LiteralPath $currentArchiveEntryFileInfo.DirectoryName -PathType Container + + # If the Parent directory of the current entry in the archive file does not exist, then create it. + if ($parentDirExists -eq $false) + { + New-Item $currentArchiveEntryFileInfo.DirectoryName -ItemType Directory -Confirm:$isConfirm | Out-Null + + if (!(Test-Path -LiteralPath $currentArchiveEntryFileInfo.DirectoryName -PathType Container)) + { + # The directory referred by $currentArchiveEntryFileInfo.DirectoryName was not successfully created. + # This could be because the user has specified -Confirm paramter when Expand-Archive was invoked + # and authorization was not provided when confirmation was prompted. In such a scenario, + # we skip the current file in the archive and continue with the remaining archive file contents. + Continue + } + + $expandedItems.Value += $currentArchiveEntryFileInfo.DirectoryName + } + + $hasNonTerminatingError = $false + + # Check if the file in to which the current archive entry contents + # would be expanded already exists. + if ($currentArchiveEntryFileInfo.Exists) + { + if ($force) + { + Remove-Item -LiteralPath $currentArchiveEntryFileInfo.FullName -Force -ErrorVariable ev -Verbose:$isVerbose -Confirm:$isConfirm + if ($ev -ne $null) + { + $hasNonTerminatingError = $true + } + + if (Test-Path -LiteralPath $currentArchiveEntryFileInfo.FullName -PathType Leaf) + { + # The file referred by $currentArchiveEntryFileInfo.FullName was not successfully removed. + # This could be because the user has specified -Confirm paramter when Expand-Archive was invoked + # and authorization was not provided when confirmation was prompted. In such a scenario, + # we skip the current file in the archive and continue with the remaining archive file contents. + Continue + } + } + else + { + # Write non-terminating error to the pipeline. + $errorMessage = ($LocalizedData.FileExistsError -f $currentArchiveEntryFileInfo.FullName, $archiveFile, $currentArchiveEntryFileInfo.FullName, $currentArchiveEntryFileInfo.FullName) + $errorRecord = CreateErrorRecordHelper "ExpandArchiveFileExists" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidOperation) $null $currentArchiveEntryFileInfo.FullName + Write-Error -ErrorRecord $errorRecord + $hasNonTerminatingError = $true + } + } + + if (!$hasNonTerminatingError) + { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($currentArchiveEntry, $currentArchiveEntryPath, $false) + + # Add the expanded file path to the $expandedItems array, + # to keep track of all the expanded files created while expanding the archive file. + # If user enters CTRL + C then at that point of time, all these expanded files + # would be deleted as part of the clean up process. + $expandedItems.Value += $currentArchiveEntryPath + + $addFiletoExpandedPathMessage = ($LocalizedData.CreateFileAtExpandedPath -f $currentArchiveEntryPath) + Write-Verbose $addFiletoExpandedPathMessage + } + } + finally + { + If ($null -ne $destStream) + { + $destStream.Dispose() + } + + If ($null -ne $srcStream) + { + $srcStream.Dispose() + } + } + } + + $currentEntryCount += 1 + # $currentSegmentWeight is Set to 100 giving equal weightage to each file that is getting expanded. + # $previousSegmentWeight is set to 0 as there are no prior segments. + $previousSegmentWeight = 0 + $currentSegmentWeight = 100 + ProgressBarHelper "Expand-Archive" $progressBarStatus $previousSegmentWeight $currentSegmentWeight $zipArchive.Entries.Count $currentEntryCount + } + } + finally + { + If ($null -ne $zipArchive) + { + $zipArchive.Dispose() + } + + If ($null -ne $archiveFileStream) + { + $archiveFileStream.Dispose() + } + + # Complete writing progress. + Write-Progress -Activity "Expand-Archive" -Completed + } + } + +<############################################################################################ +# ProgressBarHelper: This is a helper function used to display progress message. +# This function is used by both Compress-Archive & Expand-Archive to display archive file +# creation/expansion progress. +############################################################################################> + function ProgressBarHelper + { + param + ( + [string] + $cmdletName, + + [string] + $status, + + [double] + $previousSegmentWeight, + + [double] + $currentSegmentWeight, + + [int] + $totalNumberofEntries, + + [int] + $currentEntryCount + ) + + if ($currentEntryCount -gt 0 -and + $totalNumberofEntries -gt 0 -and + $previousSegmentWeight -ge 0 -and + $currentSegmentWeight -gt 0) + { + $entryDefaultWeight = $currentSegmentWeight/[double]$totalNumberofEntries + + $percentComplete = $previousSegmentWeight + ($entryDefaultWeight * $currentEntryCount) + Write-Progress -Activity $cmdletName -Status $status -PercentComplete $percentComplete + } + } + +<############################################################################################ +# CSVHelper: This is a helper function used to append comma after each path specifid by +# the SourcePath array. This helper function is used to display all the user supplied paths +# in the WhatIf message. +############################################################################################> + function CSVHelper + { + param + ( + [string[]] + $sourcePath + ) + + # SourcePath has already been validated by the calling funcation. + if ($sourcePath.Count -gt 1) + { + $sourcePathInCsvFormat = "`n" + for ($currentIndex = 0; $currentIndex -lt $sourcePath.Count; $currentIndex++) + { + if ($currentIndex -eq $sourcePath.Count - 1) + { + $sourcePathInCsvFormat += $sourcePath[$currentIndex] + } + else + { + $sourcePathInCsvFormat += $sourcePath[$currentIndex] + "`n" + } + } + } + else + { + $sourcePathInCsvFormat = $sourcePath + } + + return $sourcePathInCsvFormat + } + +<############################################################################################ +# ThrowTerminatingErrorHelper: This is a helper function used to throw terminating error. +############################################################################################> + function ThrowTerminatingErrorHelper + { + param + ( + [string] + $errorId, + + [string] + $errorMessage, + + [System.Management.Automation.ErrorCategory] + $errorCategory, + + [object] + $targetObject, + + [Exception] + $innerException + ) + + if ($innerException -eq $null) + { + $exception = New-object System.IO.IOException $errorMessage + } + else + { + $exception = New-Object System.IO.IOException $errorMessage, $innerException + } + + $exception = New-Object System.IO.IOException $errorMessage + $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $targetObject + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + +<############################################################################################ +# CreateErrorRecordHelper: This is a helper function used to create an ErrorRecord +############################################################################################> + function CreateErrorRecordHelper + { + param + ( + [string] + $errorId, + + [string] + $errorMessage, + + [System.Management.Automation.ErrorCategory] + $errorCategory, + + [Exception] + $exception, + + [object] + $targetObject + ) + + if ($null -eq $exception) + { + $exception = New-Object System.IO.IOException $errorMessage + } + + $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $targetObject + return $errorRecord + } + #endregion Utility Functions + + $isVerbose = $psboundparameters.ContainsKey("Verbose") + $isConfirm = $psboundparameters.ContainsKey("Confirm") + + $isDestinationPathProvided = $true + if ($DestinationPath -eq [string]::Empty) + { + $resolvedDestinationPath = $pwd + $isDestinationPathProvided = $false + } + else + { + $destinationPathExists = Test-Path -Path $DestinationPath -PathType Container + if ($destinationPathExists) + { + $resolvedDestinationPath = GetResolvedPathHelper $DestinationPath $false $PSCmdlet + if ($resolvedDestinationPath.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidExpandedDirPathError -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidDestinationPath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + # At this point we are sure that the provided path resolves to a valid single path. + # Calling Resolve-Path again to get the underlying provider name. + $suppliedDestinationPath = Resolve-Path -Path $DestinationPath + if ($suppliedDestinationPath.Provider.Name -ne "FileSystem") + { + $errorMessage = ($LocalizedData.ExpandArchiveInValidDestinationPath -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidDirectoryPath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + } + else + { + $createdItem = New-Item -Path $DestinationPath -ItemType Directory -Confirm:$isConfirm -Verbose:$isVerbose -ErrorAction Stop + if ($createdItem -ne $null -and $createdItem.PSProvider.Name -ne "FileSystem") + { + Remove-Item "$DestinationPath" -Force -Recurse -ErrorAction SilentlyContinue + $errorMessage = ($LocalizedData.ExpandArchiveInValidDestinationPath -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidDirectoryPath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + $resolvedDestinationPath = GetResolvedPathHelper $DestinationPath $true $PSCmdlet + } + } + + $isWhatIf = $psboundparameters.ContainsKey("WhatIf") + if (!$isWhatIf) + { + $preparingToExpandVerboseMessage = ($LocalizedData.PreparingToExpandVerboseMessage) + Write-Verbose $preparingToExpandVerboseMessage + + $progressBarStatus = ($LocalizedData.ExpandProgressBarText -f $DestinationPath) + ProgressBarHelper "Expand-Archive" $progressBarStatus 0 100 100 1 + } + } + PROCESS + { + switch ($PsCmdlet.ParameterSetName) + { + "Path" + { + $resolvedSourcePaths = GetResolvedPathHelper $Path $false $PSCmdlet + + if ($resolvedSourcePaths.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidArchiveFilePathError -f $Path, $PsCmdlet.ParameterSetName, $PsCmdlet.ParameterSetName) + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $Path + } + } + "LiteralPath" + { + $resolvedSourcePaths = GetResolvedPathHelper $LiteralPath $true $PSCmdlet + + if ($resolvedSourcePaths.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidArchiveFilePathError -f $LiteralPath, $PsCmdlet.ParameterSetName, $PsCmdlet.ParameterSetName) + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $LiteralPath + } + } + } + + ValidateArchivePathHelper $resolvedSourcePaths + + if ($pscmdlet.ShouldProcess($resolvedSourcePaths)) + { + $expandedItems = @() + + try + { + # StopProcessing is not avaliable in Script cmdlets. However the pipleline execution + # is terminated when ever 'CTRL + C' is entered by user to terminate the cmdlet execution. + # The finally block is executed whenever pipleline is terminated. + # $isArchiveFileProcessingComplete variable is used to track if 'CTRL + C' is entered by the + # user. + $isArchiveFileProcessingComplete = $false + + # The User has not provided a destination path, hence we use '$pwd\ArchiveFileName' as the directory where the + # archive file contents would be expanded. If the path '$pwd\ArchiveFileName' already exists then we use the + # Windows default mechanism of appending a counter value at the end of the directory name where the contents + # would be expanded. + if (!$isDestinationPathProvided) + { + $archiveFile = New-Object System.IO.FileInfo $resolvedSourcePaths + $resolvedDestinationPath = Join-Path -Path $resolvedDestinationPath -ChildPath $archiveFile.BaseName + $destinationPathExists = Test-Path -LiteralPath $resolvedDestinationPath -PathType Container + + if (!$destinationPathExists) + { + New-Item -Path $resolvedDestinationPath -ItemType Directory -Confirm:$isConfirm -Verbose:$isVerbose -ErrorAction Stop | Out-Null + } + } + + ExpandArchiveHelper $resolvedSourcePaths $resolvedDestinationPath ([ref]$expandedItems) $Force $isVerbose $isConfirm + + $isArchiveFileProcessingComplete = $true + } + finally + { + # The $isArchiveFileProcessingComplete would be set to $false if user has typed 'CTRL + C' to + # terminate the cmdlet execution or if an unhandled exception is thrown. + if ($isArchiveFileProcessingComplete -eq $false) + { + if ($expandedItems.Count -gt 0) + { + # delete the expanded file/directory as the archive + # file was not completly expanded. + $expandedItems | ForEach-Object { Remove-Item $_ -Force -Recurse } + } + } + } + } + } +} + +function Write-LocalMessage +{ + [CmdletBinding()] + Param ( + [string]$Message + ) + + if (Test-Path function:Write-PSFMessage) { Write-PSFMessage -Level Important -Message $Message } + else { Write-Host $Message } +} +#endregion Utility Functions + +try +{ + [System.Net.ServicePointManager]::SecurityProtocol = "Tls12" + + Write-LocalMessage -Message "Downloading repository from '$($BaseUrl)/archive/$($Branch).zip'" + Invoke-WebRequest -Uri "$($BaseUrl)/archive/$($Branch).zip" -UseBasicParsing -OutFile "$($env:TEMP)\$($ModuleName).zip" -ErrorAction Stop + + Write-LocalMessage -Message "Creating temporary project folder: '$($env:TEMP)\$($ModuleName)'" + $null = New-Item -Path $env:TEMP -Name $ModuleName -ItemType Directory -Force -ErrorAction Stop + + Write-LocalMessage -Message "Extracting archive to '$($env:TEMP)\$($ModuleName)'" + Expand-Archive -Path "$($env:TEMP)\$($ModuleName).zip" -DestinationPath "$($env:TEMP)\$($ModuleName)" -ErrorAction Stop + + $basePath = Get-ChildItem "$($env:TEMP)\$($ModuleName)\*" | Select-Object -First 1 + if ($SubFolder) { $basePath = "$($basePath)\$($SubFolder)" } + + # Only needed for PS v5+ but doesn't hurt anyway + $manifest = "$($basePath)\$($ModuleName).psd1" + $manifestData = Invoke-Expression ([System.IO.File]::ReadAllText($manifest)) + $moduleVersion = $manifestData.ModuleVersion + Write-LocalMessage -Message "Download concluded: $($ModuleName) | Branch $($Branch) | Version $($moduleVersion)" + + # Determine output path + $path = "$($env:ProgramFiles)\WindowsPowerShell\Modules\$($ModuleName)" + if ($doUserMode) { $path = "$(Split-Path $profile.CurrentUserAllHosts)\Modules\$($ModuleName)" } + if ($PSVersionTable.PSVersion.Major -ge 5) { $path += "\$moduleVersion" } + + if ((Test-Path $path) -and (-not $Force)) + { + Write-LocalMessage -Message "Module already installed, interrupting installation" + return + } + + Write-LocalMessage -Message "Creating folder: $($path)" + $null = New-Item -Path $path -ItemType Directory -Force -ErrorAction Stop + + Write-LocalMessage -Message "Copying files to $($path)" + foreach ($file in (Get-ChildItem -Path $basePath)) + { + Move-Item -Path $file.FullName -Destination $path -ErrorAction Stop + } + + Write-LocalMessage -Message "Cleaning up temporary files" + Remove-Item -Path "$($env:TEMP)\$($ModuleName)" -Force -Recurse + Remove-Item -Path "$($env:TEMP)\$($ModuleName).zip" -Force + + Write-LocalMessage -Message "Installation of the module $($ModuleName), Branch $($Branch), Version $($moduleVersion) completed successfully!" +} +catch +{ + Write-LocalMessage -Message "Installation of the module $($ModuleName) failed!" + + Write-LocalMessage -Message "Cleaning up temporary files" + Remove-Item -Path "$($env:TEMP)\$($ModuleName)" -Force -Recurse + Remove-Item -Path "$($env:TEMP)\$($ModuleName).zip" -Force + + throw +} \ No newline at end of file diff --git a/library/VMDeploy.Guest/VMDeploy.Guest.sln b/library/VMDeploy.Guest/VMDeploy.Guest.sln new file mode 100644 index 0000000..514eb5d --- /dev/null +++ b/library/VMDeploy.Guest/VMDeploy.Guest.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27130.2010 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{0F4D34EA-3298-41D9-9142-F1F238CEE330}") = "VMDeploy.Guest", "VMDeploy.Guest\VMDeploy.Guest.csproj", "{E54F9592-AF1D-4E30-86D1-F34DF9F6DDA6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E54F9592-AF1D-4E30-86D1-F34DF9F6DDA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E54F9592-AF1D-4E30-86D1-F34DF9F6DDA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E54F9592-AF1D-4E30-86D1-F34DF9F6DDA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E54F9592-AF1D-4E30-86D1-F34DF9F6DDA6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {382C6A04-55F1-4FFB-980C-ABDE1F238BF2} + EndGlobalSection +EndGlobal diff --git a/library/VMDeploy.Guest/VMDeploy.Guest/Class1.cs b/library/VMDeploy.Guest/VMDeploy.Guest/Class1.cs new file mode 100644 index 0000000..23ce7fe --- /dev/null +++ b/library/VMDeploy.Guest/VMDeploy.Guest/Class1.cs @@ -0,0 +1,8 @@ +using System; + +namespace VMDeploy.Guest +{ + public class Class1 + { + } +} diff --git a/library/VMDeploy.Guest/VMDeploy.Guest/VMDeploy.Guest.csproj b/library/VMDeploy.Guest/VMDeploy.Guest/VMDeploy.Guest.csproj new file mode 100644 index 0000000..41dc623 --- /dev/null +++ b/library/VMDeploy.Guest/VMDeploy.Guest/VMDeploy.Guest.csproj @@ -0,0 +1,21 @@ + + + + net4.5.2 + + + + ..\..\..\VMDeploy.Guest\bin + ..\..\..\VMDeploy.Guest\bin\VMDeploy.Guest.xml + + + + ..\..\..\VMDeploy.Guest\bin + ..\..\..\VMDeploy.Guest\bin\VMDeploy.Guest.xml + + + + false + + + From 9251232d182f81ddff6fd4ff8dbbf02fe847944d Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 7 Apr 2021 16:16:06 +0200 Subject: [PATCH 02/28] initial guest framework --- VMDeploy.Guest/VMDeploy.Guest.psd1 | 25 ++--- VMDeploy.Guest/en-us/strings.psd1 | 17 ++- .../functions/Actions/Get-VMGuestAction.ps1 | 12 +++ .../Actions/Register-VMGuestAction.ps1 | 45 ++++++++ .../Actions/Unregister-VMGuestAction.ps1 | 15 +++ .../Clear-VMGuestConfiguration.ps1 | 8 ++ .../Get-VMGuestConfiguration.ps1 | 12 +++ .../Import-VMGuestConfiguration.ps1 | 52 +++++++++ .../Invoke-VMGuestConfiguration.ps1 | 56 ++++++++++ .../Register-VMGuestConfiguration.ps1 | 44 ++++++++ .../Test-VMGuestConfiguration.ps1 | 60 +++++++++++ VMDeploy.Guest/functions/readme.md | 20 +++- .../internal/actions/NewFolder.action.ps1 | 31 ++++++ .../internal/scripts/initialize.ps1 | 9 ++ .../internal/scripts/postimport.ps1 | 9 ++ VMDeploy.Guest/internal/scripts/variables.ps1 | 5 + VMDeploy.Guest/internal/tepp/action.tepp.ps1 | 3 + VMDeploy.Guest/internal/tepp/config.tepp.ps1 | 3 + VMDeploy.Guest/internal/tepp/example.tepp.ps1 | 4 - .../xml/VMDeploy.Guest.Format.ps1xml | 100 +++++++++++++----- 20 files changed, 484 insertions(+), 46 deletions(-) create mode 100644 VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 create mode 100644 VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 create mode 100644 VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 create mode 100644 VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 create mode 100644 VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 create mode 100644 VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 create mode 100644 VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 create mode 100644 VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 create mode 100644 VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 create mode 100644 VMDeploy.Guest/internal/actions/NewFolder.action.ps1 create mode 100644 VMDeploy.Guest/internal/scripts/initialize.ps1 create mode 100644 VMDeploy.Guest/internal/scripts/variables.ps1 create mode 100644 VMDeploy.Guest/internal/tepp/action.tepp.ps1 create mode 100644 VMDeploy.Guest/internal/tepp/config.tepp.ps1 delete mode 100644 VMDeploy.Guest/internal/tepp/example.tepp.ps1 diff --git a/VMDeploy.Guest/VMDeploy.Guest.psd1 b/VMDeploy.Guest/VMDeploy.Guest.psd1 index b1d29bc..c80aab0 100644 --- a/VMDeploy.Guest/VMDeploy.Guest.psd1 +++ b/VMDeploy.Guest/VMDeploy.Guest.psd1 @@ -26,7 +26,7 @@ # Modules that must be imported into the global environment prior to importing # this module RequiredModules = @( - @{ ModuleName='PSFramework'; ModuleVersion='1.6.193' } + @{ ModuleName='PSFramework'; ModuleVersion='1.6.195' } ) # Assemblies that must be loaded prior to importing this module @@ -36,19 +36,20 @@ # TypesToProcess = @('xml\VMDeploy.Guest.Types.ps1xml') # Format files (.ps1xml) to be loaded when importing this module - # FormatsToProcess = @('xml\VMDeploy.Guest.Format.ps1xml') + FormatsToProcess = @('xml\VMDeploy.Guest.Format.ps1xml') # Functions to export from this module - FunctionsToExport = '' - - # Cmdlets to export from this module - CmdletsToExport = '' - - # Variables to export from this module - VariablesToExport = '' - - # Aliases to export from this module - AliasesToExport = '' + FunctionsToExport = @( + 'Clear-VMGuestConfiguration' + 'Get-VMGuestAction' + 'Get-VMGuestConfiguration' + 'Import-VMGuestConfiguration' + 'Invoke-VMGuestConfiguration' + 'Register-VMGuestAction' + 'Register-VMGuestConfiguration' + 'Test-VMGuestConfiguration' + 'Unregister-VMGuestAction' + ) # List of all modules packaged with this module ModuleList = @() diff --git a/VMDeploy.Guest/en-us/strings.psd1 b/VMDeploy.Guest/en-us/strings.psd1 index 7014c33..2879911 100644 --- a/VMDeploy.Guest/en-us/strings.psd1 +++ b/VMDeploy.Guest/en-us/strings.psd1 @@ -1,5 +1,20 @@ # This is where the strings go, that are written by # Write-PSFMessage, Stop-PSFFunction or the PSFramework validation scriptblocks @{ - 'key' = 'Value' + 'Import-VMGuestConfiguration.Config.Import' = 'Importing configuration from {0} : {1} ({2})' # $fileObject.FullName, $datumHash.Identity, $datumHash.Action + 'Import-VMGuestConfiguration.File.AccessError' = 'Failed to access {0}' # $pathItem + 'Import-VMGuestConfiguration.File.Processing' = 'Failed to process {0}' # $pathItem + + 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' = 'The action {1} required for {0} is missing' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.DependencyNotDone' = 'Cannot process {0} ({1}) as dependency {2} has not been completed. Skipping.' # $configuration.Identity, $configuration.Action, $dependency + 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' = 'The configuration {0} ({1}) has already completed, skipping.' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.Execute' = 'Applying configuration {0} ({1})' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.PostAction.Test' = 'Validating success of {0} ({1}). Successful: {2}' # $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] + 'Invoke-VMGuestConfiguration.Configuration.Processing' = 'Processing configuration {0} ({1})' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.Testing' = 'Testing configuration {0} ({1})' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' = 'Finished testing configuration {0} ({1}) - has been applied: {2}' # $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] + 'Invoke-VMGuestConfiguration.Finished' = 'All configuration items have been applied, deployment complete' # + 'Invoke-VMGuestConfiguration.Processing.Starting' = 'Starting application of configuration entries' # + 'Invoke-VMGuestConfiguration.Test.Completed' = 'Initial test completed.' # + 'Invoke-VMGuestConfiguration.Test.Starting' = 'Starting initial test of defined configuration entries.' # } \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 new file mode 100644 index 0000000..3cbcc88 --- /dev/null +++ b/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 @@ -0,0 +1,12 @@ +function Get-VMGuestAction { + [CmdletBinding()] + param ( + [PsfArgumentCompleter('VMDeploy.Guest.Action')] + [string] + $Name = '*' + ) + + process { + $($script:actions.Values | Where-Object Name -Like $Name) + } +} diff --git a/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 new file mode 100644 index 0000000..342fab8 --- /dev/null +++ b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 @@ -0,0 +1,45 @@ +function Register-VMGuestAction { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [ScriptBlock] + $ScriptBlock, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [scriptblock] + $Validate, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $Description, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string[]] + $ParameterMandatory = @(), + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string[]] + $ParameterOptional = @(), + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string[]] + $Tag + ) + + process { + $script:actions[$Name] = [pscustomobject]@{ + PSTypeName = 'VMDeploy.Guest.Action' + Name = $Name + ScriptBlock = $ScriptBlock + Validate = $Validate + Description = $Description + ParameterMandatory = $ParameterMandatory + ParameterOptional = $ParameterOptional + Tag = $Tag + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 new file mode 100644 index 0000000..39861f4 --- /dev/null +++ b/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 @@ -0,0 +1,15 @@ +function Unregister-VMGuestAction { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [PsfArgumentCompleter('VMDeploy.Guest.Action')] + [string[]] + $Name + ) + + process { + foreach ($nameEntry in $Name) { + $script:actions.Remove($nameEntry) + } + } +} diff --git a/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..5d8feef --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 @@ -0,0 +1,8 @@ +function Clear-VMGuestConfiguration { + [CmdletBinding()] + param () + + process { + $script:configurations.Clear() + } +} diff --git a/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..1a1eba6 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 @@ -0,0 +1,12 @@ +function Get-VMGuestConfiguration { + [CmdletBinding()] + param ( + [PsfArgumentCompleter('VMDeploy.Guest.ConfigurationItem')] + [string] + $Identity = '*' + ) + + process { + $($script:configurations.Values | Where-Object Identity -Like $Identity) + } +} diff --git a/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..91c9a5e --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 @@ -0,0 +1,52 @@ +function Import-VMGuestConfiguration { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] + [Alias('FullName')] + [string[]] + $Path, + + [switch] + $EnableException + ) + + begin { + $guestConfigParameters = (Get-Command Register-VMGuestConfiguration).Parameters.Values.Name + } + process { + foreach ($pathItem in Resolve-PSFPath -Path $Path -Provider FileSystem) { + $fileObject = Get-Item -Path $pathItem + if ($fileObject.PSIsContainer) { continue } + Write-PSFMessage -Level Verbose -String 'Import-VMGuestConfiguration.File.Processing' -StringValues $pathItem -Target $pathItem + + #region Read Config File + try { + $data = switch ($fileObject.Extension) { + '.psd1' { + Import-PSFPowerShellDataFile -Path $fileObject.FullName -ErrorAction Stop + } + default { + Get-Content -Path $fileObject.FullName -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } + } + } + catch { + Stop-PSFFunction -String 'Import-VMGuestConfiguration.File.AccessError' -StringValues $pathItem -Target $pathItem -EnableException $EnableException -Continue -ErrorRecord $_ + } + #endregion Read Config File + + #region Process/Load Configuration Entries + foreach ($datum in $data) { + $datumHash = $datum | ConvertTo-PSFHashtable -Include $guestConfigParameters + $datumHash.Source = $fileObject.BaseName + if ($datumHash.Parameters) { $datumHash.Parameters = $datumHash.Parameters | ConvertTo-PSFHashtable } + + Invoke-PSFProtectedCommand -ActionString 'Import-VMGuestConfiguration.Config.Import' -ActionStringValues $fileObject.FullName, $datumHash.Identity, $datumHash.Action -ScriptBlock { + Register-VMGuestConfiguration @datumHash -ErrorAction Stop + } -Target $datumHash -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue + } + #endregion Process/Load Configuration Entries + } + } +} diff --git a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..39506cf --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -0,0 +1,56 @@ +function Invoke-VMGuestConfiguration { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [switch] + $Restart + ) + + begin { + $currentState = @{ } + } + process { + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Starting' + foreach ($configuration in Get-VMGuestConfiguration) { + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration + } + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Completed' + + $configurations = Get-VMGuestConfiguration | Sort-Object Weight, Identity + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Processing.Starting' + foreach ($configuration in $configurations) { + Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Configuration.Processing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + if ($currentState[$configuration.Identity]) { + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + continue + } + + if (-not $script:actions[$configuration.Action]) { + Write-PSFMessage -Level Warning -String 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + continue + } + + foreach ($dependency in $configuration.DependsOn) { + if (-not $currentState[$dependency]) { + Write-PSFMessage -Level Warning -String 'Invoke-VMGuestConfiguration.Configuration.DependencyNotDone' -StringValues $configuration.Identity, $configuration.Action, $dependency -Target $configuration + continue + } + } + + Invoke-PSFProtectedCommand -ActionString 'Invoke-VMGuestConfiguration.Configuration.Execute' -ActionStringValues $configuration.Identity, $configuration.Action -ScriptBlock { + $null = $script:actions[$configuration.Action].ScriptBlock.Invoke($configuration.Parameters) + } -Target $configuration -Continue + + $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.PostAction.Test' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration + } + + if ($currentState.Values -notcontains $false) { + Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Finished' -StringValue @($configurations).Count -Tag finished + } + } + end { + if ($Restart) { Restart-Computer } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..677fb16 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 @@ -0,0 +1,44 @@ +function Register-VMGuestConfiguration +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $Identity, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [int] + $Weight = 50, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [PsfArgumentCompleter('VMDeploy.Guest.Action')] + [PsfValidateSet(TabCompletion = 'VMDeploy.Guest.Action')] + [string] + $Action, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [Hashtable] + $Parameters = @{ }, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string[]] + $DependsOn = @(), + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $Source = '' + ) + + process + { + $script:configurations[$Identity] = [PSCustomObject]@{ + PSTypeName = 'VMDeploy.Guest.ConfigurationEntry' + Identity = $Identity + Weight = $Weight + Action = $Action + Parameters = $Parameters + DependsOn = $DependsOn + Source = $Source + } + } +} diff --git a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..8608937 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 @@ -0,0 +1,60 @@ +function Test-VMGuestConfiguration { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [PsfArgumentCompleter('VMDeploy.Guest.ConfigurationItem')] + [string] + $Identity = '*', + + [switch] + $Quiet + ) + + process { + foreach ($configuration in Get-VMGuestConfiguration -Identity $Identity) { + $result = [PSCustomObject]@{ + ConfigurationObject = $configuration + PSTypeName = 'VMDeploy.Guest.Configuration.TestResult' + Identity = $configuration.Identity + Action = $configuration.Action + Success = $false + Type = 'Not Started' + Data = $null + } + + #region Action Missing + if (-not $script:actions[$configuration.Action]) { + $result.TypeNames = 'Missing Action' + if ($Quiet) { $result.Success } + else { $result } + continue + } + #endregion Action Missing + + #region Process Validation Script + try { + $validateResult = $script:actions[$configuration.Action].Validate.Invoke($configuration.Parameters) | Where-Object { + $_ -is [bool] + } | Select-Object -Last 1 + } + catch { + $result.Type = 'Error' + $result.Data = $_ + if ($Quiet) { $result.Success } + else { $result } + continue + } + + $result.Success = $validateResult + if ($validateResult) { + $result.Type = 'Success' + } + else { + $result.Type = 'Not Completed' + } + if ($Quiet) { $result.Success } + else { $result } + #endregion Process Validation Script + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/readme.md b/VMDeploy.Guest/functions/readme.md index 280e32c..80c9939 100644 --- a/VMDeploy.Guest/functions/readme.md +++ b/VMDeploy.Guest/functions/readme.md @@ -4,4 +4,22 @@ This is the folder where the functions go. Depending on the complexity of the module, it is recommended to subdivide them into subfolders. -The module will pick up all .ps1 files recursively \ No newline at end of file +The module will pick up all .ps1 files recursively + +## Planning + ++ Handle Actions +++ Get / Register / Unregister Commands +++ Include Predefined Actions +++ Action Components ++++ Execute ++++ Validate ++++ Dependencies ++++ Order ++++ Name ++++ Description ++++ Config ++ Handle Configuration +++ Import / Get / Clear / Test / Invoke ++ Logging +++ Eventlog diff --git a/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 b/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 new file mode 100644 index 0000000..4c289f0 --- /dev/null +++ b/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 @@ -0,0 +1,31 @@ +$validate = { + param ( + $Parameters + ) + + $exists = Test-Path -LiteralPath $Parameters.Path -PathType Container + if (-not $exists) { return $false } + $item = Get-Item -LiteralPath $Parameters.Path + $item.PSProvider.Name -eq 'FileSystem' +} + +$scriptblock = { + param ( + $Parameters + ) + + $exists = Test-Path -LiteralPath $Parameters.Path -PathType Container + if ($exists) { return } + $null = New-Item -Path $Parameters.Path -ItemType Directory +} + +$paramRegisterVMGuestAction = @{ + Name = 'NewFolder' + ParameterMandatory = 'Path' + Validate = $validate + ScriptBlock = $scriptblock + Description = 'Ensures a specific folder exists' + Tag = 'filesystem', 'folder' +} + +Register-VMGuestAction @paramRegisterVMGuestAction \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/initialize.ps1 b/VMDeploy.Guest/internal/scripts/initialize.ps1 new file mode 100644 index 0000000..d07a09a --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/initialize.ps1 @@ -0,0 +1,9 @@ +$paramSetPSFLoggingProvider = @{ + Name = 'eventlog' + InstanceName = 'VMDeploy.Guest' + IncludeModules = 'VMDeploy.Guest' + Source = 'VMDeploy.Guest' + LogName = 'VMDeployment' + Enabled = $true +} +Set-PSFLoggingProvider @paramSetPSFLoggingProvider \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/postimport.ps1 b/VMDeploy.Guest/internal/scripts/postimport.ps1 index 81583ec..03d3e6a 100644 --- a/VMDeploy.Guest/internal/scripts/postimport.ps1 +++ b/VMDeploy.Guest/internal/scripts/postimport.ps1 @@ -22,5 +22,14 @@ $moduleRoot = Split-Path (Split-Path $PSScriptRoot) # Load Tab Expansion Assignment "$moduleRoot\internal\tepp\assignment.ps1" +# Define module variables +"$moduleRoot\internal\scripts\variables.ps1" + +# Load up stuff +"$moduleRoot\internal\scripts\initialize.ps1" + +# Import all internally defined actions +(Get-ChildItem "$moduleRoot\internal\actions\*.ps1" -ErrorAction Ignore).FullName + # Load License "$moduleRoot\internal\scripts\license.ps1" \ No newline at end of file diff --git a/VMDeploy.Guest/internal/scripts/variables.ps1 b/VMDeploy.Guest/internal/scripts/variables.ps1 new file mode 100644 index 0000000..0952839 --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/variables.ps1 @@ -0,0 +1,5 @@ +# List of actions available when executing the Guest OS configuration +$script:actions = @{ } + +# List of configuration entries that are applied when executing Invoke-VMGuestConfiguration +$script:configurations = @{ } \ No newline at end of file diff --git a/VMDeploy.Guest/internal/tepp/action.tepp.ps1 b/VMDeploy.Guest/internal/tepp/action.tepp.ps1 new file mode 100644 index 0000000..933e543 --- /dev/null +++ b/VMDeploy.Guest/internal/tepp/action.tepp.ps1 @@ -0,0 +1,3 @@ +Register-PSFTeppScriptblock -Name 'VMDeploy.Guest.Action' -ScriptBlock { + (Get-VMGuestAction).Name +} \ No newline at end of file diff --git a/VMDeploy.Guest/internal/tepp/config.tepp.ps1 b/VMDeploy.Guest/internal/tepp/config.tepp.ps1 new file mode 100644 index 0000000..e78883f --- /dev/null +++ b/VMDeploy.Guest/internal/tepp/config.tepp.ps1 @@ -0,0 +1,3 @@ +Register-PSFTeppScriptblock -Name 'VMDeploy.Guest.ConfigurationItem' -ScriptBlock { + (Get-VMGuestConfiguration).Identity +} \ No newline at end of file diff --git a/VMDeploy.Guest/internal/tepp/example.tepp.ps1 b/VMDeploy.Guest/internal/tepp/example.tepp.ps1 deleted file mode 100644 index 7d2a091..0000000 --- a/VMDeploy.Guest/internal/tepp/example.tepp.ps1 +++ /dev/null @@ -1,4 +0,0 @@ -<# -# Example: -Register-PSFTeppScriptblock -Name "VMDeploy.Guest.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } -#> \ No newline at end of file diff --git a/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml b/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml index 3b881a0..a77483d 100644 --- a/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml +++ b/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml @@ -1,31 +1,75 @@  - - - - Foo.Bar - - Foo.Bar - - - - - - - - - - - - Foo - - - Bar - - - - - - - + + + + VMDeploy.Guest.Action + + VMDeploy.Guest.Action + + + + + + + + + + + + + + Name + + + + + $res = @() + foreach ($par in $_.ParameterMandatory) { $res += ', {0}' -f $par } + foreach ($par in $_.ParameterOptional) { $res += '[, {0}]' -f $par } + ($res -join "").Trim(",") + + + + + + + + + + + + VMDeploy.Guest.Configuration.TestResult + + VMDeploy.Guest.Configuration.TestResult + + + + + + + + + + + + + + Action + + + Identity + + + Success + + + Type + + + + + + + \ No newline at end of file From 0ce184259e51623090454a2712492a0363284428 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Thu, 8 Apr 2021 09:34:51 +0200 Subject: [PATCH 03/28] done v1 --- VMDeploy.Guest/en-us/strings.psd1 | 3 + .../functions/Actions/Get-VMGuestAction.ps1 | 18 ++++ .../Actions/Register-VMGuestAction.ps1 | 43 ++++++++++ .../Actions/Unregister-VMGuestAction.ps1 | 19 ++++ .../Clear-VMGuestConfiguration.ps1 | 12 +++ .../Get-VMGuestConfiguration.ps1 | 16 ++++ .../Import-VMGuestConfiguration.ps1 | 30 ++++++- .../Invoke-VMGuestConfiguration.ps1 | 60 ++++++++++++- .../Register-VMGuestConfiguration.ps1 | 86 +++++++++++++++++-- .../Test-VMGuestConfiguration.ps1 | 19 ++++ VMDeploy.Guest/functions/readme.md | 18 ---- .../internal/actions/NewFolder.action.ps1 | 7 +- .../internal/configurations/configuration.ps1 | 5 +- .../tests/general/strings.Exceptions.ps1 | 6 +- .../tests/general/strings.Tests.ps1 | 3 +- 15 files changed, 313 insertions(+), 32 deletions(-) diff --git a/VMDeploy.Guest/en-us/strings.psd1 b/VMDeploy.Guest/en-us/strings.psd1 index 2879911..dbf14c1 100644 --- a/VMDeploy.Guest/en-us/strings.psd1 +++ b/VMDeploy.Guest/en-us/strings.psd1 @@ -5,6 +5,7 @@ 'Import-VMGuestConfiguration.File.AccessError' = 'Failed to access {0}' # $pathItem 'Import-VMGuestConfiguration.File.Processing' = 'Failed to process {0}' # $pathItem + 'Invoke-VMGuestConfiguration.InvokeCount.Exceeded' = 'Maximum number of guest configuration executions exceeded: {0} / {1}' # $currentInvokeCount, $MaxInvokeCount 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' = 'The action {1} required for {0} is missing' # $configuration.Identity, $configuration.Action 'Invoke-VMGuestConfiguration.Configuration.DependencyNotDone' = 'Cannot process {0} ({1}) as dependency {2} has not been completed. Skipping.' # $configuration.Identity, $configuration.Action, $dependency 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' = 'The configuration {0} ({1}) has already completed, skipping.' # $configuration.Identity, $configuration.Action @@ -17,4 +18,6 @@ 'Invoke-VMGuestConfiguration.Processing.Starting' = 'Starting application of configuration entries' # 'Invoke-VMGuestConfiguration.Test.Completed' = 'Initial test completed.' # 'Invoke-VMGuestConfiguration.Test.Starting' = 'Starting initial test of defined configuration entries.' # + + 'Register-VMGuestConfiguration.BadParameters' = 'Error defining configuration {0} ({1}) - bad parameters. Missing required: {2} | Unknown parameters: {3}' # $Identity, $Action, ($parameterResult.MandatoryMissing -join ','), ($parameterResult.UnknownParameters -join ',') } \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 index 3cbcc88..a640eaa 100644 --- a/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 +++ b/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 @@ -1,4 +1,22 @@ function Get-VMGuestAction { +<# + .SYNOPSIS + List the available actions. + + .DESCRIPTION + List the available actions. + Use Register-VMGuestAction to define new actions. + Actions implement the logic available to guest configuration entries. + + .PARAMETER Name + Name of the action to filter by. + Defaults to '*' + + .EXAMPLE + PS C:\> Get-VMGuestAction + + List all available actions. +#> [CmdletBinding()] param ( [PsfArgumentCompleter('VMDeploy.Guest.Action')] diff --git a/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 index 342fab8..590ef66 100644 --- a/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 +++ b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 @@ -1,4 +1,47 @@ function Register-VMGuestAction { +<# + .SYNOPSIS + Register a new guest action. + + .DESCRIPTION + Register a new guest action. + Actions implement the logic available to guest configuration entries. + + .PARAMETER Name + The name of the action to register. + Action-Names must be unique, if you define an action that already exists, you will overwrite the previous implementation. + + .PARAMETER ScriptBlock + Code that is executed to implement the action. + Should generally not throw exceptions. + Receives a single argument/parameter: A hashtable containing any parameters. + + .PARAMETER Validate + Code that is executed to validate, whether the action has been executed. + Should generally not throw exceptions. + This scriptblock should ever only return a single boolean value, all other data will be ignored. + Receives a single argument/parameter: A hashtable containing any parameters. + + .PARAMETER Description + A description text, explaining how the action works. + Use this as a summary, orientation help and manual for using the action. + + .PARAMETER ParameterMandatory + List of parameters that MUST be specified when defining an action configuration. + + .PARAMETER ParameterOptional + List of parameters that can be optionally added when defining an action configuration. + + .PARAMETER Tag + Any tags to add to an action. + Tags are fully optional and are only used for keeping track of actions when defining too many. + + .EXAMPLE + PS C:\> Register-VMGuestAction -Name 'NewFolder' -ScriptBlock $ScriptBlock -Validate $Validate -Description 'Creates a new folder' -ParameterMandatory Path + + Register a new action named "NewFolder", providing execution logic, validation logic and a speaking description. + Configuration entries using it must specify a Path parameter. +#> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] diff --git a/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 index 39861f4..8bd689e 100644 --- a/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 +++ b/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 @@ -1,4 +1,23 @@ function Unregister-VMGuestAction { +<# + .SYNOPSIS + Removes a registered guest action. + + .DESCRIPTION + Removes a registered guest action. + Actions implement the logic available to guest configuration entries. + Removing an action will break all configuration entries using it. + Actions can be updated/overwritten without needing to actually remove it. + This command is intended primarily as a way to clear the current state when starting a new test/debug run with externally defined actions. + + .PARAMETER Name + Name of the action to remove. + + .EXAMPLE + PS C:\> Get-VMGuestAction | Unregister-VMGuestAction + + Clear all configured guest actions. +#> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] diff --git a/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 index 5d8feef..2d66b6d 100644 --- a/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 @@ -1,4 +1,16 @@ function Clear-VMGuestConfiguration { +<# + .SYNOPSIS + Remove all defined guest configuration entries. + + .DESCRIPTION + Remove all defined guest configuration entries. + + .EXAMPLE + PS C:\> Clear-VMGuestConfiguration + + Removes all defined guest configuration entries. +#> [CmdletBinding()] param () diff --git a/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 index 1a1eba6..44d4468 100644 --- a/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 @@ -1,4 +1,20 @@ function Get-VMGuestConfiguration { +<# + .SYNOPSIS + List defined guest configuration entries. + + .DESCRIPTION + List defined guest configuration entries. + + .PARAMETER Identity + The identity / name of the guest configuration entry to retrieve. + Defaults to '*' + + .EXAMPLE + PS C:\> Get-VMGuestConfiguration + + List all defined guest configuration entries. +#> [CmdletBinding()] param ( [PsfArgumentCompleter('VMDeploy.Guest.ConfigurationItem')] diff --git a/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 index 91c9a5e..725493c 100644 --- a/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 @@ -1,4 +1,32 @@ function Import-VMGuestConfiguration { +<# + .SYNOPSIS + Imports configuration files defining guest configuration entries. + + .DESCRIPTION + Imports configuration files defining guest configuration entries. + The files imported can be either json or psd1 format. + They must contain an array of entries, each entry matching the parameters of Register-VMGuestConfiguration. + + .PARAMETER Path + Path to the file(s) to load. + Wildcard supported. + + .PARAMETER EnableException + This parameters disables user-friendly warnings and enables the throwing of exceptions. + This is less user friendly, but allows catching exceptions in calling scripts. + + .PARAMETER Confirm + If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. + + .PARAMETER WhatIf + If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. + + .EXAMPLE + PS C:\> Import-VMGuestConfiguration -Path '.\config.json' + + Loads the file "config.json" from the current folder as configuration. +#> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] @@ -43,7 +71,7 @@ if ($datumHash.Parameters) { $datumHash.Parameters = $datumHash.Parameters | ConvertTo-PSFHashtable } Invoke-PSFProtectedCommand -ActionString 'Import-VMGuestConfiguration.Config.Import' -ActionStringValues $fileObject.FullName, $datumHash.Identity, $datumHash.Action -ScriptBlock { - Register-VMGuestConfiguration @datumHash -ErrorAction Stop + Register-VMGuestConfiguration @datumHash -ErrorAction Stop -EnableException } -Target $datumHash -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } #endregion Process/Load Configuration Entries diff --git a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 index 39506cf..f40e07d 100644 --- a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -1,14 +1,63 @@ function Invoke-VMGuestConfiguration { +<# + .SYNOPSIS + Apply the currently loaded configuration to the current computer. + + .DESCRIPTION + Apply the currently loaded configuration to the current computer. + Will respect the order of each entry defined by its weight. + Each configuration entry will only be executed if the prerequisites are met. + + .PARAMETER Restart + Restart the computer when done processing guest configuration entries. + + .PARAMETER MaxInvokeCount + The maximum number of times this command can run on the current machine. + Defaults to the configuration setting VMDeploy.Guest.Invoke.MaxRetryCount, which defaults to "10". + This is to prevent infinite reboot loops during VM deployment. + To skip this test, use the -Force parameter. + + .PARAMETER Force + Ignore retry count limits on calling Invoke-VMGuestConfiguration. + + .PARAMETER Confirm + If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. + + .PARAMETER WhatIf + If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. + + .EXAMPLE + PS C:\> Invoke-VMGuestConfiguration + + Apply the currently loaded configuration to the current computer. +#> [CmdletBinding(SupportsShouldProcess = $true)] param ( [switch] - $Restart + $Restart, + + [int] + $MaxInvokeCount = (Get-PSFConfigValue -FullName 'VMDeploy.Guest.Invoke.MaxRetryCount'), + + [switch] + $Force ) begin { $currentState = @{ } + Import-PSFConfig -ModuleName 'VMDeploy.Guest' -ModuleVersion 1 + $currentInvokeCount = Get-PSFConfigValue -FullName 'VMDeploy.Guest.Invoke.CurrentRetryCount' + + $die = $false + if (-not $Force -and $currentInvokeCount -ge $MaxInvokeCount) { + Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.InvokeCount.Exceeded' -StringValues $currentInvokeCount, $MaxInvokeCount -Tag interrupt + $die = $true + } } process { + if ($die) { return } + + #region Gather configuration entry state before processing Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Starting' foreach ($configuration in Get-VMGuestConfiguration) { Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration @@ -16,10 +65,12 @@ Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration } Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Completed' + #endregion Gather configuration entry state before processing $configurations = Get-VMGuestConfiguration | Sort-Object Weight, Identity Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Processing.Starting' foreach ($configuration in $configurations) { + #region Check Prerequisites Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Configuration.Processing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration if ($currentState[$configuration.Identity]) { Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' -StringValues $configuration.Identity, $configuration.Action -Target $configuration @@ -37,13 +88,16 @@ continue } } + #endregion Check Prerequisites + #region Implement Invoke-PSFProtectedCommand -ActionString 'Invoke-VMGuestConfiguration.Configuration.Execute' -ActionStringValues $configuration.Identity, $configuration.Action -ScriptBlock { $null = $script:actions[$configuration.Action].ScriptBlock.Invoke($configuration.Parameters) } -Target $configuration -Continue $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.PostAction.Test' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration + #endregion Implement } if ($currentState.Values -notcontains $false) { @@ -51,6 +105,10 @@ } } end { + if ($die) { return } + + Set-PSFConfig -FullName 'VMDeploy.Guest.Invoke.CurrentRetryCount' -Value ($currentInvokeCount + 1) + Export-PSFConfig -ModuleName 'VMDeploy.Guest' -ModuleVersion 1 if ($Restart) { Restart-Computer } } } \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 index 677fb16..89838a9 100644 --- a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 @@ -1,5 +1,49 @@ -function Register-VMGuestConfiguration -{ +function Register-VMGuestConfiguration { +<# + .SYNOPSIS + Register a new guest configuration entry. + + .DESCRIPTION + Register a new guest configuration entry. + These are used to define the desired state of the target machine. + Each configuration entry references the action that implements it: + - Ensure the action exists (use "Get-VMGuestAction" to verify if in doubt) + - Each action might require parameters. Check the required parameters and rovide at least all the mandatory parameters. + + .PARAMETER Identity + Name or ID of the configuration entry. + This will be used in all the logs, so make sure it is a useful label. + + .PARAMETER Weight + The weight of a configuration entry governs its processing order. + The lower the number, the sooner it will be applied. + Defaults to "50" + + .PARAMETER Action + Name of the action that implements the configuration entry. + + .PARAMETER Parameters + Parameters to provide to the action when executing or validating. + + .PARAMETER DependsOn + Other configuration entries that must have been applied before this configuration entry can be executed. + For example, imagine a configuration entry that wants to ensure all updates provided via SCCM have been installed: + That example configuration could not possibly succeed before the SCCM client has been installed and configured. + + .PARAMETER Source + Documentation only. + Where a given configuration entry comes from. + Used to track the source in the logging, but has no technical impact on processing. + + .PARAMETER EnableException + This parameters disables user-friendly warnings and enables the throwing of exceptions. + This is less user friendly, but allows catching exceptions in calling scripts. + + .EXAMPLE + PS C:\> Register-VMGuestConfiguration -Identity 'Folder_Scripts' -Action 'NewFolder' -Parameters @{ Path = 'C:\Scripts' } + + Defines a new configuration entry, ensuring a scripts folder gets created if missing. +#> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] @@ -26,11 +70,43 @@ [Parameter(ValueFromPipelineByPropertyName = $true)] [string] - $Source = '' + $Source = '', + + [switch] + $EnableException ) - process - { + begin { + #region Utility Functions + function Test-ActionParameter { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Action, + + [Parameter(Mandatory = $true)] + [System.Collections.Hashtable] + $Parameters + ) + + $actionObject = $script:actions[$Action] + $result = [pscustomobject]@{ + Success = $true + MandatoryMissing = $actionObject.ParameterMandatory | Where-Object { $_ -notin $Parameters.Keys } + UnknownParameters = $Parameters.Keys | Where-Object { $_ -notin $actionObject.ParameterMandatory -and $actionObject.ParameterOptional } + } + if ($result.MandatoryMissing -or $result.UnknownParameters) { $result.Success = $false } + $result + } + #endregion Utility Functions + } + process { + $parameterResult = Test-ActionParameter -Action $Action -Parameters $Parameters + if (-not $parameterResult.Success) { + Stop-PSFFunction -String 'Register-VMGuestConfiguration.BadParameters' -StringValues $Identity, $Action, ($parameterResult.MandatoryMissing -join ','), ($parameterResult.UnknownParameters -join ',') -EnableException $EnableException + return + } $script:configurations[$Identity] = [PSCustomObject]@{ PSTypeName = 'VMDeploy.Guest.ConfigurationEntry' Identity = $Identity diff --git a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 index 8608937..496dc22 100644 --- a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 @@ -1,4 +1,23 @@ function Test-VMGuestConfiguration { +<# + .SYNOPSIS + Tests whether a given configuration entry has been successfully applied. + + .DESCRIPTION + Tests whether a given configuration entry has been successfully applied. + + .PARAMETER Identity + The identity of the configuration entry to test. + Defaults to '*' + + .PARAMETER Quiet + Do not return a result object, instead only return $true or $false + + .EXAMPLE + PS C:\> Test-VMGuestConfiguration + + Tests for each defined configuration entry, whether it has been successfully applied. +#> [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] diff --git a/VMDeploy.Guest/functions/readme.md b/VMDeploy.Guest/functions/readme.md index 80c9939..ad3721f 100644 --- a/VMDeploy.Guest/functions/readme.md +++ b/VMDeploy.Guest/functions/readme.md @@ -5,21 +5,3 @@ This is the folder where the functions go. Depending on the complexity of the module, it is recommended to subdivide them into subfolders. The module will pick up all .ps1 files recursively - -## Planning - -+ Handle Actions -++ Get / Register / Unregister Commands -++ Include Predefined Actions -++ Action Components -+++ Execute -+++ Validate -+++ Dependencies -+++ Order -+++ Name -+++ Description -+++ Config -+ Handle Configuration -++ Import / Get / Clear / Test / Invoke -+ Logging -++ Eventlog diff --git a/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 b/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 index 4c289f0..e57c541 100644 --- a/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 +++ b/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 @@ -5,7 +5,7 @@ $exists = Test-Path -LiteralPath $Parameters.Path -PathType Container if (-not $exists) { return $false } - $item = Get-Item -LiteralPath $Parameters.Path + $item = Get-Item -LiteralPath $Parameters.Path -Force $item.PSProvider.Name -eq 'FileSystem' } @@ -14,9 +14,8 @@ $scriptblock = { $Parameters ) - $exists = Test-Path -LiteralPath $Parameters.Path -PathType Container - if ($exists) { return } - $null = New-Item -Path $Parameters.Path -ItemType Directory + if (Test-Path -LiteralPath $Parameters.Path -PathType Container) { return } + $null = New-Item -Path $Parameters.Path -ItemType Directory -Force -ErrorAction Stop } $paramRegisterVMGuestAction = @{ diff --git a/VMDeploy.Guest/internal/configurations/configuration.ps1 b/VMDeploy.Guest/internal/configurations/configuration.ps1 index dd71b45..28770da 100644 --- a/VMDeploy.Guest/internal/configurations/configuration.ps1 +++ b/VMDeploy.Guest/internal/configurations/configuration.ps1 @@ -12,4 +12,7 @@ Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Example.Setting' -Value 10 -Initia #> Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." -Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." \ No newline at end of file +Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." + +Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Invoke.MaxRetryCount' -Value 10 -Initialize -Validation 'integer' -Description 'The maximum number of attempts to invoke configuration Invoke-VMGuestConfiguration is willing to attempt before giving up.' +Set-PSFConfig -Module 'VMDeploy.Guest' -Name 'Invoke.CurrentRetryCount' -Value 0 -Initialize -Validation 'integer' -ModuleExport -Description 'How many times we have already tried invoking configuration using Invoke-VMGuestConfiguration.' \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 b/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 index 0a11c98..ff9fb18 100644 --- a/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 +++ b/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 @@ -13,7 +13,11 @@ $exceptions['LegalSurplus'] = @( ) #> $exceptions['LegalSurplus'] = @( - + +) +$exceptions['IgnoreString'] = @( + 'key' # Template default key + 'Validate.FSPath.File' ) $exceptions \ No newline at end of file diff --git a/VMDeploy.Guest/tests/general/strings.Tests.ps1 b/VMDeploy.Guest/tests/general/strings.Tests.ps1 index 910224a..73cc8e2 100644 --- a/VMDeploy.Guest/tests/general/strings.Tests.ps1 +++ b/VMDeploy.Guest/tests/general/strings.Tests.ps1 @@ -14,7 +14,8 @@ Describe "Testing localization strings" { $exceptions = & "$global:testroot\general\strings.Exceptions.ps1" foreach ($stringEntry in $stringsResults) { - if ($stringEntry.String -eq "key") { continue } # Skipping the template default entry + if ($stringEntry.String -in $exceptions.IgnoreString) { continue } + It "Should be used & have text: $($stringEntry.String)" -TestCases @{ stringEntry = $stringEntry } { if ($exceptions.LegalSurplus -notcontains $stringEntry.String) { $stringEntry.Surplus | Should -BeFalse From 17943d2fc2c8d3314cc9335bb42eb583f68d30a6 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Thu, 8 Apr 2021 11:00:05 +0200 Subject: [PATCH 04/28] creating deployment script for inclusion in Guest OS image --- scripts/vmdeploy.ps1 | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 scripts/vmdeploy.ps1 diff --git a/scripts/vmdeploy.ps1 b/scripts/vmdeploy.ps1 new file mode 100644 index 0000000..436a7ca --- /dev/null +++ b/scripts/vmdeploy.ps1 @@ -0,0 +1,82 @@ + +<# + .SYNOPSIS + Launcher script that should be added to the OS image as a scheduled task run on system boot. + + .DESCRIPTION + Launcher script that should be added to the OS image as a scheduled task run on system boot. + Note: Should probably be run as System with maximum privileges for most OS tasks. + + This script will search all available volumes for a guest configuration package. + It expects the following folder structure within that volume at the root level: + + Modules\* + Actions\* + Config\* + + Additional folders are ignored by this script. + It assumes that the modules folder contains all PowerShell modules needed for the VM Deployment's Guest Configuration workflow. + The folder will be added to PSModulePath with max priority for this workflow only. + The modules PSFramework and VMDeploy.Guest must be included in this folder at a minimum. + + The "Actions" folder is designed for additional, customer specific, non-public VMGuest Actions. + You can define your own actions using Register-VMGuestAction. + Actions are the actual implementation logic performing to Guest configuration steps. + + The "Config" folder is for the actual configuration files that define the intended post-deployment state. + + Running this script will have it register the volume root as a PSDrive named VMDeploy. + It will also set this path as the VMDeploy PSFPath, which will be available for path insertion in builtin actions. + This allows you for example to add another folder - let's call it "Install" - for your installation media and + then specify the path during a configuration for SCCM client install as "%VMDeploy%Install\sccm.client.setup.exe" + + Note on volumes: + - The volume need not have a drive letter for being detected + - On PowerShell 7, a bug requires the volume to have a driveletter, but this task is designed with Windows PowerShell in mind anyway. + + .EXAMPLE + PS C:\> .\vmdeploy.ps1 +#> +[CmdletBinding()] +param () + +#region Detect VMDeploy Volume +$volumes = Get-Volume +$volumeRoot = foreach ($volume in $volumes) { + if (-not (Test-Path -LiteralPath "$($volume.Path)Modules\VMDeploy.Guest")) { continue } + + if ($volume.DriveLetter) { + '{0}:\' -f $volume.DriveLetter + break + } + $volume.Path + break +} +#endregion Detect VMDeploy Volume + +#region Apply & Prepare paths for convenient use from PowerShell +if (Get-PSDrive -Name VMDeploy -ErrorAction Ignore) { Remove-PSDrive -Name VMDeploy } +$null = New-PSDrive -Name VMDeploy -PSProvider FileSystem -Root $volumeRoot -Scope Global + +$env:PSModulePath = "$($volumeRoot)Modules", $env:PSModulePath -join ";" +Set-PSFPath -Name VMDeploy -Path $volumeRoot +#endregion Apply & Prepare paths for convenient use from PowerShell + +# Load additional Action files +foreach ($file in Get-ChildItem -Path "$($volumeRoot)Actions\*.ps1" -ErrorAction Ignore) { + & $file.FullName +} +# Load configuration files for the current client +Import-VMGuestConfiguration -Path "$($volumeRoot)Config\*" + +# If Configuration successfull: Kill task as no longer needed +$testResults = Test-VMGuestConfiguration +if ($testResults.Success -notcontains $false) { + Invoke-PSFProtectedCommand -Action "VM Guest Configuration completed, cleaning up task" -Target "Scheduled Task" -ScriptBlock { + Unregister-ScheduledTask -TaskName VMDeployGuestConfig -ErrorAction Stop + } -EnableException $true -ErrorAction Stop + return +} + +# Execute Guest Config +Invoke-VMGuestConfiguration -Restart \ No newline at end of file From 7bcfe5055bb8162b91b66e81b609587ff0833d5f Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Fri, 22 Oct 2021 11:27:39 +0200 Subject: [PATCH 05/28] updates --- Actions/disk.action.ps1 | 72 ++++++++++++ Actions/network.action.ps1 | 94 ++++++++++++++++ VMDeploy.Guest/VMDeploy.Guest.psd1 | 2 +- VMDeploy.Guest/VMDeploy.Guest.psm1 | 2 +- .../Actions/Register-VMGuestAction.ps1 | 9 ++ .../Get-VMGuestConfiguration.ps1 | 2 +- .../Import-VMGuestConfiguration.ps1 | 2 +- .../Invoke-VMGuestConfiguration.ps1 | 2 +- .../Register-VMGuestConfiguration.ps1 | 4 +- scripts/vmdeploy.ps1 | 103 +++++++++++++++--- 10 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 Actions/disk.action.ps1 create mode 100644 Actions/network.action.ps1 diff --git a/Actions/disk.action.ps1 b/Actions/disk.action.ps1 new file mode 100644 index 0000000..e14ee0c --- /dev/null +++ b/Actions/disk.action.ps1 @@ -0,0 +1,72 @@ +$executionCode = { + param ( + $Configuration + ) + + $disk = Get-Disk | Where-Object Location -Match "LUN $($Configuration.Lun)$" + if (-not $disk) { + Write-PSFMessage -Level Warning -Message 'Error configuring volume {1} (LUN ID {0}): Disk not found, validate VM deployment!' -StringValues $Configuration.Lun, $Configuration.Letter -Data $Configuration -Target $Configuration.Letter + return + } + + if ($disk.IsOffline) { + $disk | Set-Disk -IsOffline $false + Start-Sleep -Seconds 1 + } + + $volume = $disk | Get-Partition | Get-Volume + if (-not $volume) { + $disk | Initialize-Disk -ErrorAction Ignore + $partition = $disk | New-Partition -UseMaximumSize + $volume = $partition | Get-Volume | Format-Volume -FileSystem NTFS + } + $letterOccupyingVolume = Get-Volume -DriveLetter $Configuration.Letter -ErrorAction Ignore + if ($letterOccupyingVolume -and $letterOccupyingVolume.UniqueId -ne $volume.UniqueId) { + $null = "SELECT VOLUME $($Configuration.Letter)", "REMOVE LETTER $($Configuration.Letter)" | diskpart + } + if ($volume.DriveLetter -ne $Configuration.Letter) { + $volume | Get-Partition | Set-Partition -NewDriveLetter $Configuration.Letter -ErrorAction Stop + } + + if ($Configuration.Label -and $Configuration.Label -ne $volume.FileSystemLabel) { + $volume | Set-Volume -NewFileSystemLabel $Configuration.Label -ErrorAction Stop + } +} + +$validationCode = { + param ( + $Configuration + ) + + $disk = Get-Disk | Where-Object Location -Match "LUN $($Configuration.Lun)$" + if (-not $disk) { + Write-PSFMessage -Level Warning -Message 'Error configuring volume {1} (LUN ID {0}): Disk not found, validate VM deployment!' -StringValues $Configuration.Lun, $Configuration.Letter -Data $Configuration -Target $Configuration.Letter + return $false + } + + if ($disk.IsOffline) { return $false } + + $volume = $disk | Get-Partition | Get-Volume | Where-Object DriveLetter -EQ $Configuration.Letter + if (-not $volume) { return $false } + if ($Configuration.Label -and $Configuration.Label -ne $volume.FileSystemLabel) { + return $false + } + $true +} + +$param = @{ + Name = 'disk' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Configure a disk' + ParameterMandatory = @( + 'Lun' + 'Letter' + ) + ParameterOptional = @( + 'Label' + 'Size' + ) + Tag = 'volume', 'disk' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/network.action.ps1 b/Actions/network.action.ps1 new file mode 100644 index 0000000..b4bc0b0 --- /dev/null +++ b/Actions/network.action.ps1 @@ -0,0 +1,94 @@ +$executionCode = { + param ( + $Configuration + ) + + $netConfigs = Get-NetIPConfiguration + $netConfig = $netConfigs | Where-Object InterfaceAlias -EQ Ethernet | Select-Object -First 1 + if (-not $netConfig) { $netConfig = $netConfigs | Where-Object InterfaceAlias -NotLike 'Loopback Pseudo-Interface*' | Select-Object -First 1 } + + if ($Configuration.IPAddress -and $netConfig.IPv4Address.IPAddress -notcontains $Configuration.IPAddress) { + foreach ($address in $netConfig.IPv4Address) { Remove-NetIPAddress -IPAddress $address -InterfaceIndex $netConfig.InterfaceIndex -Confirm:$false } + $param = @{ + IPAddress = $Configuration.IPAddress + InterfaceIndex = $netConfig.InterfaceIndex + AddressFamily = 'IPv4' + PrefixLength = 24 + Confirm = $false + } + if ($Configuration.PrefixLength) { $param.PrefixLength = $Configuration.PrefixLength } + New-NetIPAddress @param + } + elseif ($Configuration.PrefixLength) { + $ipAddress = $netConfig.IPv4Address[0] + if ($Configuration.IPAddress) { $ipAddress = $netConfig.IPv4Address | Where-Object IPAddress -EQ $Configuration.IPAddress } + if ($ipAddress.PrefixLength -ne $Configuration.PrefixLength) { + Set-NetIPAddress -IPAddress $ipAddress -InterfaceIndex 5 -PrefixLength $Configuration.PrefixLength -Confirm:$false + } + } + if ($Configuration.DefaultGateway) { + if ($netConfig.IPv4DefaultGateway.NextHop -notcontains $Configuration.DefaultGateway) { + if (-not $netConfig.IPv4DefaultGateway) { + New-NetRoute -InterfaceIndex $netConfig.InterfaceIndex -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 -NextHop $Configuration.DefaultGateway + } + else { + Set-NetRoute -InterfaceIndex $netConfig.InterfaceIndex -DestinationPrefix '0.0.0.0/0' -NextHop $Configuration.DefaultGateway + } + } + } + if ($Configuration.DnsServer) { + Set-DnsClientServerAddress -InterfaceIndex $netConfig.InterfaceIndex -ServerAddresses $Configuration.DnsServer + } +} + +$validationCode = { + param ( + $Configuration + ) + + $netConfigs = Get-NetIPConfiguration + $netConfig = $netConfigs | Where-Object InterfaceAlias -EQ Ethernet | Select-Object -First 1 + if (-not $netConfig) { $netConfig = $netConfigs | Where-Object InterfaceAlias -NotLike 'Loopback Pseudo-Interface*' | Select-Object -First 1 } + + if ($Configuration.IPAddress -and $netConfig.IPv4Address.IPAddress -notcontains $Configuration.IPAddress) { + return $false + } + if ($Configuration.PrefixLength) { + $ipAddress = $netConfig.IPv4Address[0] + if ($Configuration.IPAddress) { $ipAddress = $netConfig.IPv4Address | Where-Object IPAddress -EQ $Configuration.IPAddress } + if ($ipAddress.PrefixLength -ne $Configuration.PrefixLength) { return $false } + } + if ($Configuration.DefaultGateway) { + if ($netConfig.IPv4DefaultGateway.NextHop -notcontains $Configuration.DefaultGateway) { + return $false + } + } + if ($Configuration.DnsServer) { + $actualServers = $netConfig.DNSServer.Where{ $_.AddressFamily -eq 2 }.ServerAddresses + foreach ($server in $Configuration.DnsServer) { + if ($server -notin $actualServers) { return $false } + } + foreach ($server in $actualServers) { + if ($server -notin $Configuration.DnsServer) { return $false } + } + } + + $true +} + +$param = @{ + Name = 'network' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Implement client network configuration' + ParameterMandatory = @( + ) + ParameterOptional = @( + 'IPAddress' + 'DefaultGateway' + 'DnsServer' + 'SubnetMask' + ) + Tag = 'network' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/VMDeploy.Guest/VMDeploy.Guest.psd1 b/VMDeploy.Guest/VMDeploy.Guest.psd1 index c80aab0..947e4bc 100644 --- a/VMDeploy.Guest/VMDeploy.Guest.psd1 +++ b/VMDeploy.Guest/VMDeploy.Guest.psd1 @@ -26,7 +26,7 @@ # Modules that must be imported into the global environment prior to importing # this module RequiredModules = @( - @{ ModuleName='PSFramework'; ModuleVersion='1.6.195' } + @{ ModuleName='PSFramework'; ModuleVersion='1.6.201' } ) # Assemblies that must be loaded prior to importing this module diff --git a/VMDeploy.Guest/VMDeploy.Guest.psm1 b/VMDeploy.Guest/VMDeploy.Guest.psm1 index 82f630f..6514aef 100644 --- a/VMDeploy.Guest/VMDeploy.Guest.psm1 +++ b/VMDeploy.Guest/VMDeploy.Guest.psm1 @@ -1,5 +1,5 @@ $script:ModuleRoot = $PSScriptRoot -$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\VMDeploy.Guest.psd1").ModuleVersion +$script:ModuleVersion = (Import-PSFPowerShellDataFile -Path "$($script:ModuleRoot)\VMDeploy.Guest.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName VMDeploy.Guest.Import.DoDotSource -Fallback $false diff --git a/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 index 590ef66..e52ed83 100644 --- a/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 +++ b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 @@ -25,6 +25,10 @@ .PARAMETER Description A description text, explaining how the action works. Use this as a summary, orientation help and manual for using the action. + + .PARAMETER PreDeploymentCode + Scriptblock that is executed within the VMDeployment JEA endpoint before the Virtual Machine creation is triggered. + This allows executing code in the context of the JEA gMSA and dynamically preparing resources to be included in the deployment. .PARAMETER ParameterMandatory List of parameters that MUST be specified when defining an action configuration. @@ -59,6 +63,10 @@ [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Description, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [scriptblock] + $PreDeploymentCode, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] @@ -80,6 +88,7 @@ ScriptBlock = $ScriptBlock Validate = $Validate Description = $Description + PreDeploymentCode = $PreDeploymentCode ParameterMandatory = $ParameterMandatory ParameterOptional = $ParameterOptional Tag = $Tag diff --git a/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 index 44d4468..83fad84 100644 --- a/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 @@ -25,4 +25,4 @@ process { $($script:configurations.Values | Where-Object Identity -Like $Identity) } -} +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 index 725493c..7c22673 100644 --- a/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 @@ -77,4 +77,4 @@ #endregion Process/Load Configuration Entries } } -} +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 index f40e07d..28c07ae 100644 --- a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -109,6 +109,6 @@ Set-PSFConfig -FullName 'VMDeploy.Guest.Invoke.CurrentRetryCount' -Value ($currentInvokeCount + 1) Export-PSFConfig -ModuleName 'VMDeploy.Guest' -ModuleVersion 1 - if ($Restart) { Restart-Computer } + if ($Restart) { Restart-Computer -Confirm:$false -Force } } } \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 index 89838a9..3b7dcb4 100644 --- a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 @@ -94,7 +94,7 @@ $result = [pscustomobject]@{ Success = $true MandatoryMissing = $actionObject.ParameterMandatory | Where-Object { $_ -notin $Parameters.Keys } - UnknownParameters = $Parameters.Keys | Where-Object { $_ -notin $actionObject.ParameterMandatory -and $actionObject.ParameterOptional } + UnknownParameters = $Parameters.Keys | Where-Object { $_ -notin $actionObject.ParameterMandatory -and $_ -notin $actionObject.ParameterOptional } } if ($result.MandatoryMissing -or $result.UnknownParameters) { $result.Success = $false } $result @@ -117,4 +117,4 @@ Source = $Source } } -} +} \ No newline at end of file diff --git a/scripts/vmdeploy.ps1 b/scripts/vmdeploy.ps1 index 436a7ca..133319f 100644 --- a/scripts/vmdeploy.ps1 +++ b/scripts/vmdeploy.ps1 @@ -38,40 +38,115 @@ PS C:\> .\vmdeploy.ps1 #> [CmdletBinding()] -param () +param ( + [ValidateRange(1, 63)] + [int] + $ConfigLun = 63, + + [switch] + $EnableAllDisks +) + +#region Functions +function Write-LogEntry { + [CmdletBinding()] + param ( + [string] + $LogName, + + [string] + $Source, + + [int] + $EventID, + + [int] + $Category, + + [System.Diagnostics.EventLogEntryType] + $Type, + + [object[]] + $Data + ) + $id = New-Object System.Diagnostics.EventInstance($EventID, $Category, $Type) + $evtObject = New-Object System.Diagnostics.EventLog + $evtObject.Log = $LogName + $evtObject.Source = $Source + $evtObject.WriteEvent($id, $Data) +} + +function Get-DiskLetters { + [CmdletBinding()] + param ( + $VolumeObject + ) + + (Get-Volume).DriveLetter + + $configRoot = "$($VolumeObject.Path)Config" + foreach ($file in Get-ChildItem -Path $configRoot -Recurse -Filter *.json) { + $config = Get-Content -LiteralPath $file.FullName | ConvertFrom-Json + if ($config.Action -ne 'disk') { continue } + $config.Parameters.Letter + } +} +#endregion Functions #region Detect VMDeploy Volume +if ($EnableAllDisks) { + Get-Disk | Where-Object OperationalStatus -EQ 'Offline' | Set-Disk -IsOffline $false +} +else { + $disk = Get-Disk | Where-Object Location -Match "LUN $ConfigLun" | Where-Object OperationalStatus -EQ 'Offline' + if ($disk) { $disk | Set-Disk -IsOffline $false } +} +Start-Sleep -Seconds 1 $volumes = Get-Volume -$volumeRoot = foreach ($volume in $volumes) { +$volumeObject = foreach ($volume in $volumes) { if (-not (Test-Path -LiteralPath "$($volume.Path)Modules\VMDeploy.Guest")) { continue } - if ($volume.DriveLetter) { - '{0}:\' -f $volume.DriveLetter - break - } - $volume.Path + $volume break } + +if (-not $volumeObject) { + Write-LogEntry -LogName Application -Source Application -EventID 1 -Category 666 -Type Information -Data "No VMDeploy.Guest configuration volume detected. Assuming image used outside of the system, unregistering scheduled task" + Unregister-ScheduledTask -TaskName VMDeployGuestConfig -ErrorAction Stop + return +} + +$diskLetters = Get-DiskLetters -VolumeObject $volumeObject +foreach ($number in 122..97) { + if (([char]$number) -in $diskLetters) { continue } + $vmdeployOSConfigLetter = [char]$number + break +} +if ($volumeObject.DriveLetter) { + $null = "SELECT VOLUME $($volumeObject.DriveLetter)", "REMOVE LETTER $($volumeObject.DriveLetter)" | diskpart +} +$volumeObject | Get-Partition | Set-Partition -NewDriveLetter $vmdeployOSConfigLetter #endregion Detect VMDeploy Volume #region Apply & Prepare paths for convenient use from PowerShell if (Get-PSDrive -Name VMDeploy -ErrorAction Ignore) { Remove-PSDrive -Name VMDeploy } -$null = New-PSDrive -Name VMDeploy -PSProvider FileSystem -Root $volumeRoot -Scope Global +$null = New-PSDrive -Name VMDeploy -PSProvider FileSystem -Root "$($vmdeployOSConfigLetter):\" -Scope Global -$env:PSModulePath = "$($volumeRoot)Modules", $env:PSModulePath -join ";" -Set-PSFPath -Name VMDeploy -Path $volumeRoot +$env:PSModulePath = "$($vmdeployOSConfigLetter):\Modules", $env:PSModulePath -join ";" +Set-PSFPath -Name VMDeploy -Path "$($vmdeployOSConfigLetter):\" #endregion Apply & Prepare paths for convenient use from PowerShell # Load additional Action files -foreach ($file in Get-ChildItem -Path "$($volumeRoot)Actions\*.ps1" -ErrorAction Ignore) { +foreach ($file in Get-ChildItem -Path "$($vmdeployOSConfigLetter):\Actions\*.ps1" -ErrorAction Ignore) { & $file.FullName } # Load configuration files for the current client -Import-VMGuestConfiguration -Path "$($volumeRoot)Config\*" +Import-VMGuestConfiguration -Path "$($vmdeployOSConfigLetter):\Config\*" # If Configuration successfull: Kill task as no longer needed $testResults = Test-VMGuestConfiguration if ($testResults.Success -notcontains $false) { + $null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart Invoke-PSFProtectedCommand -Action "VM Guest Configuration completed, cleaning up task" -Target "Scheduled Task" -ScriptBlock { Unregister-ScheduledTask -TaskName VMDeployGuestConfig -ErrorAction Stop } -EnableException $true -ErrorAction Stop @@ -79,4 +154,6 @@ if ($testResults.Success -notcontains $false) { } # Execute Guest Config -Invoke-VMGuestConfiguration -Restart \ No newline at end of file +Invoke-VMGuestConfiguration -Restart + +$null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart \ No newline at end of file From c6d134830d8aef1ba14fe1e7186a717e9f4dbc43 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Fri, 22 Oct 2021 13:06:41 +0200 Subject: [PATCH 06/28] Update vmdeploy.ps1 --- scripts/vmdeploy.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/vmdeploy.ps1 b/scripts/vmdeploy.ps1 index 133319f..2e82cf7 100644 --- a/scripts/vmdeploy.ps1 +++ b/scripts/vmdeploy.ps1 @@ -146,14 +146,16 @@ Import-VMGuestConfiguration -Path "$($vmdeployOSConfigLetter):\Config\*" # If Configuration successfull: Kill task as no longer needed $testResults = Test-VMGuestConfiguration if ($testResults.Success -notcontains $false) { + $schtaskResult = schtasks /delete /TN VMDeployGuestConfig /f + Write-PSFMessage -Message "schtasks:`n$($schtaskResult -join "`n")" $null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart - Invoke-PSFProtectedCommand -Action "VM Guest Configuration completed, cleaning up task" -Target "Scheduled Task" -ScriptBlock { - Unregister-ScheduledTask -TaskName VMDeployGuestConfig -ErrorAction Stop - } -EnableException $true -ErrorAction Stop + Write-PSFMessage -Message "VMDeployment Guest Configuration Concluded" + Wait-PSFMessage return } # Execute Guest Config Invoke-VMGuestConfiguration -Restart -$null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart \ No newline at end of file +$null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart +Wait-PSFMessage \ No newline at end of file From 86637ace8fcebc23bc70f2943906525134c91c5a Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Fri, 10 Dec 2021 10:07:43 +0100 Subject: [PATCH 07/28] actions, lots of actions --- Actions/application.action.ps1 | 63 ++++++++ Actions/certificate.action.ps1 | 226 +++++++++++++++++++++++++++++ Actions/disk.action.ps1 | 4 +- Actions/domainJoin.action.ps1 | 68 +++++++++ Actions/filecopy.action.ps1 | 50 +++++++ Actions/firewall.action.ps1 | 61 ++++++++ Actions/ipsec.action.ps1 | 104 +++++++++++++ Actions/ipsec_cryptoset.action.ps1 | 73 ++++++++++ Actions/network.action.ps1 | 2 +- Actions/policy_clear.action.ps1 | 90 ++++++++++++ Actions/serverfeature.action.ps1 | 40 +++++ Actions/template.md | 42 ++++++ 12 files changed, 820 insertions(+), 3 deletions(-) create mode 100644 Actions/application.action.ps1 create mode 100644 Actions/certificate.action.ps1 create mode 100644 Actions/domainJoin.action.ps1 create mode 100644 Actions/filecopy.action.ps1 create mode 100644 Actions/firewall.action.ps1 create mode 100644 Actions/ipsec.action.ps1 create mode 100644 Actions/ipsec_cryptoset.action.ps1 create mode 100644 Actions/policy_clear.action.ps1 create mode 100644 Actions/serverfeature.action.ps1 create mode 100644 Actions/template.md diff --git a/Actions/application.action.ps1 b/Actions/application.action.ps1 new file mode 100644 index 0000000..83fbd54 --- /dev/null +++ b/Actions/application.action.ps1 @@ -0,0 +1,63 @@ +$executionCode = { + param ( + $Configuration + ) + + $param = @{ + Path = $Configuration.Path + Wait = $true + PassThru = $true + } + if ($Configuration.Arguments) { $param.ArgumentList = $Configuration.Arguments } + + $result = Start-Process @param + if ($result.ExitCode -eq 0) { + $trackingFile = Join-Path -Path 'VMDeploy:\Runtime' -ChildPath $Configuration.Name + "Success" | Set-Content -Path $trackingFile + return + } + + Write-PSFMessage -Level Warning -Message "Application '{0}' failed with exit code {1}" -StringValues $Configuration.Path, $result.ExitCode -ModuleName 'VMDeploy.Guest' -Data @{ + Path = $Configuration.Path + Arguments = $Configuration.Arguments -join " " + ExitCode = $result.ExitCode + } +} + +$validationCode = { + param ( + $Configuration + ) + + if (-not (Test-Path 'VMDeploy:\Runtime')) { + $null = New-Item -Path 'VMDeploy:\Runtime' -ItemType Directory -Force + } + + $trackingFile = Join-Path -Path 'VMDeploy:\Runtime' -ChildPath $Configuration.Name + Test-Path $trackingFile +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) +} + +$param = @{ + Name = 'application' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Execute an application' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + 'Name' + 'Path' + ) + ParameterOptional = @( + 'Arguments' + ) + Tag = 'application','generic' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/certificate.action.ps1 b/Actions/certificate.action.ps1 new file mode 100644 index 0000000..b7e851a --- /dev/null +++ b/Actions/certificate.action.ps1 @@ -0,0 +1,226 @@ +$executionCode = { + param ( + $Configuration + ) + + $param = @{ + FilePath = "VMDeploy:\Resources\__cert_$($Configuration.Name).pfx" + CertStoreLocation = 'Cert:\LocalMachine\My' + Password = ("DoesNotMatter" | ConvertTo-SecureString -AsPlainText -Force) + } + Import-PfxCertificate @param +} + +$validationCode = { + param ( + $Configuration + ) + + if (-not (Test-Path "VMDeploy:\Resources\__cert_$($Configuration.Name).pfx")) { return $false } + $certPath = (Get-Item -Path "VMDeploy:\Resources\__cert_$($Configuration.Name).pfx").FullName + $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certPath, "DoesNotMatter") + + Test-Path -Path "Cert:\LocalMachine\My\$($certObject.Thumbprint)" +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) + # Explicitly import module since auto-import does not happen on JEA endpoints + Import-Module PKI + + # Process FQDN + $fqdn = $Configuration.Fqdn + if (-not $fqdn) { $fqdn = Read-Host "Enter FQDN of target computer for certificate request" } + + #region Functions + function New-CertificateRequest { + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Template, + + [Parameter(Mandatory = $true)] + [string] + $Fqdn, + + [Parameter(Mandatory = $true)] + [string] + $WorkingDirectory + ) + + $templateData = @" +[Version] +Signature="`$Windows NT$" +[NewRequest] +Subject = "CN=$Fqdn" +Exportable = True +KeyLength = 2048 +KeySpec = 1 +KeyUsage = 0xA0 +MachineKeySet = True +ProviderName = "Microsoft RSA SChannel Cryptographic Provider" +RequestType = PKCS10 +SMIME = FALSE +[Extensions] +2.5.29.17 = "{text}" +_continue_ = "dns=$Fqdn" +[RequestAttributes] +CertificateTemplate = "$Template" +"@ + Remove-Item "$WorkingDirectory\_certReq.req" -ErrorAction Ignore + [System.IO.File]::WriteAllText("$WorkingDirectory\_certReq.inf", $templateData, [System.Text.Encoding]::ASCII) + $result = CertReq.exe -new -q "$WorkingDirectory\_certReq.inf" "$WorkingDirectory\_certReq.req" + if ($LASTEXITCODE -ne 0) { + Remove-Item "$WorkingDirectory\_certReq.inf" -ErrorAction Ignore + foreach ($line in $result) { + Write-Warning $line + } + throw "Failed to create certificate request!" + } + + Remove-Item "$WorkingDirectory\_certReq.inf" -ErrorAction Ignore + "$WorkingDirectory\_certReq.req" + } + + function Send-CertificateRequest { + #[OutputType([int])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $RequestPath, + + [Parameter(Mandatory = $true)] + [string] + $CA + ) + + $certPath = $RequestPath -replace '\.req$', '.cer' + $responsePath = $RequestPath -replace '\.req$', '.rsp' + Remove-Item $certPath -ErrorAction Ignore + Remove-Item $responsePath -ErrorAction Ignore + $result = CertReq.exe -submit -kerberos -q -config $CA $RequestPath $certPath + if ($LASTEXITCODE -ne 0) { + Remove-Item $RequestPath -ErrorAction Ignore + Remove-Item $responsePath -ErrorAction Ignore + foreach ($line in $result) { + Write-Warning $line + } + throw "Failed to submit certificate request!" + } + Remove-Item $RequestPath -ErrorAction Ignore + Remove-Item $responsePath -ErrorAction Ignore + + [PSCustomObject]@{ + Path = $certPath + RequestID = ($result | Where-Object { $_ -match '^RequestID: \d+$' }) -replace '^RequestID: (\d+)$', '$1' -as [int] + Result = $result + } + } + + function Test-CertificateRequest { + [CmdletBinding()] + param ( + [string] + $CA, + + [int] + $RequestID, + + [string] + $CertPath + ) + + Remove-Item -Path ($CertPath -replace '\.cer$', '.rsp') -ErrorAction Ignore + $result = CertReq.exe -retrieve -kerberos -q -config $CA $RequestID $CertPath + if ($LASTEXITCODE -ne 0) { + foreach ($line in $result) { + Write-Warning $line + } + throw "Failed to retrieve certificate request!" + } + Test-Path $CertPath + } + + function Receive-Certificate { + [CmdletBinding()] + param ( + [string] + $CA, + + [int] + $RequestID, + + [string] + $CertPath + ) + + if (Test-Path $CertPath) { + Remove-Item -Path ($CertPath -replace '\.cer$', '.rsp') -ErrorAction Ignore + $result = certreq -accept -q $CertPath + if ($LASTEXITCODE -ne 0) { + foreach ($line in $result) { + Write-Warning $line + } + Remove-Item -Path $CertPath -ErrorAction Ignore + throw "Failed to accept certificate!" + } + + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertPath) + $certificate.Thumbprint + $certificate.Dispose() + Remove-Item -Path $CertPath -ErrorAction Ignore + return + } + + Write-Host "Waiting for Certificate Request $($RequestID) from $($CA) being approved" + while (-not (Test-CertificateRequest -RequestID $RequestID -CA $CA -CertPath $CertPath)) { + Start-Sleep -Seconds 1 + } + Write-Host "Request approved, certificate received" + Remove-Item -Path ($CertPath -replace '\.cer$', '.rsp') -ErrorAction Ignore + $result = certreq -accept -q $CertPath + if ($LASTEXITCODE -ne 0) { + foreach ($line in $result) { + Write-Warning $line + } + Remove-Item -Path $CertPath -ErrorAction Ignore + throw "Failed to accept certificate!" + } + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertPath) + $certificate.Thumbprint + $certificate.Dispose() + Remove-Item -Path $CertPath -ErrorAction Ignore + } + #endregion Functions + + $requestPath = New-CertificateRequest -Template $Configuration.Template -Fqdn $fqdn -WorkingDirectory $WorkingDirectory + $request = Send-CertificateRequest -RequestPath $requestPath -CA $Configuration.CA + $thumbprint = Receive-Certificate -CA $Configuration.CA -RequestID $request.RequestID -CertPath $request.Path + $null = Get-Item "Cert:\LocalMachine\My\$thumbprint" | Export-PfxCertificate -FilePath "$WorkingDirectory\Resources\__cert_$($Configuration.Name).pfx" -Password ("DoesNotMatter" | ConvertTo-SecureString -AsPlainText -Force) + Remove-Item "Cert:\LocalMachine\My\$thumbprint" +} + +$param = @{ + Name = 'certificate' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Request and apply a certificate to the deployed virtual machine' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + 'CA' # FQCA - 'vmdf1dc.contoso.com\contoso-VMDF1DC-CA' + 'Template' # System Name, not Displayname + 'Name' # Name of the cert file, in case multiple cert definitions concur + ) + ParameterOptional = @( + 'Fqdn' # Prompted if not configured + ) + Tag = 'certificate', 'pki' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/disk.action.ps1 b/Actions/disk.action.ps1 index e14ee0c..d6ab627 100644 --- a/Actions/disk.action.ps1 +++ b/Actions/disk.action.ps1 @@ -5,7 +5,7 @@ $disk = Get-Disk | Where-Object Location -Match "LUN $($Configuration.Lun)$" if (-not $disk) { - Write-PSFMessage -Level Warning -Message 'Error configuring volume {1} (LUN ID {0}): Disk not found, validate VM deployment!' -StringValues $Configuration.Lun, $Configuration.Letter -Data $Configuration -Target $Configuration.Letter + Write-PSFMessage -Level Warning -Message 'Error configuring volume {1} (LUN ID {0}): Disk not found, validate VM deployment!' -StringValues $Configuration.Lun, $Configuration.Letter -Data $Configuration -Target $Configuration.Letter -ModuleName 'VMDeploy.Guest' return } @@ -40,7 +40,7 @@ $validationCode = { $disk = Get-Disk | Where-Object Location -Match "LUN $($Configuration.Lun)$" if (-not $disk) { - Write-PSFMessage -Level Warning -Message 'Error configuring volume {1} (LUN ID {0}): Disk not found, validate VM deployment!' -StringValues $Configuration.Lun, $Configuration.Letter -Data $Configuration -Target $Configuration.Letter + Write-PSFMessage -Level Warning -Message 'Error configuring volume {1} (LUN ID {0}): Disk not found, validate VM deployment!' -StringValues $Configuration.Lun, $Configuration.Letter -Data $Configuration -Target $Configuration.Letter -ModuleName 'VMDeploy.Guest' return $false } diff --git a/Actions/domainJoin.action.ps1 b/Actions/domainJoin.action.ps1 new file mode 100644 index 0000000..4200475 --- /dev/null +++ b/Actions/domainJoin.action.ps1 @@ -0,0 +1,68 @@ +$executionCode = { + param ( + $Configuration + ) + + $data = Import-PSFCLixml -Path 'VMDeploy:\Resources\__joindomain.dat' + $param = @{ + ComputerName = $env:COMPUTERNAME + DomainName = $data.Domain + Credential = [PSCredential]::new("$($data.Domain)\$($data.AccountName)", ($data.Password | ConvertTo-SecureString -AsPlainText -Force)) + } + if ($Configuration.OU) { + $param.OUPath = $Configuration.OU + } + if (Test-Path -Path 'VMDeploy:\Resources\__computername.dat') { + $param.ComputerName = Import-PSFCLixml -Path 'VMDeploy:\Resources\__computername.dat' + } + Add-Computer @param -ErrorAction stop +} + +$validationCode = { + param ( + $Configuration + ) + + $data = Import-PSFCLixml -Path 'VMDeploy:\Resources\__joindomain.dat' + (Get-CimInstance win32_computersystem).Domain -eq $data.Domain +} + +$preDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) + + $data = @{ } + if ($Configuration.Domain) { $data.Domain = $Configuration.Domain } + else { $data.Domain = Read-Host "Enter FQDN of Domain to join the VM to" } + if ($Configuration.AccountName) { $data.AccountName = $Configuration.AccountName } + else { $data.AccountName = Read-Host "Enter Name of Account to use for joining the VM to the target domain" } + if ($Configuration.Password) { $data.Password = $Configuration.Password } + else { + $password = Read-Host "Enter Password used to join VM to domain" -AsSecureString + $data.Password = [PSCredential]::New("whatever", $password).GetNetworkCredential().Password + } + + $data | Export-PSFClixml -Path "$WorkingDirectory\Resources\__joindomain.dat" +} + +$param = @{ + Name = 'domainJoin' + ScriptBlock = $executionCode + Validate = $validationCode + PreDeploymentCode = $preDeploymentCode + Description = 'Join Computer to a Target Domain' + ParameterMandatory = @( + + ) + ParameterOptional = @( + 'Domain' + 'AccountName' + 'Password' + 'OU' + ) + Tag = 'ad', 'join' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/filecopy.action.ps1 b/Actions/filecopy.action.ps1 new file mode 100644 index 0000000..8a6602b --- /dev/null +++ b/Actions/filecopy.action.ps1 @@ -0,0 +1,50 @@ +$executionCode = { + param ( + $Configuration + ) + + foreach ($item in $Configuration.Path) { + $targetPath = Join-Path -Path $Configuration.Destination -ChildPath $item + if (Test-Path -Path $targetPath) { continue } + + $sourcePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $item + Copy-Item -Path $sourcePath -Destination $targetPath -Recurse -Force + } +} + +$validationCode = { + param ( + $Configuration + ) + + foreach ($item in $Configuration.Path) { + $targetPath = Join-Path -Path $Configuration.Destination -ChildPath $item + if (-not (Test-Path -Path $targetPath)) { return $false } + } + + $true +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) +} + +$param = @{ + Name = 'filecopy' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Copies files from resources to the target path within the guest OS' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + 'Path' # Source path(s) within the resources folder - e.g. SCCM_Setup | Use the exact same notation as in the Resources config + 'Destination' # Target folder on os - e.g.: C:\contoso\scripts + ) + ParameterOptional = @( + ) + Tag = 'dummy' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/firewall.action.ps1 b/Actions/firewall.action.ps1 new file mode 100644 index 0000000..c0b0d4b --- /dev/null +++ b/Actions/firewall.action.ps1 @@ -0,0 +1,61 @@ +$executionCode = { + param ( + $Configuration + ) + + $properties = 'Exemptions','EnableStatefulFtp','EnableStatefulPptp','ActiveProfile','RemoteMachineTransportAuthorizationList','RemoteMachineTunnelAuthorizationList','RemoteUserTransportAuthorizationList','RemoteUserTunnelAuthorizationList','RequireFullAuthSupport','CertValidationLevel','AllowIPsecThroughNAT','MaxSAIdleTimeSeconds','KeyEncoding','EnablePacketQueuing' + $fwConfig = Get-NetFirewallSetting + $param = @{ PolicyStore = 'Localhost' } + + foreach ($property in $properties) { + if (-not $Configuration.$property) { continue } + if ($fwConfig.$property -eq $Configuration.$property) { continue } + $param.$property = $Configuration.$property + } + + Set-NetFirewallSetting @param -ErrorAction Stop +} + +$validationCode = { + param ( + $Configuration + ) + + $properties = 'Exemptions','EnableStatefulFtp','EnableStatefulPptp','ActiveProfile','RemoteMachineTransportAuthorizationList','RemoteMachineTunnelAuthorizationList','RemoteUserTransportAuthorizationList','RemoteUserTunnelAuthorizationList','RequireFullAuthSupport','CertValidationLevel','AllowIPsecThroughNAT','MaxSAIdleTimeSeconds','KeyEncoding','EnablePacketQueuing' + $fwConfig = Get-NetFirewallSetting + + foreach ($property in $properties) { + if (-not $Configuration.$property) { continue } + if ($fwConfig.$property -eq $Configuration.$property) { continue } + return $false + } + $true +} + +$param = @{ + Name = 'firewall' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Manage global firewall settings for the local store' + ParameterMandatory = @( + + ) + ParameterOptional = @( + 'Exemptions' + 'EnableStatefulFtp' + 'EnableStatefulPptp' + 'ActiveProfile' + 'RemoteMachineTransportAuthorizationList' + 'RemoteMachineTunnelAuthorizationList' + 'RemoteUserTransportAuthorizationList' + 'RemoteUserTunnelAuthorizationList' + 'RequireFullAuthSupport' + 'CertValidationLevel' + 'AllowIPsecThroughNAT' + 'MaxSAIdleTimeSeconds' + 'KeyEncoding' + 'EnablePacketQueuing' + ) + Tag = 'firewall', 'config' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/ipsec.action.ps1 b/Actions/ipsec.action.ps1 new file mode 100644 index 0000000..5d7a378 --- /dev/null +++ b/Actions/ipsec.action.ps1 @@ -0,0 +1,104 @@ +$executionCode = { + param ( + $Configuration + ) + + #region Functions + function Set-IPSecRule { + <# + .SYNOPSIS + Set IPSecRule to communicate between different network zones. + + .DESCRIPTION + Set IPSecRule to communicate between different network zones. + + .PARAMETER Displayname + Name of the IPSec rule + + .PARAMETER Authority + CA path + + .PARAMETER RemoteAddress + Remote address from the Endpoint 2 (subnet or specific IP address). + + .EXAMPLE + + #> + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $DisplayName, + + [Parameter(Mandatory = $true)] + [string] + $Authority, + + [Parameter(Mandatory = $false)] + $RemoteAddress = "Any", + + [Parameter(Mandatory = $false)] + $LocalAddress = "Any", + + [Parameter(Mandatory = $false)] + $Protocol = "Any", + + [Parameter(Mandatory = $false)] + $RemotePort = "Any", + + [Parameter(Mandatory = $false)] + $LocalPort = "Any", + + [Parameter(Mandatory = $false)] + $Enabled = "True", + + [Parameter(Mandatory = $false)] + [string] + $Security = "Require" + + ) + + process { + $certProposal = New-NetIPsecAuthProposal -Machine -Cert -Authority $Authority -AuthorityType "root" -Signing RSA -ErrorAction SilentlyContinue + $certAuthSet = New-NetIPsecPhase1AuthSet -PolicyStore 'localhost' -DisplayName $DisplayName -Proposal $certProposal -ErrorAction Stop + New-NetIPsecRule -PolicyStore 'localhost' -DisplayName $DisplayName -Name $DisplayName -InboundSecurity $Security -OutboundSecurity $Security -Phase1AuthSet $certAuthSet.Name -Mode Transport -LocalAddress $LocalAddress -RemoteAddress $RemoteAddress -Phase2AuthSet None -Protocol $Protocol -RemotePort $RemotePort -LocalPort $LocalPort -Enabled $Enabled + } + } + #endregion Functions + + Set-IPSecRule @Configuration +} + +$validationCode = { + param ( + $Configuration + ) + + $rule = Get-NetIPSecRule -DisplayName $Configuration.DisplayName -PolicyStore localhost -ErrorAction Ignore + if (-not $rule) { return $false } + + $true +} + +$param = @{ + Name = 'ipsec' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Create IPSec Rules' + ParameterMandatory = @( + 'DisplayName' + 'Authority' + ) + ParameterOptional = @( + 'RemoteAddress' + 'LocalAddress' + 'Protocol' + 'RemotePort' + 'LocalPort' + 'Enabled' + 'Security' + ) + Tag = 'Network', 'ipsec' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/ipsec_cryptoset.action.ps1 b/Actions/ipsec_cryptoset.action.ps1 new file mode 100644 index 0000000..73fed10 --- /dev/null +++ b/Actions/ipsec_cryptoset.action.ps1 @@ -0,0 +1,73 @@ +$executionCode = { + param ( + $Configuration + ) + + $cryptoSet = Get-NetIPsecMainModeCryptoSet -PolicyStore localhost -DisplayName $Configuration.DisplayName -ErrorAction Ignore + if ($cryptoSet) { $cryptoSet | Remove-NetIPsecMainModeCryptoSet } + + $paramProp = @{ + # Default Values + Encryption = 'AES256' + Hash = 'SHA256' + KeyExchange = 'DH20' + } + if ($Configuration.Encryption) { $paramProp.Encryption = $Configuration.Encryption } + if ($Configuration.Hash) { $paramProp.Hash = $Configuration.Hash } + if ($Configuration.KeyExchange) { $paramProp.KeyExchange = $Configuration.KeyExchange } + $cryptosetProposal = New-NetIPsecMainModeCryptoProposal @paramProp + + $paramCrypto = @{ + PolicyStore = 'localhost' + Proposal = $cryptosetProposal + DisplayName = $Configuration.DisplayName + } + if ($Configuration.Default) { $paramCrypto.Default = $Configuration.Default } + + $null = New-NetIPsecMainModeCryptoSet @paramCrypto +} + +$validationCode = { + param ( + $Configuration + ) + + $cryptoSet = Get-NetIPsecMainModeCryptoSet -PolicyStore localhost -DisplayName $Configuration.DisplayName -ErrorAction Ignore + if (-not $cryptoSet) { return $false } + + if ($cryptoSet.Proposal.Count -ne 1) { return $false } + + $intended = @{ + # Default Values + Encryption = 'AES256' + Hash = 'SHA256' + KeyExchange = 'DH20' + } + if ($Configuration.Encryption) { $intended.Encryption = $Configuration.Encryption } + if ($Configuration.Hash) { $intended.Hash = $Configuration.Hash } + if ($Configuration.KeyExchange) { $intended.KeyExchange = $Configuration.KeyExchange } + + if ($cryptoSet.Proposal[0].Encryption -ne $intended.Encryption) { return $false } + if ($cryptoSet.Proposal[0].Hash -ne $intended.Hash) { return $false } + if ($cryptoSet.Proposal[0].KeyExchange -ne $intended.KeyExchange) { return $false } + + $true +} + +$param = @{ + Name = 'ipsec_cryptoset' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Define an IPSec Crypto Set' + ParameterMandatory = @( + 'DisplayName' + ) + ParameterOptional = @( + 'Encryption' + 'Hash' + 'KeyExchange' + 'Default' + ) + Tag = 'ipsec', 'crypto' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/network.action.ps1 b/Actions/network.action.ps1 index b4bc0b0..618c141 100644 --- a/Actions/network.action.ps1 +++ b/Actions/network.action.ps1 @@ -87,7 +87,7 @@ $param = @{ 'IPAddress' 'DefaultGateway' 'DnsServer' - 'SubnetMask' + 'PrefixLength' ) Tag = 'network' } diff --git a/Actions/policy_clear.action.ps1 b/Actions/policy_clear.action.ps1 new file mode 100644 index 0000000..d324912 --- /dev/null +++ b/Actions/policy_clear.action.ps1 @@ -0,0 +1,90 @@ +$executionCode = { + param ( + $Configuration + ) + + if (Test-Path "$env:WinDir\System32\GroupPolicyUsers") { + Remove-Item "$env:WinDir\System32\GroupPolicyUsers\*" -Recurse -Force -ErrorAction Stop + } + if (Test-Path "$env:WinDir\System32\GroupPolicy") { + Remove-Item "$env:WinDir\System32\GroupPolicy\*" -Recurse -Force -ErrorAction Stop + } + + secedit /configure /cfg $env:WinDir\inf\defltbase.inf /db defltbase.sdb /verbose + + if (Get-NetIPsecMainModeCryptoSet -PolicyStore 'localhost') { + Get-NetIPsecMainModeCryptoSet -PolicyStore 'localhost' | Remove-NetIPsecMainModeCryptoSet -ErrorAction Stop + } + + if (Get-NetIPsecRule -PolicyStore 'localhost') { + Get-NetIPsecRule -PolicyStore 'localhost' | Remove-NetIPsecRule -ErrorAction Stop + } + if (Get-NetIPsecPhase1AuthSet -PolicyStore 'localhost') { + Get-NetIPsecPhase1AuthSet -PolicyStore 'localhost' | Remove-NetIPsecPhase1AuthSet -ErrorAction Stop + } + if (Get-NetIPsecRule -PolicyStore PersistentStore) { + Get-NetIPsecRule -PolicyStore PersistentStore | Remove-NetIPsecRule -ErrorAction Stop + } + if (Get-NetIPsecPhase1AuthSet -PolicyStore PersistentStore) { + Get-NetIPsecPhase1AuthSet -PolicyStore PersistentStore | Remove-NetIPsecPhase1AuthSet -ErrorAction Stop + } +} + +$validationCode = { + param ( + $Configuration + ) + + $allIsWell = $true + $msgCommon = @{ + FunctionName = 'policy_clear' + ModuleName = 'VMDeploy.Guest' + } + + if (Get-Item "$env:WinDir\System32\GroupPolicyUsers\*" -Force -ErrorAction Ignore) { + $allIsWell = $false + Write-PSFMessage -Message 'Local User Policies found' @msgCommon + } + if (Get-Item "$env:WinDir\System32\GroupPolicy\*" -Force -ErrorAction Ignore) { + $allIsWell = $false + Write-PSFMessage -Message 'Local System Policies found' @msgCommon + } + + if (Get-NetIPsecMainModeCryptoSet -PolicyStore 'localhost') { + $allIsWell = $false + Write-PSFMessage -Message 'IPSec Main Mode CryptoSet found' @msgCommon + } + + if (Get-NetIPsecRule -PolicyStore 'localhost') { + $allIsWell = $false + Write-PSFMessage -Message 'IPSec rules found in the local store' @msgCommon + } + if (Get-NetIPsecPhase1AuthSet -PolicyStore 'localhost') { + $allIsWell = $false + Write-PSFMessage -Message 'IPSec Phase 1 AuthSet found in the local store' @msgCommon + } + if (Get-NetIPsecRule -PolicyStore PersistentStore) { + $allIsWell = $false + Write-PSFMessage -Message 'IPSec rules found in the persistent store' @msgCommon + } + if (Get-NetIPsecPhase1AuthSet -PolicyStore PersistentStore) { + $allIsWell = $false + Write-PSFMessage -Message 'IPSec Phase 1 AuthSet found in the persistent store' @msgCommon + } + + $allIsWell +} + +$param = @{ + Name = 'policy_clear' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Resets the local policy & security configuration.' + ParameterMandatory = @( + + ) + ParameterOptional = @( + ) + Tag = 'policy', 'ipsec', 'remove', 'clear' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/serverfeature.action.ps1 b/Actions/serverfeature.action.ps1 new file mode 100644 index 0000000..77bcc2c --- /dev/null +++ b/Actions/serverfeature.action.ps1 @@ -0,0 +1,40 @@ +$executionCode = { + param ( + $Configuration + ) + + Install-WindowsFeature -Name $Configuration.Name -IncludeAllSubFeature:($Configuration.AllSubFeatures -as [bool]) -IncludeManagementTools:(-not $Configuration.ExcludeTools) +} + +$validationCode = { + param ( + $Configuration + ) + + (Get-WindowsFeature -Name $Configuration.Name).Installed -as [bool] +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) +} + +$param = @{ + Name = 'serverfeature' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Enables a server feature' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + 'Name' + ) + ParameterOptional = @( + 'AllSubFeatures' + 'ExcludeTools' + ) + Tag = 'windows','role' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/template.md b/Actions/template.md new file mode 100644 index 0000000..766e194 --- /dev/null +++ b/Actions/template.md @@ -0,0 +1,42 @@ +# Template + +```powershell +$executionCode = { + param ( + $Configuration + ) +} + +$validationCode = { + param ( + $Configuration + ) + + $true +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) + + Write-Host "Predeployment Test: $($Configuration.Whatever) - $WorkingDirectory" +} + +$param = @{ + Name = 'dummy' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Do Nothing' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + 'Whatever' + ) + ParameterOptional = @( + ) + Tag = 'dummy' +} +Register-VMGuestAction @param +``` From 154945ed4d7aef13bb1331f06535e65c9f875573 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 27 Apr 2022 11:29:28 +0200 Subject: [PATCH 08/28] added persistent configuration for success --- VMDeploy.Guest/VMDeploy.Guest.psd1 | 11 ++-- .../Register-VMGuestConfiguration.ps1 | 10 ++++ .../Test-VMGuestConfiguration.ps1 | 13 ++++- .../Get-VMGuestPersistentSuccess.ps1 | 39 ++++++++++++++ .../Set-VMGuestPersistentSuccess.ps1 | 51 +++++++++++++++++++ .../Test-VMGuestPersistentSuccess.ps1 | 42 +++++++++++++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 VMDeploy.Guest/functions/Persistence/Get-VMGuestPersistentSuccess.ps1 create mode 100644 VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 create mode 100644 VMDeploy.Guest/functions/Persistence/Test-VMGuestPersistentSuccess.ps1 diff --git a/VMDeploy.Guest/VMDeploy.Guest.psd1 b/VMDeploy.Guest/VMDeploy.Guest.psd1 index 947e4bc..6bcde75 100644 --- a/VMDeploy.Guest/VMDeploy.Guest.psd1 +++ b/VMDeploy.Guest/VMDeploy.Guest.psd1 @@ -40,15 +40,20 @@ # Functions to export from this module FunctionsToExport = @( - 'Clear-VMGuestConfiguration' 'Get-VMGuestAction' + 'Register-VMGuestAction' + 'Unregister-VMGuestAction' + + 'Clear-VMGuestConfiguration' 'Get-VMGuestConfiguration' 'Import-VMGuestConfiguration' 'Invoke-VMGuestConfiguration' - 'Register-VMGuestAction' 'Register-VMGuestConfiguration' 'Test-VMGuestConfiguration' - 'Unregister-VMGuestAction' + + 'Get-VMGuestPersistentSuccess' + 'Set-VMGuestPersistentSuccess' + 'Test-VMGuestPersistentSuccess' ) # List of all modules packaged with this module diff --git a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 index 3b7dcb4..d3441e1 100644 --- a/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 @@ -29,6 +29,11 @@ Other configuration entries that must have been applied before this configuration entry can be executed. For example, imagine a configuration entry that wants to ensure all updates provided via SCCM have been installed: That example configuration could not possibly succeed before the SCCM client has been installed and configured. + + .PARAMETER Persistent + Whether the action once tested successfully should keep being flagged as successful in the future. + This means later changes to the environment cannot affect the results anymore. + Defaults to: $true .PARAMETER Source Documentation only. @@ -67,6 +72,10 @@ [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $DependsOn = @(), + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [bool] + $Persistent = $true, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] @@ -114,6 +123,7 @@ Action = $Action Parameters = $Parameters DependsOn = $DependsOn + Persistent = $Persistent Source = $Source } } diff --git a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 index 496dc22..091d89e 100644 --- a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 @@ -43,12 +43,22 @@ #region Action Missing if (-not $script:actions[$configuration.Action]) { - $result.TypeNames = 'Missing Action' + $result.Type = 'Missing Action' if ($Quiet) { $result.Success } else { $result } continue } #endregion Action Missing + + #region Persistence + if (Test-VMGuestPersistentSuccess -Identity $configuration.Identity -Persistent $configuration.Persistent) { + $result.Type = 'Success (Persisted)' + $result.Success = $true + if ($Quiet) { $result.Success } + else { $result } + continue + } + #endregion Persistence #region Process Validation Script try { @@ -67,6 +77,7 @@ $result.Success = $validateResult if ($validateResult) { $result.Type = 'Success' + Set-VMGuestPersistentSuccess -Identity $configuration.Identity -Value $true } else { $result.Type = 'Not Completed' diff --git a/VMDeploy.Guest/functions/Persistence/Get-VMGuestPersistentSuccess.ps1 b/VMDeploy.Guest/functions/Persistence/Get-VMGuestPersistentSuccess.ps1 new file mode 100644 index 0000000..a951e12 --- /dev/null +++ b/VMDeploy.Guest/functions/Persistence/Get-VMGuestPersistentSuccess.ps1 @@ -0,0 +1,39 @@ +function Get-VMGuestPersistentSuccess +{ + <# + .SYNOPSIS + Returns the various success states of configuration items. + + .DESCRIPTION + Returns the various success states of configuration items. + Configuration items that have been verified as successfully completed persist that information, + so that subsequent tests need not be executed and later alterations to the state of the machine + do not affect the test results. + + .EXAMPLE + PS C:\> Get-VMGuestPersistentSuccess + + Returns a list of all persisted configuration items and their completion state + #> + [OutputType([hashtable])] + [CmdletBinding()] + Param ( + + ) + + begin + { + $configPath = Join-Path -Path (Get-PSFPath -Name AppData) -ChildPath 'VMDeploy\Guest' + if (-not (Test-Path -Path $configPath)) { + $null = New-Item -Path $configPath -ItemType Directory -Force + } + $configFile = Join-Path -Path $configPath -ChildPath 'persistence.clidat' + } + process + { + if (Test-Path -Path $configFile) { + Import-PSFClixml -Path $configFile + } + else { @{ } } + } +} diff --git a/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 new file mode 100644 index 0000000..9071009 --- /dev/null +++ b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 @@ -0,0 +1,51 @@ +function Set-VMGuestPersistentSuccess +{ + <# + .SYNOPSIS + Persists the success state of a given configuration item. + + .DESCRIPTION + Persists the success state of a given configuration item. + This may be used in subsequent tests to skip the actual test run for configuration items configured to use persistence. + This is mostly used for actions whose results are falsified by subsequent actions in the processing list. + + .PARAMETER Identity + Identity of the configuration item to persist the success state for. + + .PARAMETER Value + Whether the configuration item was actually successful. + + .EXAMPLE + PS C:\> Set-VMGuestPersistentSuccess -Identity $configuration.Identity -Value $true + + Sets the specified configuration item as completed successfully. + #> + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $Identity, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [bool] + $Value + ) + + begin + { + $configPath = Join-Path -Path (Get-PSFPath -Name AppData) -ChildPath 'VMDeploy\Guest' + if (-not (Test-Path -Path $configPath)) { + $null = New-Item -Path $configPath -ItemType Directory -Force + } + $configFile = Join-Path -Path $configPath -ChildPath 'persistence.clidat' + } + process + { + $data = @{ } + if (Test-Path -Path $configFile) { + $data = Import-PSFClixml -Path $configFile + } + $data[$Identity] = $value + $data | Export-PSFClixml -Path $configFile + } +} diff --git a/VMDeploy.Guest/functions/Persistence/Test-VMGuestPersistentSuccess.ps1 b/VMDeploy.Guest/functions/Persistence/Test-VMGuestPersistentSuccess.ps1 new file mode 100644 index 0000000..c9d479f --- /dev/null +++ b/VMDeploy.Guest/functions/Persistence/Test-VMGuestPersistentSuccess.ps1 @@ -0,0 +1,42 @@ +function Test-VMGuestPersistentSuccess +{ + <# + .SYNOPSIS + Tests, whether the specified configuration entry has been completed previously. + + .DESCRIPTION + Tests, whether the specified configuration entry has been completed previously. + This is used in situations where an action that has been completed once will not be validated again. + Specifically useful when later actions in the task sequence invalidate the result. + + .PARAMETER Identity + Identity of the configuration entry to test. + + .PARAMETER Persistent + Whether the configuration entry uses persistent success flags. + If this is false, this command will always return $false. + + .EXAMPLE + PS C:\> Test-VMGuestPersistentSuccess -Identity $configuration.Identity -Persistent $configuration.Persistent + + Tests, whether the specified configuration entry has been completed successfully. + #> + [OutputType([bool])] + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true)] + [string] + $Identity, + + [Parameter(Mandatory = $true)] + [bool] + $Persistent + ) + + process + { + if (-not $Persistent) { return $false } + $persistenceData = Get-VMGuestPersistentSuccess + $persistenceData.$Identity + } +} From 89ea68e184e44cefaea2c4f00ed867a849709dda Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 3 May 2022 10:39:45 +0200 Subject: [PATCH 09/28] update --- Actions/computername.action.ps1 | 29 ++++ .../Invoke-VMGuestConfiguration.ps1 | 4 +- scripts/vmdeploy.ps1 | 161 ------------------ 3 files changed, 31 insertions(+), 163 deletions(-) create mode 100644 Actions/computername.action.ps1 delete mode 100644 scripts/vmdeploy.ps1 diff --git a/Actions/computername.action.ps1 b/Actions/computername.action.ps1 new file mode 100644 index 0000000..ae631f6 --- /dev/null +++ b/Actions/computername.action.ps1 @@ -0,0 +1,29 @@ +$executionCode = { + param ( + $Configuration + ) + + Rename-Computer -NewName $Configuration.Name -ErrorAction Stop -WarningAction SilentlyContinue +} + +$validationCode = { + param ( + $Configuration + ) + + $env:COMPUTERNAME -eq $Configuration.Name +} + +$param = @{ + Name = 'Computername' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Assigns the name to the computer. This action is auto-configured and needs/should not be defined explicitly' + ParameterMandatory = @( + 'Name' + ) + ParameterOptional = @( + ) + Tag = 'Computername' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 index 28c07ae..d6d0b5f 100644 --- a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -69,7 +69,7 @@ $configurations = Get-VMGuestConfiguration | Sort-Object Weight, Identity Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Processing.Starting' - foreach ($configuration in $configurations) { + :main foreach ($configuration in $configurations) { #region Check Prerequisites Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Configuration.Processing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration if ($currentState[$configuration.Identity]) { @@ -85,7 +85,7 @@ foreach ($dependency in $configuration.DependsOn) { if (-not $currentState[$dependency]) { Write-PSFMessage -Level Warning -String 'Invoke-VMGuestConfiguration.Configuration.DependencyNotDone' -StringValues $configuration.Identity, $configuration.Action, $dependency -Target $configuration - continue + continue main } } #endregion Check Prerequisites diff --git a/scripts/vmdeploy.ps1 b/scripts/vmdeploy.ps1 deleted file mode 100644 index 2e82cf7..0000000 --- a/scripts/vmdeploy.ps1 +++ /dev/null @@ -1,161 +0,0 @@ - -<# - .SYNOPSIS - Launcher script that should be added to the OS image as a scheduled task run on system boot. - - .DESCRIPTION - Launcher script that should be added to the OS image as a scheduled task run on system boot. - Note: Should probably be run as System with maximum privileges for most OS tasks. - - This script will search all available volumes for a guest configuration package. - It expects the following folder structure within that volume at the root level: - - Modules\* - Actions\* - Config\* - - Additional folders are ignored by this script. - It assumes that the modules folder contains all PowerShell modules needed for the VM Deployment's Guest Configuration workflow. - The folder will be added to PSModulePath with max priority for this workflow only. - The modules PSFramework and VMDeploy.Guest must be included in this folder at a minimum. - - The "Actions" folder is designed for additional, customer specific, non-public VMGuest Actions. - You can define your own actions using Register-VMGuestAction. - Actions are the actual implementation logic performing to Guest configuration steps. - - The "Config" folder is for the actual configuration files that define the intended post-deployment state. - - Running this script will have it register the volume root as a PSDrive named VMDeploy. - It will also set this path as the VMDeploy PSFPath, which will be available for path insertion in builtin actions. - This allows you for example to add another folder - let's call it "Install" - for your installation media and - then specify the path during a configuration for SCCM client install as "%VMDeploy%Install\sccm.client.setup.exe" - - Note on volumes: - - The volume need not have a drive letter for being detected - - On PowerShell 7, a bug requires the volume to have a driveletter, but this task is designed with Windows PowerShell in mind anyway. - - .EXAMPLE - PS C:\> .\vmdeploy.ps1 -#> -[CmdletBinding()] -param ( - [ValidateRange(1, 63)] - [int] - $ConfigLun = 63, - - [switch] - $EnableAllDisks -) - -#region Functions -function Write-LogEntry { - [CmdletBinding()] - param ( - [string] - $LogName, - - [string] - $Source, - - [int] - $EventID, - - [int] - $Category, - - [System.Diagnostics.EventLogEntryType] - $Type, - - [object[]] - $Data - ) - $id = New-Object System.Diagnostics.EventInstance($EventID, $Category, $Type) - $evtObject = New-Object System.Diagnostics.EventLog - $evtObject.Log = $LogName - $evtObject.Source = $Source - $evtObject.WriteEvent($id, $Data) -} - -function Get-DiskLetters { - [CmdletBinding()] - param ( - $VolumeObject - ) - - (Get-Volume).DriveLetter - - $configRoot = "$($VolumeObject.Path)Config" - foreach ($file in Get-ChildItem -Path $configRoot -Recurse -Filter *.json) { - $config = Get-Content -LiteralPath $file.FullName | ConvertFrom-Json - if ($config.Action -ne 'disk') { continue } - $config.Parameters.Letter - } -} -#endregion Functions - -#region Detect VMDeploy Volume -if ($EnableAllDisks) { - Get-Disk | Where-Object OperationalStatus -EQ 'Offline' | Set-Disk -IsOffline $false -} -else { - $disk = Get-Disk | Where-Object Location -Match "LUN $ConfigLun" | Where-Object OperationalStatus -EQ 'Offline' - if ($disk) { $disk | Set-Disk -IsOffline $false } -} -Start-Sleep -Seconds 1 -$volumes = Get-Volume -$volumeObject = foreach ($volume in $volumes) { - if (-not (Test-Path -LiteralPath "$($volume.Path)Modules\VMDeploy.Guest")) { continue } - - $volume - break -} - -if (-not $volumeObject) { - Write-LogEntry -LogName Application -Source Application -EventID 1 -Category 666 -Type Information -Data "No VMDeploy.Guest configuration volume detected. Assuming image used outside of the system, unregistering scheduled task" - Unregister-ScheduledTask -TaskName VMDeployGuestConfig -ErrorAction Stop - return -} - -$diskLetters = Get-DiskLetters -VolumeObject $volumeObject -foreach ($number in 122..97) { - if (([char]$number) -in $diskLetters) { continue } - $vmdeployOSConfigLetter = [char]$number - break -} -if ($volumeObject.DriveLetter) { - $null = "SELECT VOLUME $($volumeObject.DriveLetter)", "REMOVE LETTER $($volumeObject.DriveLetter)" | diskpart -} -$volumeObject | Get-Partition | Set-Partition -NewDriveLetter $vmdeployOSConfigLetter -#endregion Detect VMDeploy Volume - -#region Apply & Prepare paths for convenient use from PowerShell -if (Get-PSDrive -Name VMDeploy -ErrorAction Ignore) { Remove-PSDrive -Name VMDeploy } -$null = New-PSDrive -Name VMDeploy -PSProvider FileSystem -Root "$($vmdeployOSConfigLetter):\" -Scope Global - -$env:PSModulePath = "$($vmdeployOSConfigLetter):\Modules", $env:PSModulePath -join ";" -Set-PSFPath -Name VMDeploy -Path "$($vmdeployOSConfigLetter):\" -#endregion Apply & Prepare paths for convenient use from PowerShell - -# Load additional Action files -foreach ($file in Get-ChildItem -Path "$($vmdeployOSConfigLetter):\Actions\*.ps1" -ErrorAction Ignore) { - & $file.FullName -} -# Load configuration files for the current client -Import-VMGuestConfiguration -Path "$($vmdeployOSConfigLetter):\Config\*" - -# If Configuration successfull: Kill task as no longer needed -$testResults = Test-VMGuestConfiguration -if ($testResults.Success -notcontains $false) { - $schtaskResult = schtasks /delete /TN VMDeployGuestConfig /f - Write-PSFMessage -Message "schtasks:`n$($schtaskResult -join "`n")" - $null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart - Write-PSFMessage -Message "VMDeployment Guest Configuration Concluded" - Wait-PSFMessage - return -} - -# Execute Guest Config -Invoke-VMGuestConfiguration -Restart - -$null = "SELECT VOLUME $($vmdeployOSConfigLetter)", "REMOVE LETTER $($vmdeployOSConfigLetter)" | diskpart -Wait-PSFMessage \ No newline at end of file From 83c517f4586d8f3d76c8f256bc34aded227f4fd7 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 3 May 2022 10:41:04 +0200 Subject: [PATCH 10/28] update --- .../functions/Persistence/Set-VMGuestPersistentSuccess.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 index 9071009..9eda990 100644 --- a/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 +++ b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 @@ -20,6 +20,7 @@ Sets the specified configuration item as completed successfully. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] From 96177c8090cdddf8afe8853f0bdc6987f9a485f4 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 4 May 2022 15:29:56 +0200 Subject: [PATCH 11/28] Update strings.psd1 --- VMDeploy.Guest/en-us/strings.psd1 | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/VMDeploy.Guest/en-us/strings.psd1 b/VMDeploy.Guest/en-us/strings.psd1 index dbf14c1..ce59fcc 100644 --- a/VMDeploy.Guest/en-us/strings.psd1 +++ b/VMDeploy.Guest/en-us/strings.psd1 @@ -1,23 +1,23 @@ # This is where the strings go, that are written by # Write-PSFMessage, Stop-PSFFunction or the PSFramework validation scriptblocks @{ - 'Import-VMGuestConfiguration.Config.Import' = 'Importing configuration from {0} : {1} ({2})' # $fileObject.FullName, $datumHash.Identity, $datumHash.Action - 'Import-VMGuestConfiguration.File.AccessError' = 'Failed to access {0}' # $pathItem - 'Import-VMGuestConfiguration.File.Processing' = 'Failed to process {0}' # $pathItem + 'Import-VMGuestConfiguration.Config.Import' = 'Importing configuration from {0} : {1} ({2})' # $fileObject.FullName, $datumHash.Identity, $datumHash.Action + 'Import-VMGuestConfiguration.File.AccessError' = 'Failed to access {0}' # $pathItem + 'Import-VMGuestConfiguration.File.Processing' = 'Failed to process {0}' # $pathItem - 'Invoke-VMGuestConfiguration.InvokeCount.Exceeded' = 'Maximum number of guest configuration executions exceeded: {0} / {1}' # $currentInvokeCount, $MaxInvokeCount - 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' = 'The action {1} required for {0} is missing' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' = 'The action {1} required for {0} is missing' # $configuration.Identity, $configuration.Action 'Invoke-VMGuestConfiguration.Configuration.DependencyNotDone' = 'Cannot process {0} ({1}) as dependency {2} has not been completed. Skipping.' # $configuration.Identity, $configuration.Action, $dependency - 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' = 'The configuration {0} ({1}) has already completed, skipping.' # $configuration.Identity, $configuration.Action - 'Invoke-VMGuestConfiguration.Configuration.Execute' = 'Applying configuration {0} ({1})' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' = 'The configuration {0} ({1}) has already completed, skipping.' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.Execute' = 'Applying configuration {0} ({1})' # $configuration.Identity, $configuration.Action 'Invoke-VMGuestConfiguration.Configuration.PostAction.Test' = 'Validating success of {0} ({1}). Successful: {2}' # $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] - 'Invoke-VMGuestConfiguration.Configuration.Processing' = 'Processing configuration {0} ({1})' # $configuration.Identity, $configuration.Action - 'Invoke-VMGuestConfiguration.Configuration.Testing' = 'Testing configuration {0} ({1})' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.Processing' = 'Processing configuration {0} ({1})' # $configuration.Identity, $configuration.Action + 'Invoke-VMGuestConfiguration.Configuration.Testing' = 'Testing configuration {0} ({1})' # $configuration.Identity, $configuration.Action 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' = 'Finished testing configuration {0} ({1}) - has been applied: {2}' # $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] - 'Invoke-VMGuestConfiguration.Finished' = 'All configuration items have been applied, deployment complete' # - 'Invoke-VMGuestConfiguration.Processing.Starting' = 'Starting application of configuration entries' # - 'Invoke-VMGuestConfiguration.Test.Completed' = 'Initial test completed.' # - 'Invoke-VMGuestConfiguration.Test.Starting' = 'Starting initial test of defined configuration entries.' # + 'Invoke-VMGuestConfiguration.Finished' = 'All configuration items have been applied, deployment complete' # + 'Invoke-VMGuestConfiguration.InvokeCount.Exceeded' = 'Maximum number of guest configuration executions exceeded: {0} / {1}' # $currentInvokeCount, $MaxInvokeCount + 'Invoke-VMGuestConfiguration.Processing.Starting' = 'Starting application of configuration entries' # + 'Invoke-VMGuestConfiguration.Test.Completed' = 'Initial test completed.' # + 'Invoke-VMGuestConfiguration.Test.Starting' = 'Starting initial test of defined configuration entries.' # - 'Register-VMGuestConfiguration.BadParameters' = 'Error defining configuration {0} ({1}) - bad parameters. Missing required: {2} | Unknown parameters: {3}' # $Identity, $Action, ($parameterResult.MandatoryMissing -join ','), ($parameterResult.UnknownParameters -join ',') + 'Register-VMGuestConfiguration.BadParameters' = 'Error defining configuration {0} ({1}) - bad parameters. Missing required: {2} | Unknown parameters: {3}' # $Identity, $Action, ($parameterResult.MandatoryMissing -join ','), ($parameterResult.UnknownParameters -join ',') } \ No newline at end of file From 1a8c3c709ede80e3c5a8e277161e6d1fad733cd1 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 24 May 2022 10:39:06 +0200 Subject: [PATCH 12/28] Updated actions --- Actions/certificate.action.ps1 | 25 ++++++++++++++++++++++++- Actions/computername.action.ps1 | 2 +- Actions/policy_clear.action.ps1 | 4 ---- VMDeploy.Guest/changelog.md | 14 +++++++++++--- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Actions/certificate.action.ps1 b/Actions/certificate.action.ps1 index b7e851a..9bd5483 100644 --- a/Actions/certificate.action.ps1 +++ b/Actions/certificate.action.ps1 @@ -9,6 +9,16 @@ Password = ("DoesNotMatter" | ConvertTo-SecureString -AsPlainText -Force) } Import-PfxCertificate @param + + $certPath = (Get-Item -Path "VMDeploy:\Resources\__cert_$($Configuration.Name).pfx").FullName + $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certPath, "DoesNotMatter") + + switch ($Configuration.CertRoles) { + 'RDP' { + $instance = Get-CimInstance -Namespace root\cimv2\TerminalServices -ClassName Win32_TSGeneralSetting -Filter 'TerminalName = "RDP-TCP"' + $instance | Set-CimInstance -Property @{ SSLCertificateSHA1Hash = $certObject.Thumbprint } + } + } } $validationCode = { @@ -20,7 +30,18 @@ $validationCode = { $certPath = (Get-Item -Path "VMDeploy:\Resources\__cert_$($Configuration.Name).pfx").FullName $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certPath, "DoesNotMatter") - Test-Path -Path "Cert:\LocalMachine\My\$($certObject.Thumbprint)" + if (-not (Test-Path -Path "Cert:\LocalMachine\My\$($certObject.Thumbprint)")) { + return $false + } + + switch ($Configuration.CertRoles) { + 'RDP' { + $instance = Get-CimInstance -Namespace root\cimv2\TerminalServices -ClassName Win32_TSGeneralSetting -Filter 'TerminalName = "RDP-TCP"' + if ($instance.SSLCertificateSHA1Hash -ne $certObject.Thumbprint) { return $false } + } + } + + $true } $PreDeploymentCode = { @@ -220,6 +241,8 @@ $param = @{ ) ParameterOptional = @( 'Fqdn' # Prompted if not configured + 'CertRoles' # Determines other execution logic to apply to the certificate after installation. + # Supported Roles: RDP ) Tag = 'certificate', 'pki' } diff --git a/Actions/computername.action.ps1 b/Actions/computername.action.ps1 index ae631f6..b9b503a 100644 --- a/Actions/computername.action.ps1 +++ b/Actions/computername.action.ps1 @@ -3,7 +3,7 @@ $Configuration ) - Rename-Computer -NewName $Configuration.Name -ErrorAction Stop -WarningAction SilentlyContinue + Rename-Computer -NewName $Configuration.Name -ErrorAction Stop -WarningAction SilentlyContinue -Force -Confirm:$false } $validationCode = { diff --git a/Actions/policy_clear.action.ps1 b/Actions/policy_clear.action.ps1 index d324912..dbd22c6 100644 --- a/Actions/policy_clear.action.ps1 +++ b/Actions/policy_clear.action.ps1 @@ -45,10 +45,6 @@ $validationCode = { $allIsWell = $false Write-PSFMessage -Message 'Local User Policies found' @msgCommon } - if (Get-Item "$env:WinDir\System32\GroupPolicy\*" -Force -ErrorAction Ignore) { - $allIsWell = $false - Write-PSFMessage -Message 'Local System Policies found' @msgCommon - } if (Get-NetIPsecMainModeCryptoSet -PolicyStore 'localhost') { $allIsWell = $false diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index ea9aaeb..8b3b091 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -1,5 +1,13 @@ # Changelog + +## ??? + ++ Upd: Action: Certificate - added CertRoles & RDP configuration ++ Upd: Action: ComputerName - disabled interactive user prompts ++ Fix: Action: policy_clear - fixed broken validation for local machine policy (will get filled automatically) + ## 1.0.0 (2021-04-07) - - New: Some Stuff - - Upd: Moar Stuff - - Fix: Much Stuff \ No newline at end of file + ++ New: Some Stuff ++ Upd: Moar Stuff ++ Fix: Much Stuff From 5cf5d9477dd2f19f85f67683386371eb537bede6 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 24 May 2022 13:00:10 +0200 Subject: [PATCH 13/28] firewall processing update --- Actions/firewall.action.ps1 | 8 ++++---- VMDeploy.Guest/changelog.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Actions/firewall.action.ps1 b/Actions/firewall.action.ps1 index c0b0d4b..d5d633f 100644 --- a/Actions/firewall.action.ps1 +++ b/Actions/firewall.action.ps1 @@ -8,8 +8,8 @@ $param = @{ PolicyStore = 'Localhost' } foreach ($property in $properties) { - if (-not $Configuration.$property) { continue } - if ($fwConfig.$property -eq $Configuration.$property) { continue } + if ($Configuration.PSObject.Properties.Name -notcontains $property) { continue } + if ($Configuration.$property -eq $fwConfig.$property) { continue } $param.$property = $Configuration.$property } @@ -25,8 +25,8 @@ $validationCode = { $fwConfig = Get-NetFirewallSetting foreach ($property in $properties) { - if (-not $Configuration.$property) { continue } - if ($fwConfig.$property -eq $Configuration.$property) { continue } + if ($Configuration.PSObject.Properties.Name -notcontains $property) { continue } + if ($Configuration.$property -eq $fwConfig.$property) { continue } return $false } $true diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index 8b3b091..76e5cc0 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -4,6 +4,7 @@ + Upd: Action: Certificate - added CertRoles & RDP configuration + Upd: Action: ComputerName - disabled interactive user prompts ++ Upd: Action: firewall - updated/restructured filtering + Fix: Action: policy_clear - fixed broken validation for local machine policy (will get filled automatically) ## 1.0.0 (2021-04-07) From fdf7dfd9528bfd0e233cfc7e35ceb78e897e1713 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 24 May 2022 13:01:49 +0200 Subject: [PATCH 14/28] Update firewall.action.ps1 --- Actions/firewall.action.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Actions/firewall.action.ps1 b/Actions/firewall.action.ps1 index d5d633f..ffa9936 100644 --- a/Actions/firewall.action.ps1 +++ b/Actions/firewall.action.ps1 @@ -8,7 +8,7 @@ $param = @{ PolicyStore = 'Localhost' } foreach ($property in $properties) { - if ($Configuration.PSObject.Properties.Name -notcontains $property) { continue } + if ($Configuration.Keys -notcontains $property) { continue } if ($Configuration.$property -eq $fwConfig.$property) { continue } $param.$property = $Configuration.$property } @@ -25,7 +25,7 @@ $validationCode = { $fwConfig = Get-NetFirewallSetting foreach ($property in $properties) { - if ($Configuration.PSObject.Properties.Name -notcontains $property) { continue } + if ($Configuration.Keys -notcontains $property) { continue } if ($Configuration.$property -eq $fwConfig.$property) { continue } return $false } From 29af43d37e2e754af61c3af81f2abd042ca2f930 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 24 May 2022 14:59:22 +0200 Subject: [PATCH 15/28] new action --- Actions/scriptblock.action.ps1 | 38 ++++++++++++++++++++++++++++++++++ VMDeploy.Guest/changelog.md | 1 + 2 files changed, 39 insertions(+) create mode 100644 Actions/scriptblock.action.ps1 diff --git a/Actions/scriptblock.action.ps1 b/Actions/scriptblock.action.ps1 new file mode 100644 index 0000000..a227d8d --- /dev/null +++ b/Actions/scriptblock.action.ps1 @@ -0,0 +1,38 @@ +$executionCode = { + param ( + $Configuration + ) + + $global:_____ScriptBlockSuccess = $false + try { + & $Configuration.ScriptBlock $Configuration.Parameters + $global:_____ScriptBlockSuccess = $true + } + catch { + Write-PSFMessage -Level Error -Message "Failed" -ErrorRecord $_ + throw + } +} + +$validationCode = { + param ( + $Configuration + ) + + $global:_____ScriptBlockSuccess +} + +$param = @{ + Name = 'Scriptblock' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Executes a ScriptBlock' + ParameterMandatory = @( + 'ScriptBlock' + 'Parameters' + ) + ParameterOptional = @( + ) + Tag = 'code' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index 76e5cc0..71c5bf4 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -2,6 +2,7 @@ ## ??? ++ New: Action: ScriptBlock - execute a custom scriptblock + Upd: Action: Certificate - added CertRoles & RDP configuration + Upd: Action: ComputerName - disabled interactive user prompts + Upd: Action: firewall - updated/restructured filtering From 016dda57bab11c1dc54a4106edfd50b45520d5ad Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 25 May 2022 11:04:58 +0200 Subject: [PATCH 16/28] updates --- Actions/scriptblock.action.ps1 | 2 +- VMDeploy.Guest/changelog.md | 4 +--- .../Configuration/Invoke-VMGuestConfiguration.ps1 | 4 +++- .../Configuration/Test-VMGuestConfiguration.ps1 | 13 +++++++++++-- .../Persistence/Set-VMGuestPersistentSuccess.ps1 | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Actions/scriptblock.action.ps1 b/Actions/scriptblock.action.ps1 index a227d8d..f5689eb 100644 --- a/Actions/scriptblock.action.ps1 +++ b/Actions/scriptblock.action.ps1 @@ -29,9 +29,9 @@ $param = @{ Description = 'Executes a ScriptBlock' ParameterMandatory = @( 'ScriptBlock' - 'Parameters' ) ParameterOptional = @( + 'Parameters' ) Tag = 'code' } diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index 71c5bf4..e52410b 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -10,6 +10,4 @@ ## 1.0.0 (2021-04-07) -+ New: Some Stuff -+ Upd: Moar Stuff -+ Fix: Much Stuff ++ Initial Upload diff --git a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 index d6d0b5f..0d4e9ca 100644 --- a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -61,7 +61,7 @@ Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Starting' foreach ($configuration in Get-VMGuestConfiguration) { Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration - $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet + $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet -NoPersistence Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration } Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Completed' @@ -109,6 +109,8 @@ Set-PSFConfig -FullName 'VMDeploy.Guest.Invoke.CurrentRetryCount' -Value ($currentInvokeCount + 1) Export-PSFConfig -ModuleName 'VMDeploy.Guest' -ModuleVersion 1 + # Ensure all messages are flushed to log + Wait-PSFMessage if ($Restart) { Restart-Computer -Confirm:$false -Force } } } \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 index 091d89e..1eb15a5 100644 --- a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 @@ -12,6 +12,10 @@ .PARAMETER Quiet Do not return a result object, instead only return $true or $false + + .PARAMETER NoPersistence + Do not persistently flag this configuration as successful. + Used to avoid permanently flagging a guest configuration as successful, before its dependencies have been completed. .EXAMPLE PS C:\> Test-VMGuestConfiguration @@ -26,7 +30,10 @@ $Identity = '*', [switch] - $Quiet + $Quiet, + + [switch] + $NoPersistence ) process { @@ -77,7 +84,9 @@ $result.Success = $validateResult if ($validateResult) { $result.Type = 'Success' - Set-VMGuestPersistentSuccess -Identity $configuration.Identity -Value $true + if (-not $NoPersistence) { + Set-VMGuestPersistentSuccess -Identity $configuration.Identity -Value $true + } } else { $result.Type = 'Not Completed' diff --git a/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 index 9eda990..5cf5fc0 100644 --- a/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 +++ b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 @@ -49,4 +49,4 @@ $data[$Identity] = $value $data | Export-PSFClixml -Path $configFile } -} +} \ No newline at end of file From 38a0542da516742159faa745628c0a7076f956b9 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 25 May 2022 11:20:46 +0200 Subject: [PATCH 17/28] Create firewall_exemptions.action.ps1 --- Actions/firewall_exemptions.action.ps1 | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Actions/firewall_exemptions.action.ps1 diff --git a/Actions/firewall_exemptions.action.ps1 b/Actions/firewall_exemptions.action.ps1 new file mode 100644 index 0000000..b72b34f --- /dev/null +++ b/Actions/firewall_exemptions.action.ps1 @@ -0,0 +1,30 @@ +$executionCode = { + param ( + $Configuration + ) + + Set-NetFirewallSetting -PolicyStore Localhost -Exemptions $Configuration.Exemptions -ErrorAction Stop +} + +$validationCode = { + param ( + $Configuration + ) + + $fwConfig = Get-NetFirewallSetting + $Configuration.Exemptions -eq $fwConfig.Exemptions +} + +$param = @{ + Name = 'firewall_exemptions' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Manage global firewall exemption settings for the local store' + ParameterMandatory = @( + 'Exemptions' + ) + ParameterOptional = @( + ) + Tag = 'firewall', 'config' +} +Register-VMGuestAction @param \ No newline at end of file From 01d139399df653670a608609ed5cba9f065e2537 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Thu, 26 May 2022 13:48:39 +0200 Subject: [PATCH 18/28] adding store parameter on get --- Actions/firewall.action.ps1 | 4 ++-- Actions/firewall_exemptions.action.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Actions/firewall.action.ps1 b/Actions/firewall.action.ps1 index ffa9936..8803c0e 100644 --- a/Actions/firewall.action.ps1 +++ b/Actions/firewall.action.ps1 @@ -4,7 +4,7 @@ ) $properties = 'Exemptions','EnableStatefulFtp','EnableStatefulPptp','ActiveProfile','RemoteMachineTransportAuthorizationList','RemoteMachineTunnelAuthorizationList','RemoteUserTransportAuthorizationList','RemoteUserTunnelAuthorizationList','RequireFullAuthSupport','CertValidationLevel','AllowIPsecThroughNAT','MaxSAIdleTimeSeconds','KeyEncoding','EnablePacketQueuing' - $fwConfig = Get-NetFirewallSetting + $fwConfig = Get-NetFirewallSetting -PolicyStore Localhost $param = @{ PolicyStore = 'Localhost' } foreach ($property in $properties) { @@ -22,7 +22,7 @@ $validationCode = { ) $properties = 'Exemptions','EnableStatefulFtp','EnableStatefulPptp','ActiveProfile','RemoteMachineTransportAuthorizationList','RemoteMachineTunnelAuthorizationList','RemoteUserTransportAuthorizationList','RemoteUserTunnelAuthorizationList','RequireFullAuthSupport','CertValidationLevel','AllowIPsecThroughNAT','MaxSAIdleTimeSeconds','KeyEncoding','EnablePacketQueuing' - $fwConfig = Get-NetFirewallSetting + $fwConfig = Get-NetFirewallSetting -PolicyStore Localhost foreach ($property in $properties) { if ($Configuration.Keys -notcontains $property) { continue } diff --git a/Actions/firewall_exemptions.action.ps1 b/Actions/firewall_exemptions.action.ps1 index b72b34f..e7ef059 100644 --- a/Actions/firewall_exemptions.action.ps1 +++ b/Actions/firewall_exemptions.action.ps1 @@ -11,7 +11,7 @@ $validationCode = { $Configuration ) - $fwConfig = Get-NetFirewallSetting + $fwConfig = Get-NetFirewallSetting -PolicyStore Localhost $Configuration.Exemptions -eq $fwConfig.Exemptions } From 4693af8c26df3f3b7ae4ed9d8abea95fa9017c83 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 1 Jun 2022 19:15:20 +0200 Subject: [PATCH 19/28] Update disk.action.ps1 --- Actions/disk.action.ps1 | 86 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/Actions/disk.action.ps1 b/Actions/disk.action.ps1 index d6ab627..ff4bc0a 100644 --- a/Actions/disk.action.ps1 +++ b/Actions/disk.action.ps1 @@ -31,6 +31,49 @@ if ($Configuration.Label -and $Configuration.Label -ne $volume.FileSystemLabel) { $volume | Set-Volume -NewFileSystemLabel $Configuration.Label -ErrorAction Stop } + + #region BitLocker + $protectionMode = 'Ignore' + if ($Configuration.BitLocker) { $protectionMode = $Configuration.BitLocker } + if ('Ignore' -eq $protectionMode) { return $true } + + if ($invalidOptions = $protectionMode | Where-Object { $_ -notin 'Ignore', 'None', 'Tpm', 'RecoveryPassword' }) { + Write-PSFMessage -Level Warning -Message "Volume $($Configuration.Letter) has invalid bitlocker configuration: Unknown options $($invalidOptions -join ',')" + return + } + + $bitLockerInfo = Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore + if ('None' -eq $protectionMode) { + if ('Off' -eq $bitLockerInfo.ProtectionStatus) { return } + Write-PSFMessage -Level Warning -Message "Volume $($Configuration.Letter) is configured to be unencrypted but is protected by BitLocker. Drive decryption has not been implemented yet!" + return + } + + if ('Off' -eq $bitLockerInfo.ProtectionStatus) { + $param = @{ RecoveryPasswordProtector = $true } + if ($protectionMode -contains 'Tpm') { $param = @{ TpmProtector = $true } } + + $null = Enable-BitLocker -MountPoint "$($Configuration.Letter):" @param -Confirm:$false + } + + $bitLockerInfo = Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore + if ($protectionMode -contains 'Tpm' -and $bitLockerInfo.KeyProtector.KeyProtectorType -notcontains 'Tpm') { + $null = Add-BitLockerKeyProtector -MountPoint "$($Configuration.Letter):" -TpmProtector -Confirm:$false + } + if ($protectionMode -contains 'RecoveryPassword' -and $bitLockerInfo.KeyProtector.KeyProtectorType -notcontains 'RecoveryPassword') { + $null = Add-BitLockerKeyProtector -MountPoint "$($Configuration.Letter):" -RecoveryPasswordProtector -Confirm:$false + } + + $keyPath = $Configuration.BitLockerKeyPath -replace '%COMPUTERNAME%', $env:COMPUTERNAME + if (-not $keyPath) { return } + + $bitLockerInfo = Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore + $keyFileContent = Get-Content -Path $keyPath + $recoveryPassword = $bitLockerInfo.KeyProtector.RecoveryPassword | Remove-PSFNull + $hasKey = ($keyFileContent -match $recoveryPassword) -as [bool] + if ($hasKey) { return } + "{0}: {1}" -f $Configuration.Letter, $recoveryPassword | Add-Content -Path $keyPath + #endregion BitLocker } $validationCode = { @@ -51,6 +94,45 @@ $validationCode = { if ($Configuration.Label -and $Configuration.Label -ne $volume.FileSystemLabel) { return $false } + + #region Bitlocker + $protectionMode = 'Ignore' + if ($Configuration.BitLocker) { $protectionMode = $Configuration.BitLocker } + # If we don't care, just skip + if ('Ignore' -eq $protectionMode) { return $true } + + # Short way out if the file doesn't exist yet + $keyPath = $Configuration.BitLockerKeyPath -replace '%COMPUTERNAME%', $env:COMPUTERNAME + if ($keyPath -and -not (Test-Path -Path $keyPath)) { return $false } + + $protectionStatus = (Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore).ProtectionStatus + if ('On' -ne $protectionStatus) { return $protectionMode -eq 'None' } + + foreach ($option in $protectionMode) { + $protector = (Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore).KeyProtector | Where-Object KeyProtectorType -EQ $option + if (-not $protector) { + Write-PSFMessage "Protector not found: $option" + return $false + } + + switch ("$($protector.KeyProtectorType)") { + 'Tpm' { + # No fail condition - if it is set, that is ok + break + } + 'RecoveryPassword' { + if (-not $keyPath) { break } + $keyFileContent = Get-Content -Path $keyPath + $hasKey = ($keyFileContent -match $protector.RecoveryPassword) -as [bool] + if (-not $hasKey) { return $false } + } + default { + Write-PSFMessage -Level Warning -Message "Invalid Key Protector type! $option is not supported, provide either Tpm or RecoveryPassword protectors!" + return $false + } + } + } + #endregion Bitlocker $true } @@ -63,9 +145,11 @@ $param = @{ 'Lun' 'Letter' ) - ParameterOptional = @( + ParameterOptional = @( 'Label' 'Size' + 'BitLocker' # How should the disk be encrypted? Ignore, None, Tpm, RecoveryPassword (Can combine Tpm and RecoveryPassword) + 'BitLockerKeyPath' # Path to where a recovery key protector will be written ) Tag = 'volume', 'disk' } From 261d5460ad9a33d2a1b76e2e4a52dfeec389ead3 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Mon, 18 Jul 2022 16:16:21 +0200 Subject: [PATCH 20/28] Update disk.action.ps1 --- Actions/disk.action.ps1 | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Actions/disk.action.ps1 b/Actions/disk.action.ps1 index ff4bc0a..4808b90 100644 --- a/Actions/disk.action.ps1 +++ b/Actions/disk.action.ps1 @@ -53,15 +53,21 @@ $param = @{ RecoveryPasswordProtector = $true } if ($protectionMode -contains 'Tpm') { $param = @{ TpmProtector = $true } } - $null = Enable-BitLocker -MountPoint "$($Configuration.Letter):" @param -Confirm:$false + $null = Enable-BitLocker -MountPoint "$($Configuration.Letter):" @param -Confirm:$false -ErrorAction Stop } $bitLockerInfo = Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore if ($protectionMode -contains 'Tpm' -and $bitLockerInfo.KeyProtector.KeyProtectorType -notcontains 'Tpm') { - $null = Add-BitLockerKeyProtector -MountPoint "$($Configuration.Letter):" -TpmProtector -Confirm:$false + $null = Add-BitLockerKeyProtector -MountPoint "$($Configuration.Letter):" -TpmProtector -Confirm:$false -ErrorAction Stop } if ($protectionMode -contains 'RecoveryPassword' -and $bitLockerInfo.KeyProtector.KeyProtectorType -notcontains 'RecoveryPassword') { - $null = Add-BitLockerKeyProtector -MountPoint "$($Configuration.Letter):" -RecoveryPasswordProtector -Confirm:$false + $null = Add-BitLockerKeyProtector -MountPoint "$($Configuration.Letter):" -RecoveryPasswordProtector -Confirm:$false -ErrorAction Stop + } + + # For secondary drives, autounlock + $bitLockerInfo = Get-BitLockerVolume -MountPoint "$($Configuration.Letter):" -ErrorAction Ignore + if ($null -ne $bitLockerInfo.AutoUnlockEnabled -and -not $bitLockerInfo.AutoUnlockEnabled) { + Enable-BitLockerAutoUnlock -MountPoint "$($Configuration.Letter):" -ErrorAction Stop } $keyPath = $Configuration.BitLockerKeyPath -replace '%COMPUTERNAME%', $env:COMPUTERNAME From b32c22cb7d0c5fc9308df89ca8113ac16621a9e8 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Tue, 10 Oct 2023 08:52:58 +0200 Subject: [PATCH 21/28] Adding CertDeploy action --- Actions/certdeploy.action.ps1 | 90 +++++++++++++++++++++++++++++++++++ VMDeploy.Guest/changelog.md | 1 + 2 files changed, 91 insertions(+) create mode 100644 Actions/certdeploy.action.ps1 diff --git a/Actions/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 new file mode 100644 index 0000000..a110f86 --- /dev/null +++ b/Actions/certdeploy.action.ps1 @@ -0,0 +1,90 @@ +$executionCode = { + param ( + $Configuration + ) + + $filePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $Configuration.FileName + if (-not (Test-Path -Path $filePath)) { + Write-PSFMessage -Level Warning -Message "Certificate file not found in the VMDeploy package! Ensure the $($Configuration.FileName) certificate is deployed as a resource!" + return + } + $fullFilePath = (Get-Item -Path $filePath).FullName + try { $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) } + catch { + Write-PSFMessage -Level Warning -Message "Error opening certificate $($Configuration.FileName)" -ErrorRecord $_ + return + } + + try { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( + $Configuration.Store, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + $store.Open('ReadWrite') + } + catch { + Write-PSFMessage -Level Warning -Message "Error accessing certificate store $($Configuration.Store)" -ErrorRecord $_ + return + } + + try { $store.Add($certificate) } + catch { + Write-PSFMessage -Level Warning -Message "Error writing certificate $($Configuration.FileName) to certificate store $($Configuration.Store)" -ErrorRecord $_ + return + } +} + +$validationCode = { + param ( + $Configuration + ) + + $filePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $Configuration.FileName + if (-not (Test-Path -Path $filePath)) { + Write-PSFMessage -Level Warning -Message "Certificate file not found in the VMDeploy package! Ensure the $($Configuration.FileName) certificate is deployed as a resource!" + return $false + } + $fullFilePath = (Get-Item -Path $filePath).FullName + try { $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) } + catch { + Write-PSFMessage -Level Warning -Message "Error opening certificate $($Configuration.FileName)" -ErrorRecord $_ + return $false + } + + try { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( + $Configuration.Store, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + $store.Open('ReadOnly') + } + catch { + Write-PSFMessage -Level Warning -Message "Error accessing certificate store $($Configuration.Store)" -ErrorRecord $_ + return $false + } + $store.Certificates.ThumbPrint -contains $certificate.ThumbPrint +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) +} + +$param = @{ + Name = 'CertDeploy' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Deploys a certificate to the specified certificate store' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + 'FileName' + 'Store' + ) + ParameterOptional = @( + ) + Tag = 'certificate', 'pki' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index e52410b..af7c5bb 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -2,6 +2,7 @@ ## ??? ++ New: Action: CertDeploy - deploy a certificate (such as a root CA cert) to a selected certificate store + New: Action: ScriptBlock - execute a custom scriptblock + Upd: Action: Certificate - added CertRoles & RDP configuration + Upd: Action: ComputerName - disabled interactive user prompts From 3692ec9faa7a77dbe7cb51e304e71497ac937450 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Thu, 15 Feb 2024 12:25:37 +0100 Subject: [PATCH 22/28] adding PFX support --- Actions/certdeploy.action.ps1 | 54 ++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/Actions/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 index a110f86..575d88b 100644 --- a/Actions/certdeploy.action.ps1 +++ b/Actions/certdeploy.action.ps1 @@ -9,7 +9,18 @@ return } $fullFilePath = (Get-Item -Path $filePath).FullName - try { $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) } + $fullPWFilePath = "$($fullFilePath)_password" + $password = '' + if (Test-Path -LiteralPath $fullPWFilePath) { $password = Get-Content -LiteralPath $fullPWFilePath } + try { + if (-not $password) { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) + } + else { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() + $certificate.Import($filePath, $password, 'MachineKeySet') + } + } catch { Write-PSFMessage -Level Warning -Message "Error opening certificate $($Configuration.FileName)" -ErrorRecord $_ return @@ -45,7 +56,18 @@ $validationCode = { return $false } $fullFilePath = (Get-Item -Path $filePath).FullName - try { $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) } + $fullPWFilePath = "$($fullFilePath)_password" + $password = '' + if (Test-Path -LiteralPath $fullPWFilePath) { $password = Get-Content -LiteralPath $fullPWFilePath } + try { + if (-not $password) { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) + } + else { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() + $certificate.Import($filePath, $Configuration.Password, 'MachineKeySet') + } + } catch { Write-PSFMessage -Level Warning -Message "Error opening certificate $($Configuration.FileName)" -ErrorRecord $_ return $false @@ -62,7 +84,12 @@ $validationCode = { Write-PSFMessage -Level Warning -Message "Error accessing certificate store $($Configuration.Store)" -ErrorRecord $_ return $false } - $store.Certificates.ThumbPrint -contains $certificate.ThumbPrint + + $result = $store.Certificates.ThumbPrint -contains $certificate.ThumbPrint + if ($result) { + Remove-Item -LiteralPath $fullPWFilePath + } + $result } $PreDeploymentCode = { @@ -71,6 +98,25 @@ $PreDeploymentCode = { $WorkingDirectory ) + + $certPath = "$WorkingDirectory\Resources\$($Configuration.FileName)" + if (-not (Test-Path -Path $certPath)) { + throw "Certificate not found! $($Configuration.FileName)" + } + + if ($Configuration.FileName -notmatch '\.pfx$') { return } + + $securePassword = Read-Host "Specify password for certificate $($Configuration.FileName)" -AsSecureString + $password = [PSCredential]::new("Whatever", $securePassword).GetNetworkCredential().Password + + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() + try { $certificate.Import($Configuration.FileName, $password, 'EphemeralKeySet') } + catch { + throw "Password does not match Certificate" + } + + $certPasswordPath = "$($certPath)_password" + $password | Set-Content -Path $certPasswordPath } $param = @{ @@ -83,7 +129,7 @@ $param = @{ 'FileName' 'Store' ) - ParameterOptional = @( + ParameterOptional = @( ) Tag = 'certificate', 'pki' } From c44b19dcb4dea9ae7d92cc12dc888583778302f4 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Thu, 15 Feb 2024 14:14:13 +0100 Subject: [PATCH 23/28] updates --- Actions/certdeploy.action.ps1 | 2 +- Actions/ipsec.action.ps1 | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Actions/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 index 575d88b..679ec39 100644 --- a/Actions/certdeploy.action.ps1 +++ b/Actions/certdeploy.action.ps1 @@ -86,7 +86,7 @@ $validationCode = { } $result = $store.Certificates.ThumbPrint -contains $certificate.ThumbPrint - if ($result) { + if ($result -and (Test-Path -LiteralPath $fullPWFilePath)) { Remove-Item -LiteralPath $fullPWFilePath } $result diff --git a/Actions/ipsec.action.ps1 b/Actions/ipsec.action.ps1 index 5d7a378..39ea5ad 100644 --- a/Actions/ipsec.action.ps1 +++ b/Actions/ipsec.action.ps1 @@ -59,6 +59,19 @@ ) + begin { + # Remove local IP Address from remote addresses (if any) + # Including a local IP Address on a remote address for IPSec Rules is going to break networking + $localIPAddresses = [System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces().GetIPProperties().UnicastAddresses.ForEach{$_.Address}.IPAddressToString + $actualRemoteAddresses = $RemoteAddress | Where-Object { $_ -notin $localIPAddresses } + if (-not $actualRemoteAddresses) { + Write-PSFMessage -Level Warning -Message "No valid remote addresses remaining: $($RemoteAddress -join ',') were only local addresses!" -Data @{ + Remote = $RemoteAddress -join ',' + LocalIP = $localIPAddresses -join ',' + } + throw "No valid remote addresses remaining: $($RemoteAddress -join ',') were only local addresses!" + } + } process { $certProposal = New-NetIPsecAuthProposal -Machine -Cert -Authority $Authority -AuthorityType "root" -Signing RSA -ErrorAction SilentlyContinue $certAuthSet = New-NetIPsecPhase1AuthSet -PolicyStore 'localhost' -DisplayName $DisplayName -Proposal $certProposal -ErrorAction Stop From 37d1cedf09527821a7bc8d3982e9e3af77fee357 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Mon, 19 Feb 2024 07:04:08 +0100 Subject: [PATCH 24/28] updates --- Actions/certdeploy.action.ps1 | 4 ++-- Actions/ipsec.action.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Actions/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 index 679ec39..b1d56ac 100644 --- a/Actions/certdeploy.action.ps1 +++ b/Actions/certdeploy.action.ps1 @@ -110,9 +110,9 @@ $PreDeploymentCode = { $password = [PSCredential]::new("Whatever", $securePassword).GetNetworkCredential().Password $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() - try { $certificate.Import($Configuration.FileName, $password, 'EphemeralKeySet') } + try { $certificate.Import($certPath, $password, 'EphemeralKeySet') } catch { - throw "Password does not match Certificate" + throw "Password does not match Certificate: $_" } $certPasswordPath = "$($certPath)_password" diff --git a/Actions/ipsec.action.ps1 b/Actions/ipsec.action.ps1 index 39ea5ad..b308adb 100644 --- a/Actions/ipsec.action.ps1 +++ b/Actions/ipsec.action.ps1 @@ -75,7 +75,7 @@ process { $certProposal = New-NetIPsecAuthProposal -Machine -Cert -Authority $Authority -AuthorityType "root" -Signing RSA -ErrorAction SilentlyContinue $certAuthSet = New-NetIPsecPhase1AuthSet -PolicyStore 'localhost' -DisplayName $DisplayName -Proposal $certProposal -ErrorAction Stop - New-NetIPsecRule -PolicyStore 'localhost' -DisplayName $DisplayName -Name $DisplayName -InboundSecurity $Security -OutboundSecurity $Security -Phase1AuthSet $certAuthSet.Name -Mode Transport -LocalAddress $LocalAddress -RemoteAddress $RemoteAddress -Phase2AuthSet None -Protocol $Protocol -RemotePort $RemotePort -LocalPort $LocalPort -Enabled $Enabled + New-NetIPsecRule -PolicyStore 'localhost' -DisplayName $DisplayName -Name $DisplayName -InboundSecurity $Security -OutboundSecurity $Security -Phase1AuthSet $certAuthSet.Name -Mode Transport -LocalAddress $LocalAddress -RemoteAddress $actualRemoteAddresses -Phase2AuthSet None -Protocol $Protocol -RemotePort $RemotePort -LocalPort $LocalPort -Enabled $Enabled } } #endregion Functions From 9c55f6d5b2f9ca1244d854c32c3de6e0e624e96e Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 20 Mar 2024 10:28:02 +0100 Subject: [PATCH 25/28] updates --- Actions/certdeploy.action.ps1 | 41 +++++++++++------ Actions/certificate.action.ps1 | 2 +- Actions/debug.action.ps1 | 46 +++++++++++++++++++ Actions/debug_end.action.ps1 | 40 ++++++++++++++++ Actions/filecopy.action.ps1 | 10 +++- Actions/firewall.action.ps1 | 1 + Actions/ipsec.action.ps1 | 5 ++ Actions/ipsec_cryptoset.action.ps1 | 4 ++ VMDeploy.Guest/changelog.md | 2 + VMDeploy.Guest/en-us/strings.psd1 | 4 +- .../Invoke-VMGuestConfiguration.ps1 | 30 ++++++------ .../Test-VMGuestConfiguration.ps1 | 2 +- .../internal/scripts/initialize.ps1 | 14 ++++-- 13 files changed, 159 insertions(+), 42 deletions(-) create mode 100644 Actions/debug.action.ps1 create mode 100644 Actions/debug_end.action.ps1 diff --git a/Actions/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 index b1d56ac..2ec0699 100644 --- a/Actions/certdeploy.action.ps1 +++ b/Actions/certdeploy.action.ps1 @@ -3,6 +3,9 @@ $Configuration ) + $driveLetter = (Get-Item -Path VMDeploy:\).FullName -replace ':.+' + Get-Volume -DriveLetter $driveLetter | Get-Partition | Get-Disk | Set-Disk -IsReadOnly $false + $filePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $Configuration.FileName if (-not (Test-Path -Path $filePath)) { Write-PSFMessage -Level Warning -Message "Certificate file not found in the VMDeploy package! Ensure the $($Configuration.FileName) certificate is deployed as a resource!" @@ -14,11 +17,11 @@ if (Test-Path -LiteralPath $fullPWFilePath) { $password = Get-Content -LiteralPath $fullPWFilePath } try { if (-not $password) { - $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($fullFilePath) } else { $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() - $certificate.Import($filePath, $password, 'MachineKeySet') + $certificate.Import($fullFilePath, $password, 'MachineKeySet') } } catch { @@ -38,7 +41,10 @@ return } - try { $store.Add($certificate) } + try { + $store.Add($certificate) + $store.Close() + } catch { Write-PSFMessage -Level Warning -Message "Error writing certificate $($Configuration.FileName) to certificate store $($Configuration.Store)" -ErrorRecord $_ return @@ -57,21 +63,27 @@ $validationCode = { } $fullFilePath = (Get-Item -Path $filePath).FullName $fullPWFilePath = "$($fullFilePath)_password" + $fullThumbprintPath = "$($fullFilePath)_thumbprint" $password = '' - if (Test-Path -LiteralPath $fullPWFilePath) { $password = Get-Content -LiteralPath $fullPWFilePath } - try { - if (-not $password) { - $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromCertFile($fullFilePath) + if (Test-Path -LiteralPath $fullThumbprintPath) { + $certificate = @{ Thumbprint = Get-Content -LiteralPath $fullThumbprintPath | Select-Object -First 1 } + } + else { + if (Test-Path -LiteralPath $fullPWFilePath) { $password = Get-Content -LiteralPath $fullPWFilePath } + try { + if (-not $password) { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($fullFilePath) + } + else { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() + $certificate.Import($fullFilePath, $password, 'MachineKeySet') + } } - else { - $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() - $certificate.Import($filePath, $Configuration.Password, 'MachineKeySet') + catch { + Write-PSFMessage -Level Warning -Message "Error opening certificate $($Configuration.FileName)" -ErrorRecord $_ + return $false } } - catch { - Write-PSFMessage -Level Warning -Message "Error opening certificate $($Configuration.FileName)" -ErrorRecord $_ - return $false - } try { $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( @@ -88,6 +100,7 @@ $validationCode = { $result = $store.Certificates.ThumbPrint -contains $certificate.ThumbPrint if ($result -and (Test-Path -LiteralPath $fullPWFilePath)) { Remove-Item -LiteralPath $fullPWFilePath + $certificate.ThumbPrint | Set-Content -Path $fullThumbprintPath } $result } diff --git a/Actions/certificate.action.ps1 b/Actions/certificate.action.ps1 index 9bd5483..ed4c1bb 100644 --- a/Actions/certificate.action.ps1 +++ b/Actions/certificate.action.ps1 @@ -16,7 +16,7 @@ switch ($Configuration.CertRoles) { 'RDP' { $instance = Get-CimInstance -Namespace root\cimv2\TerminalServices -ClassName Win32_TSGeneralSetting -Filter 'TerminalName = "RDP-TCP"' - $instance | Set-CimInstance -Property @{ SSLCertificateSHA1Hash = $certObject.Thumbprint } + $instance | Set-CimInstance -Property @{ SSLCertificateSHA1Hash = $certObject.Thumbprint } -ErrorAction Stop } } } diff --git a/Actions/debug.action.ps1 b/Actions/debug.action.ps1 new file mode 100644 index 0000000..4a094c3 --- /dev/null +++ b/Actions/debug.action.ps1 @@ -0,0 +1,46 @@ +$executionCode = { + param ( + $Configuration + ) + + $driveLetter = (Get-Item -Path VMDeploy:\).FullName -replace ':.+' + Get-Volume -DriveLetter $driveLetter | Get-Partition | Get-Disk | Set-Disk -IsReadOnly $false + + $logPath = 'VMDeploy:\logs' + if (-not (Test-Path -Path $logPath)) { + $null = New-Item -Path $logPath -ItemType Directory -Force -ErrorAction Stop + } + $resolvedPath = (Get-Item -Path $logPath -ErrorAction Stop).FullName + + Set-PSFLoggingProvider -Name logfile -InstanceName VMDeployDebugLog -FilePath "$env:Temp\vmdeploy_debug_$(Get-Date -Format yyyy-MM-dd_HH-mm-ss).csv" -Enabled $true -Wait -CopyOnFinal $resolvedPath +} + +$validationCode = { + param ( + $Configuration + ) + + (Get-PSFLoggingProviderInstance -ProviderName logfile -Name VMDeployDebugLog | Where-Object Enabled) -as [bool] +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) +} + +$param = @{ + Name = 'debug' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Enables the Debug Log to the deployment disk' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + ) + ParameterOptional = @( + ) + Tag = 'debug' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/debug_end.action.ps1 b/Actions/debug_end.action.ps1 new file mode 100644 index 0000000..868fb91 --- /dev/null +++ b/Actions/debug_end.action.ps1 @@ -0,0 +1,40 @@ +$executionCode = { + param ( + $Configuration + ) + + try { Disable-PSFLoggingProvider -Name logfile -InstanceName VMDeployDebugLog } + catch { } + + Wait-PSFMessage +} + +$validationCode = { + param ( + $Configuration + ) + + -not (Get-PSFLoggingProviderInstance -ProviderName logfile -Name VMDeployDebugLog | Where-Object Enabled) +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $WorkingDirectory + ) +} + +$param = @{ + Name = 'debug_end' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Closes the debug log. Should be the last config to run and not persist its success' + PreDeploymentCode = $PreDeploymentCode + ParameterMandatory = @( + ) + ParameterOptional = @( + ) + Tag = 'debug' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/filecopy.action.ps1 b/Actions/filecopy.action.ps1 index 8a6602b..6a92c0d 100644 --- a/Actions/filecopy.action.ps1 +++ b/Actions/filecopy.action.ps1 @@ -5,7 +5,6 @@ foreach ($item in $Configuration.Path) { $targetPath = Join-Path -Path $Configuration.Destination -ChildPath $item - if (Test-Path -Path $targetPath) { continue } $sourcePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $item Copy-Item -Path $sourcePath -Destination $targetPath -Recurse -Force @@ -18,8 +17,17 @@ $validationCode = { ) foreach ($item in $Configuration.Path) { + $sourcePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $item $targetPath = Join-Path -Path $Configuration.Destination -ChildPath $item + if (-not (Test-Path -Path $targetPath)) { return $false } + + if (Test-Path -Path $sourcePath -PathType Leaf) { + $sourceHash = Get-FileHash -Path $sourcePath + $targetHash = Get-FileHash -Path $targetPath + + if ($sourceHash.Hash -ne $targetHash.Hash) { return $false } + } } $true diff --git a/Actions/firewall.action.ps1 b/Actions/firewall.action.ps1 index 8803c0e..88d6a09 100644 --- a/Actions/firewall.action.ps1 +++ b/Actions/firewall.action.ps1 @@ -27,6 +27,7 @@ $validationCode = { foreach ($property in $properties) { if ($Configuration.Keys -notcontains $property) { continue } if ($Configuration.$property -eq $fwConfig.$property) { continue } + Write-PSFMessage -Level Verbose -Message 'Firewall Config Mismatch in property "{0}". Found: {1} | Expected: {2}' -StringValues $property, $fwConfig.$property, $Configuration.$property return $false } $true diff --git a/Actions/ipsec.action.ps1 b/Actions/ipsec.action.ps1 index b308adb..5169a63 100644 --- a/Actions/ipsec.action.ps1 +++ b/Actions/ipsec.action.ps1 @@ -71,6 +71,7 @@ } throw "No valid remote addresses remaining: $($RemoteAddress -join ',') were only local addresses!" } + Write-PSFMessage -Level Debug -Message "Remote addresses for rule '{0}': {1}" -StringValues $DisplayName, ($actualRemoteAddresses -join ',') -Tag remoteaddresses } process { $certProposal = New-NetIPsecAuthProposal -Machine -Cert -Authority $Authority -AuthorityType "root" -Signing RSA -ErrorAction SilentlyContinue @@ -81,6 +82,10 @@ #endregion Functions Set-IPSecRule @Configuration + + # Enforce Policy Application + $null = gpupdate + Restart-Service ikeext } $validationCode = { diff --git a/Actions/ipsec_cryptoset.action.ps1 b/Actions/ipsec_cryptoset.action.ps1 index 73fed10..8c01b3b 100644 --- a/Actions/ipsec_cryptoset.action.ps1 +++ b/Actions/ipsec_cryptoset.action.ps1 @@ -25,6 +25,10 @@ if ($Configuration.Default) { $paramCrypto.Default = $Configuration.Default } $null = New-NetIPsecMainModeCryptoSet @paramCrypto + + # Enforce Policy Application + $null = gpupdate + Restart-Service ikeext } $validationCode = { diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index af7c5bb..1e3d059 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -4,9 +4,11 @@ + New: Action: CertDeploy - deploy a certificate (such as a root CA cert) to a selected certificate store + New: Action: ScriptBlock - execute a custom scriptblock ++ Upd: Invoke-VMGuestConfiguration - fixed processing order to ensure items are not flagged as success before dependencies are successful. + Upd: Action: Certificate - added CertRoles & RDP configuration + Upd: Action: ComputerName - disabled interactive user prompts + Upd: Action: firewall - updated/restructured filtering ++ Upd: Action: File_copy - added file validation via hash + Fix: Action: policy_clear - fixed broken validation for local machine policy (will get filled automatically) ## 1.0.0 (2021-04-07) diff --git a/VMDeploy.Guest/en-us/strings.psd1 b/VMDeploy.Guest/en-us/strings.psd1 index ce59fcc..8e037fd 100644 --- a/VMDeploy.Guest/en-us/strings.psd1 +++ b/VMDeploy.Guest/en-us/strings.psd1 @@ -3,7 +3,7 @@ @{ 'Import-VMGuestConfiguration.Config.Import' = 'Importing configuration from {0} : {1} ({2})' # $fileObject.FullName, $datumHash.Identity, $datumHash.Action 'Import-VMGuestConfiguration.File.AccessError' = 'Failed to access {0}' # $pathItem - 'Import-VMGuestConfiguration.File.Processing' = 'Failed to process {0}' # $pathItem + 'Import-VMGuestConfiguration.File.Processing' = 'Processing config file: {0}' # $pathItem 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' = 'The action {1} required for {0} is missing' # $configuration.Identity, $configuration.Action 'Invoke-VMGuestConfiguration.Configuration.DependencyNotDone' = 'Cannot process {0} ({1}) as dependency {2} has not been completed. Skipping.' # $configuration.Identity, $configuration.Action, $dependency @@ -16,8 +16,6 @@ 'Invoke-VMGuestConfiguration.Finished' = 'All configuration items have been applied, deployment complete' # 'Invoke-VMGuestConfiguration.InvokeCount.Exceeded' = 'Maximum number of guest configuration executions exceeded: {0} / {1}' # $currentInvokeCount, $MaxInvokeCount 'Invoke-VMGuestConfiguration.Processing.Starting' = 'Starting application of configuration entries' # - 'Invoke-VMGuestConfiguration.Test.Completed' = 'Initial test completed.' # - 'Invoke-VMGuestConfiguration.Test.Starting' = 'Starting initial test of defined configuration entries.' # 'Register-VMGuestConfiguration.BadParameters' = 'Error defining configuration {0} ({1}) - bad parameters. Missing required: {2} | Unknown parameters: {3}' # $Identity, $Action, ($parameterResult.MandatoryMissing -join ','), ($parameterResult.UnknownParameters -join ',') } \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 index 0d4e9ca..3081335 100644 --- a/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -56,27 +56,14 @@ } process { if ($die) { return } - - #region Gather configuration entry state before processing - Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Starting' - foreach ($configuration in Get-VMGuestConfiguration) { - Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration - $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet -NoPersistence - Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration - } - Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Test.Completed' - #endregion Gather configuration entry state before processing - + $configurations = Get-VMGuestConfiguration | Sort-Object Weight, Identity + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Processing.Starting' :main foreach ($configuration in $configurations) { - #region Check Prerequisites Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Configuration.Processing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration - if ($currentState[$configuration.Identity]) { - Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' -StringValues $configuration.Identity, $configuration.Action -Target $configuration - continue - } + #region Check Prerequisites if (-not $script:actions[$configuration.Action]) { Write-PSFMessage -Level Warning -String 'Invoke-VMGuestConfiguration.Configuration.ActionMissing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration continue @@ -88,11 +75,20 @@ continue main } } + + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet -NoPersistence + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.Testing.Completed' -StringValues $configuration.Identity, $configuration.Action, $currentState[$configuration.Identity] -Target $configuration + + if ($currentState[$configuration.Identity]) { + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Configuration.DoneSkipping' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + continue + } #endregion Check Prerequisites #region Implement Invoke-PSFProtectedCommand -ActionString 'Invoke-VMGuestConfiguration.Configuration.Execute' -ActionStringValues $configuration.Identity, $configuration.Action -ScriptBlock { - $null = $script:actions[$configuration.Action].ScriptBlock.Invoke($configuration.Parameters) + $null = & $script:actions[$configuration.Action].ScriptBlock $configuration.Parameters } -Target $configuration -Continue $currentState[$configuration.Identity] = Test-VMGuestConfiguration -Identity $configuration.Identity -Quiet diff --git a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 index 1eb15a5..61c0de9 100644 --- a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 +++ b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 @@ -69,7 +69,7 @@ #region Process Validation Script try { - $validateResult = $script:actions[$configuration.Action].Validate.Invoke($configuration.Parameters) | Where-Object { + $validateResult = & $script:actions[$configuration.Action].Validate $configuration.Parameters | Where-Object { $_ -is [bool] } | Select-Object -Last 1 } diff --git a/VMDeploy.Guest/internal/scripts/initialize.ps1 b/VMDeploy.Guest/internal/scripts/initialize.ps1 index d07a09a..82092f9 100644 --- a/VMDeploy.Guest/internal/scripts/initialize.ps1 +++ b/VMDeploy.Guest/internal/scripts/initialize.ps1 @@ -1,9 +1,13 @@ $paramSetPSFLoggingProvider = @{ - Name = 'eventlog' + Name = 'eventlog' InstanceName = 'VMDeploy.Guest' IncludeModules = 'VMDeploy.Guest' - Source = 'VMDeploy.Guest' - LogName = 'VMDeployment' - Enabled = $true + Source = 'VMDeploy.Guest' + LogName = 'VMDeployment' + Enabled = $true + Wait = $true } -Set-PSFLoggingProvider @paramSetPSFLoggingProvider \ No newline at end of file +Set-PSFLoggingProvider @paramSetPSFLoggingProvider + +$log = [System.Diagnostics.EventLog]::new("VMDeployment") +$log.MaximumKilobytes = 1MB \ No newline at end of file From 8aab49b7847defe784aefd69b119ec02e264965b Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Wed, 20 Mar 2024 10:28:58 +0100 Subject: [PATCH 26/28] Update changelog.md --- VMDeploy.Guest/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index 1e3d059..fa32a43 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -9,6 +9,8 @@ + Upd: Action: ComputerName - disabled interactive user prompts + Upd: Action: firewall - updated/restructured filtering + Upd: Action: File_copy - added file validation via hash ++ Upd: Action: ipsec - performs gpupdate and restarts IKEEXT service on execute ++ Upd: Action: ipsec_cryptoset - performs gpupdate and restarts IKEEXT service on execute + Fix: Action: policy_clear - fixed broken validation for local machine policy (will get filled automatically) ## 1.0.0 (2021-04-07) From 09848dc47eefff458795bec6af19e22e63993b4a Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Thu, 21 Mar 2024 08:07:33 +0100 Subject: [PATCH 27/28] updates --- Actions/certdeploy.action.ps1 | 15 +++++++++++++-- VMDeploy.Guest/VMDeploy.Guest.psd1 | 2 +- VMDeploy.Guest/changelog.md | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Actions/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 index 2ec0699..610c773 100644 --- a/Actions/certdeploy.action.ps1 +++ b/Actions/certdeploy.action.ps1 @@ -42,8 +42,19 @@ } try { - $store.Add($certificate) - $store.Close() + if ($password) { + $store.Close() + $param = @{ + FilePath = $fullFilePath + CertStoreLocation = "Cert:\LocalMachine\$($Configuration.Store)" + Password = $password | ConvertTo-SecureString -AsPlainText -Force + } + Import-PfxCertificate @param -ErrorAction Stop + } + else { + $store.Add($certificate) + $store.Close() + } } catch { Write-PSFMessage -Level Warning -Message "Error writing certificate $($Configuration.FileName) to certificate store $($Configuration.Store)" -ErrorRecord $_ diff --git a/VMDeploy.Guest/VMDeploy.Guest.psd1 b/VMDeploy.Guest/VMDeploy.Guest.psd1 index 6bcde75..29731eb 100644 --- a/VMDeploy.Guest/VMDeploy.Guest.psd1 +++ b/VMDeploy.Guest/VMDeploy.Guest.psd1 @@ -3,7 +3,7 @@ RootModule = 'VMDeploy.Guest.psm1' # Version number of this module. - ModuleVersion = '1.0.0' + ModuleVersion = '1.0.10' # ID used to uniquely identify this module GUID = '254b0808-8c89-47f9-b9fa-97e865a56d1e' diff --git a/VMDeploy.Guest/changelog.md b/VMDeploy.Guest/changelog.md index fa32a43..13c16af 100644 --- a/VMDeploy.Guest/changelog.md +++ b/VMDeploy.Guest/changelog.md @@ -1,6 +1,6 @@ # Changelog -## ??? +## 1.0.10 (2024-03-21) + New: Action: CertDeploy - deploy a certificate (such as a root CA cert) to a selected certificate store + New: Action: ScriptBlock - execute a custom scriptblock From 2ae6af0f09b6dbe90e1a84b6d402619f74e1d571 Mon Sep 17 00:00:00 2001 From: Friedrich Weinmann Date: Fri, 24 Jan 2025 14:04:09 +0100 Subject: [PATCH 28/28] Update network.action.ps1 --- Actions/network.action.ps1 | 62 +++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/Actions/network.action.ps1 b/Actions/network.action.ps1 index 618c141..46d6342 100644 --- a/Actions/network.action.ps1 +++ b/Actions/network.action.ps1 @@ -3,6 +3,36 @@ $Configuration ) + #region Override Get-NetConnectionProfile + # An interface that has two Connection Profiles will cause Get-NetIPConfiguration to fail + function Update-NetConnection { + [CmdletBinding()] + param () + + Import-Module NetConnection -Scope Global + function global:Get-MyNetConnectionProfile { + [Alias('Get-NetConnectionProfile')] + [CmdletBinding()] + param ( + $Name, + $InterfaceAlias, + $InterfaceIndex, + $NetworkCategory, + $IPv4Connectivity, + $IPv6Connectivity, + $CimSession, + $ThrottleLimit, + $AsJob + ) + + Write-Verbose "Executing overridden Get-NetConnectionProfile" + $command = Get-Command Get-NetConnectionProfile -CommandType Function + & $command @PSBoundParameters | Microsoft.PowerShell.Utility\Select-Object -First 1 + } + } + Update-NetConnection + #endregion Override Get-NetConnectionProfile + $netConfigs = Get-NetIPConfiguration $netConfig = $netConfigs | Where-Object InterfaceAlias -EQ Ethernet | Select-Object -First 1 if (-not $netConfig) { $netConfig = $netConfigs | Where-Object InterfaceAlias -NotLike 'Loopback Pseudo-Interface*' | Select-Object -First 1 } @@ -23,7 +53,7 @@ $ipAddress = $netConfig.IPv4Address[0] if ($Configuration.IPAddress) { $ipAddress = $netConfig.IPv4Address | Where-Object IPAddress -EQ $Configuration.IPAddress } if ($ipAddress.PrefixLength -ne $Configuration.PrefixLength) { - Set-NetIPAddress -IPAddress $ipAddress -InterfaceIndex 5 -PrefixLength $Configuration.PrefixLength -Confirm:$false + Set-NetIPAddress -IPAddress $ipAddress -InterfaceIndex $netConfig.InterfaceIndex -PrefixLength $Configuration.PrefixLength -Confirm:$false } } if ($Configuration.DefaultGateway) { @@ -46,6 +76,36 @@ $validationCode = { $Configuration ) + #region Override Get-NetConnectionProfile + # An interface that has two Connection Profiles will cause Get-NetIPConfiguration to fail + function Update-NetConnection { + [CmdletBinding()] + param () + + Import-Module NetConnection -Scope Global + function global:Get-MyNetConnectionProfile { + [Alias('Get-NetConnectionProfile')] + [CmdletBinding()] + param ( + $Name, + $InterfaceAlias, + $InterfaceIndex, + $NetworkCategory, + $IPv4Connectivity, + $IPv6Connectivity, + $CimSession, + $ThrottleLimit, + $AsJob + ) + + Write-Verbose "Executing overridden Get-NetConnectionProfile" + $command = Get-Command Get-NetConnectionProfile -CommandType Function + & $command @PSBoundParameters | Microsoft.PowerShell.Utility\Select-Object -First 1 + } + } + Update-NetConnection + #endregion Override Get-NetConnectionProfile + $netConfigs = Get-NetIPConfiguration $netConfig = $netConfigs | Where-Object InterfaceAlias -EQ Ethernet | Select-Object -First 1 if (-not $netConfig) { $netConfig = $netConfigs | Where-Object InterfaceAlias -NotLike 'Loopback Pseudo-Interface*' | Select-Object -First 1 }