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/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/certdeploy.action.ps1 b/Actions/certdeploy.action.ps1 new file mode 100644 index 0000000..610c773 --- /dev/null +++ b/Actions/certdeploy.action.ps1 @@ -0,0 +1,160 @@ +$executionCode = { + param ( + $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!" + return + } + $fullFilePath = (Get-Item -Path $filePath).FullName + $fullPWFilePath = "$($fullFilePath)_password" + $password = '' + 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') + } + } + 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 { + 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 $_ + 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 + $fullPWFilePath = "$($fullFilePath)_password" + $fullThumbprintPath = "$($fullFilePath)_thumbprint" + $password = '' + 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') + } + } + 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 + } + + $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 +} + +$PreDeploymentCode = { + param ( + $Configuration, + + $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($certPath, $password, 'EphemeralKeySet') } + catch { + throw "Password does not match Certificate: $_" + } + + $certPasswordPath = "$($certPath)_password" + $password | Set-Content -Path $certPasswordPath +} + +$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/Actions/certificate.action.ps1 b/Actions/certificate.action.ps1 new file mode 100644 index 0000000..ed4c1bb --- /dev/null +++ b/Actions/certificate.action.ps1 @@ -0,0 +1,249 @@ +$executionCode = { + param ( + $Configuration + ) + + $param = @{ + FilePath = "VMDeploy:\Resources\__cert_$($Configuration.Name).pfx" + CertStoreLocation = 'Cert:\LocalMachine\My' + 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 } -ErrorAction Stop + } + } +} + +$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") + + 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 = { + 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 + 'CertRoles' # Determines other execution logic to apply to the certificate after installation. + # Supported Roles: RDP + ) + Tag = 'certificate', 'pki' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/computername.action.ps1 b/Actions/computername.action.ps1 new file mode 100644 index 0000000..b9b503a --- /dev/null +++ b/Actions/computername.action.ps1 @@ -0,0 +1,29 @@ +$executionCode = { + param ( + $Configuration + ) + + Rename-Computer -NewName $Configuration.Name -ErrorAction Stop -WarningAction SilentlyContinue -Force -Confirm:$false +} + +$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/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/disk.action.ps1 b/Actions/disk.action.ps1 new file mode 100644 index 0000000..4808b90 --- /dev/null +++ b/Actions/disk.action.ps1 @@ -0,0 +1,162 @@ +$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 -ModuleName 'VMDeploy.Guest' + 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 + } + + #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 -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 -ErrorAction Stop + } + if ($protectionMode -contains 'RecoveryPassword' -and $bitLockerInfo.KeyProtector.KeyProtectorType -notcontains 'RecoveryPassword') { + $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 + 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 = { + 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 -ModuleName 'VMDeploy.Guest' + 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 + } + + #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 +} + +$param = @{ + Name = 'disk' + ScriptBlock = $executionCode + Validate = $validationCode + Description = 'Configure a disk' + ParameterMandatory = @( + 'Lun' + 'Letter' + ) + 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' +} +Register-VMGuestAction @param \ No newline at end of file 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..6a92c0d --- /dev/null +++ b/Actions/filecopy.action.ps1 @@ -0,0 +1,58 @@ +$executionCode = { + param ( + $Configuration + ) + + foreach ($item in $Configuration.Path) { + $targetPath = Join-Path -Path $Configuration.Destination -ChildPath $item + + $sourcePath = Join-Path -Path 'VMDeploy:\Resources' -ChildPath $item + Copy-Item -Path $sourcePath -Destination $targetPath -Recurse -Force + } +} + +$validationCode = { + param ( + $Configuration + ) + + 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 +} + +$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..88d6a09 --- /dev/null +++ b/Actions/firewall.action.ps1 @@ -0,0 +1,62 @@ +$executionCode = { + param ( + $Configuration + ) + + $properties = 'Exemptions','EnableStatefulFtp','EnableStatefulPptp','ActiveProfile','RemoteMachineTransportAuthorizationList','RemoteMachineTunnelAuthorizationList','RemoteUserTransportAuthorizationList','RemoteUserTunnelAuthorizationList','RequireFullAuthSupport','CertValidationLevel','AllowIPsecThroughNAT','MaxSAIdleTimeSeconds','KeyEncoding','EnablePacketQueuing' + $fwConfig = Get-NetFirewallSetting -PolicyStore Localhost + $param = @{ PolicyStore = 'Localhost' } + + foreach ($property in $properties) { + if ($Configuration.Keys -notcontains $property) { continue } + if ($Configuration.$property -eq $fwConfig.$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 -PolicyStore Localhost + + 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 +} + +$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/firewall_exemptions.action.ps1 b/Actions/firewall_exemptions.action.ps1 new file mode 100644 index 0000000..e7ef059 --- /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 -PolicyStore Localhost + $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 diff --git a/Actions/ipsec.action.ps1 b/Actions/ipsec.action.ps1 new file mode 100644 index 0000000..5169a63 --- /dev/null +++ b/Actions/ipsec.action.ps1 @@ -0,0 +1,122 @@ +$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" + + ) + + 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!" + } + 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 + $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 $actualRemoteAddresses -Phase2AuthSet None -Protocol $Protocol -RemotePort $RemotePort -LocalPort $LocalPort -Enabled $Enabled + } + } + #endregion Functions + + Set-IPSecRule @Configuration + + # Enforce Policy Application + $null = gpupdate + Restart-Service ikeext +} + +$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..8c01b3b --- /dev/null +++ b/Actions/ipsec_cryptoset.action.ps1 @@ -0,0 +1,77 @@ +$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 + + # Enforce Policy Application + $null = gpupdate + Restart-Service ikeext +} + +$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 new file mode 100644 index 0000000..46d6342 --- /dev/null +++ b/Actions/network.action.ps1 @@ -0,0 +1,154 @@ +$executionCode = { + param ( + $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 } + + 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 $netConfig.InterfaceIndex -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 + ) + + #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 } + + 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' + 'PrefixLength' + ) + Tag = 'network' +} +Register-VMGuestAction @param \ No newline at end of file diff --git a/Actions/policy_clear.action.ps1 b/Actions/policy_clear.action.ps1 new file mode 100644 index 0000000..dbd22c6 --- /dev/null +++ b/Actions/policy_clear.action.ps1 @@ -0,0 +1,86 @@ +$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-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/scriptblock.action.ps1 b/Actions/scriptblock.action.ps1 new file mode 100644 index 0000000..f5689eb --- /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' + ) + ParameterOptional = @( + 'Parameters' + ) + Tag = 'code' +} +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 +``` 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..29731eb --- /dev/null +++ b/VMDeploy.Guest/VMDeploy.Guest.psd1 @@ -0,0 +1,89 @@ +@{ + # Script module or binary module file associated with this manifest + RootModule = 'VMDeploy.Guest.psm1' + + # Version number of this module. + ModuleVersion = '1.0.10' + + # 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.201' } + ) + + # 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 = @( + 'Get-VMGuestAction' + 'Register-VMGuestAction' + 'Unregister-VMGuestAction' + + 'Clear-VMGuestConfiguration' + 'Get-VMGuestConfiguration' + 'Import-VMGuestConfiguration' + 'Invoke-VMGuestConfiguration' + 'Register-VMGuestConfiguration' + 'Test-VMGuestConfiguration' + + 'Get-VMGuestPersistentSuccess' + 'Set-VMGuestPersistentSuccess' + 'Test-VMGuestPersistentSuccess' + ) + + # 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..6514aef --- /dev/null +++ b/VMDeploy.Guest/VMDeploy.Guest.psm1 @@ -0,0 +1,85 @@ +$script:ModuleRoot = $PSScriptRoot +$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 +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..13c16af --- /dev/null +++ b/VMDeploy.Guest/changelog.md @@ -0,0 +1,18 @@ +# 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 ++ 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 ++ 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) + ++ Initial Upload 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..8e037fd --- /dev/null +++ b/VMDeploy.Guest/en-us/strings.psd1 @@ -0,0 +1,21 @@ +# 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' = '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 + '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.InvokeCount.Exceeded' = 'Maximum number of guest configuration executions exceeded: {0} / {1}' # $currentInvokeCount, $MaxInvokeCount + 'Invoke-VMGuestConfiguration.Processing.Starting' = 'Starting application of 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 new file mode 100644 index 0000000..a640eaa --- /dev/null +++ b/VMDeploy.Guest/functions/Actions/Get-VMGuestAction.ps1 @@ -0,0 +1,30 @@ +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')] + [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..e52ed83 --- /dev/null +++ b/VMDeploy.Guest/functions/Actions/Register-VMGuestAction.ps1 @@ -0,0 +1,97 @@ +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 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. + + .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)] + [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)] + [scriptblock] + $PreDeploymentCode, + + [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 + PreDeploymentCode = $PreDeploymentCode + 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..8bd689e --- /dev/null +++ b/VMDeploy.Guest/functions/Actions/Unregister-VMGuestAction.ps1 @@ -0,0 +1,34 @@ +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)] + [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..2d66b6d --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Clear-VMGuestConfiguration.ps1 @@ -0,0 +1,20 @@ +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 () + + 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..83fad84 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Get-VMGuestConfiguration.ps1 @@ -0,0 +1,28 @@ +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')] + [string] + $Identity = '*' + ) + + 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 new file mode 100644 index 0000000..7c22673 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Import-VMGuestConfiguration.ps1 @@ -0,0 +1,80 @@ +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)] + [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 -EnableException + } -Target $datumHash -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue + } + #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 new file mode 100644 index 0000000..3081335 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Invoke-VMGuestConfiguration.ps1 @@ -0,0 +1,112 @@ +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, + + [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 } + + $configurations = Get-VMGuestConfiguration | Sort-Object Weight, Identity + + Write-PSFMessage -String 'Invoke-VMGuestConfiguration.Processing.Starting' + :main foreach ($configuration in $configurations) { + Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Configuration.Processing' -StringValues $configuration.Identity, $configuration.Action -Target $configuration + + #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 + } + + 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 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 $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) { + Write-PSFMessage -Level Host -String 'Invoke-VMGuestConfiguration.Finished' -StringValue @($configurations).Count -Tag finished + } + } + end { + if ($die) { return } + + 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/Register-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..d3441e1 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Register-VMGuestConfiguration.ps1 @@ -0,0 +1,130 @@ +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 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. + 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)] + [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)] + [bool] + $Persistent = $true, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $Source = '', + + [switch] + $EnableException + ) + + 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 $_ -notin $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 + Weight = $Weight + Action = $Action + Parameters = $Parameters + DependsOn = $DependsOn + Persistent = $Persistent + Source = $Source + } + } +} \ No newline at end of file diff --git a/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 new file mode 100644 index 0000000..61c0de9 --- /dev/null +++ b/VMDeploy.Guest/functions/Configuration/Test-VMGuestConfiguration.ps1 @@ -0,0 +1,99 @@ +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 + + .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 + + Tests for each defined configuration entry, whether it has been successfully applied. +#> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [PsfArgumentCompleter('VMDeploy.Guest.ConfigurationItem')] + [string] + $Identity = '*', + + [switch] + $Quiet, + + [switch] + $NoPersistence + ) + + 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.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 { + $validateResult = & $script:actions[$configuration.Action].Validate $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' + if (-not $NoPersistence) { + Set-VMGuestPersistentSuccess -Identity $configuration.Identity -Value $true + } + } + 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/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..5cf5fc0 --- /dev/null +++ b/VMDeploy.Guest/functions/Persistence/Set-VMGuestPersistentSuccess.ps1 @@ -0,0 +1,52 @@ +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. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] + [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 + } +} \ No newline at end of file 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 + } +} diff --git a/VMDeploy.Guest/functions/readme.md b/VMDeploy.Guest/functions/readme.md new file mode 100644 index 0000000..ad3721f --- /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 diff --git a/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 b/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 new file mode 100644 index 0000000..e57c541 --- /dev/null +++ b/VMDeploy.Guest/internal/actions/NewFolder.action.ps1 @@ -0,0 +1,30 @@ +$validate = { + param ( + $Parameters + ) + + $exists = Test-Path -LiteralPath $Parameters.Path -PathType Container + if (-not $exists) { return $false } + $item = Get-Item -LiteralPath $Parameters.Path -Force + $item.PSProvider.Name -eq 'FileSystem' +} + +$scriptblock = { + param ( + $Parameters + ) + + if (Test-Path -LiteralPath $Parameters.Path -PathType Container) { return } + $null = New-Item -Path $Parameters.Path -ItemType Directory -Force -ErrorAction Stop +} + +$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/configurations/configuration.ps1 b/VMDeploy.Guest/internal/configurations/configuration.ps1 new file mode 100644 index 0000000..28770da --- /dev/null +++ b/VMDeploy.Guest/internal/configurations/configuration.ps1 @@ -0,0 +1,18 @@ +<# +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." + +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/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/initialize.ps1 b/VMDeploy.Guest/internal/scripts/initialize.ps1 new file mode 100644 index 0000000..82092f9 --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/initialize.ps1 @@ -0,0 +1,13 @@ +$paramSetPSFLoggingProvider = @{ + Name = 'eventlog' + InstanceName = 'VMDeploy.Guest' + IncludeModules = 'VMDeploy.Guest' + Source = 'VMDeploy.Guest' + LogName = 'VMDeployment' + Enabled = $true + Wait = $true +} +Set-PSFLoggingProvider @paramSetPSFLoggingProvider + +$log = [System.Diagnostics.EventLog]::new("VMDeployment") +$log.MaximumKilobytes = 1MB \ 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..03d3e6a --- /dev/null +++ b/VMDeploy.Guest/internal/scripts/postimport.ps1 @@ -0,0 +1,35 @@ +<# +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" + +# 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/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/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/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/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/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..ff9fb18 --- /dev/null +++ b/VMDeploy.Guest/tests/general/strings.Exceptions.ps1 @@ -0,0 +1,23 @@ +$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['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 new file mode 100644 index 0000000..73cc8e2 --- /dev/null +++ b/VMDeploy.Guest/tests/general/strings.Tests.ps1 @@ -0,0 +1,26 @@ +<# +.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 -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 + } + $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..a77483d --- /dev/null +++ b/VMDeploy.Guest/xml/VMDeploy.Guest.Format.ps1xml @@ -0,0 +1,75 @@ + + + + + + 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 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 + + +