├── image ├── src │ ├── .env │ ├── dev-patches │ │ ├── DebugOn │ │ │ └── Web.config.xdt │ │ ├── CustomErrorsOff │ │ │ └── Web.config.xdt │ │ ├── DevEnvOn │ │ │ └── Web.config.xdt │ │ ├── OptimizeCompilationsOn │ │ │ └── Web.config.xdt │ │ ├── HttpErrorsDetailed │ │ │ └── Web.config.xdt │ │ ├── XdbOff │ │ │ └── App_Config │ │ │ │ └── Environment │ │ │ │ └── XdbOff.config │ │ ├── RobotDetectionOff │ │ │ └── App_Config │ │ │ │ └── Environment │ │ │ │ └── RobotDetectionOff.config │ │ ├── DeviceDetectionOff │ │ │ └── App_Config │ │ │ │ └── Environment │ │ │ │ └── DeviceDetectionOff.config │ │ ├── DiagnosticsOff │ │ │ └── App_Config │ │ │ │ └── Environment │ │ │ │ └── DiagnosticsOff.config │ │ └── InitMessagesOff │ │ │ └── App_Config │ │ │ └── Environment │ │ │ └── InitMessagesOff.config │ ├── docker-compose.yml │ ├── Dockerfile │ ├── scripts │ │ ├── Install-ConfigurationFolder.ps1 │ │ ├── Get-PatchFolders.ps1 │ │ ├── Invoke-XdtTransform.ps1 │ │ ├── Deploy-TdsWdpPackages.ps1 │ │ └── Watch-Directory.ps1 │ └── entrypoints │ │ ├── worker │ │ └── Development.ps1 │ │ └── iis │ │ └── Development.ps1 ├── .vscode │ ├── settings.json │ ├── launch.json │ └── tasks.json ├── test │ ├── TestUtils.ps1 │ └── scripts │ │ ├── Get-PatchFolders.Tests.ps1 │ │ ├── Install-ConfigurationFolder.Tests.ps1 │ │ ├── Watch-Directory.Tests.ps1 │ │ └── Invoke-XdtTransform.Tests.ps1 └── build │ ├── azure-pipelines.yml │ ├── azure-pipelines-ltsc2019.yml │ ├── azure-pipelines-ltsc2022.yml │ └── azure-pipelines-ltsc2025.yml ├── .gitignore ├── .gitattributes ├── powershell ├── .vscode │ ├── settings.json │ ├── launch.json │ └── tasks.json ├── src │ ├── SitecoreDockerTools.nuspec │ ├── Private │ │ ├── Get-EnvFileContent.ps1 │ │ ├── Utilities.ps1 │ │ └── RSAKeyTool.ps1 │ ├── SitecoreDockerTools.psm1 │ ├── Public │ │ ├── Create-PfxFile.ps1 │ │ ├── Load-Certificate.ps1 │ │ ├── Remove-HostsEntry.ps1 │ │ ├── Add-HostsEntry.ps1 │ │ ├── ConvertTo-CompressedBase64String.ps1 │ │ ├── Get-SitecoreCertificateAsBase64String.ps1 │ │ ├── Get-EnvFileVariable.ps1 │ │ ├── Set-EnvFileVariable.ps1 │ │ ├── Create-SqlServerCertificate.ps1 │ │ ├── Import-Certificate.ps1 │ │ ├── Write-SitecoreDockerWelcome.ps1 │ │ └── Get-SitecoreRandomString.ps1 │ └── SitecoreDockerTools.psd1 ├── test │ ├── TestRunner.ps1 │ ├── Public │ │ ├── SitecoreDockerTools.Tests.ps1 │ │ ├── Import-Certificate.Tests.ps1 │ │ ├── ConvertTo-CompressedBase64String.Tests.ps1 │ │ ├── Create-PfxFile.Tests.ps1 │ │ ├── Create-SqlServerCertificate.Tests.ps1 │ │ ├── Get-SitecoreCertificateAsBase64String.Tests.ps1 │ │ ├── Get-EnvFileVariable.Tests.ps1 │ │ ├── Load-Certificate.Tests.ps1 │ │ ├── Get-SitecoreRandomString.Tests.ps1 │ │ ├── Add-HostsEntry.Tests.ps1 │ │ ├── Remove-HostsEntry.Tests.ps1 │ │ ├── Set-EnvFileVariable.Tests.ps1 │ │ └── Get-SitecoreSelfSignedCertificate.Tests.ps1 │ ├── Private │ │ ├── Utilities.Tests.ps1 │ │ ├── Get-EnvFileContent.Tests.ps1 │ │ └── RSAKeyTool.Tests.ps1 │ └── TestUtils.ps1 └── build │ └── azure-pipelines.yml ├── LICENSE └── README.md /image/src/.env: -------------------------------------------------------------------------------- 1 | REGISTRY= 2 | VERSION= 3 | BASE_IMAGE= 4 | BUILD_IMAGE= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | *.log 3 | obj 4 | bin 5 | *.exe 6 | *.dll 7 | Packages/ 8 | *.user -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto -------------------------------------------------------------------------------- /image/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // When enabled, will trim trailing whitespace when you save a file. 3 | "files.trimTrailingWhitespace": true 4 | } 5 | -------------------------------------------------------------------------------- /powershell/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // When enabled, will trim trailing whitespace when you save a file. 3 | "files.trimTrailingWhitespace": true 4 | } 5 | -------------------------------------------------------------------------------- /image/src/dev-patches/DebugOn/Web.config.xdt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /image/src/dev-patches/CustomErrorsOff/Web.config.xdt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /image/src/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | tools: 5 | image: ${REGISTRY}sitecore-docker-tools-assets:${VERSION:-latest} 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | args: 10 | BASE_IMAGE: ${BASE_IMAGE} 11 | BUILD_IMAGE: ${BUILD_IMAGE} -------------------------------------------------------------------------------- /image/src/dev-patches/DevEnvOn/Web.config.xdt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /image/src/dev-patches/OptimizeCompilationsOn/Web.config.xdt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /image/src/dev-patches/HttpErrorsDetailed/Web.config.xdt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /powershell/src/SitecoreDockerTools.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SitecoreDockerTools 5 | $VERSION$ 6 | Sitecore Corporation A/S 7 | PowerShell extensions for Docker-based Sitecore development 8 | Copyright (C) by Sitecore A/S 9 | 10 | -------------------------------------------------------------------------------- /image/src/dev-patches/XdbOff/App_Config/Environment/XdbOff.config: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /powershell/test/TestRunner.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(HelpMessage="The block of tests to run in the scope of the module")] 3 | [ScriptBlock]$TestScope 4 | ) 5 | 6 | if (Get-Module SitecoreDockerTools -ErrorAction SilentlyContinue) { 7 | Remove-Module SitecoreDockerTools -Force 8 | } 9 | Import-Module $PSScriptRoot\..\src\SitecoreDockerTools.psd1 -Force -Scope Global -ErrorAction Stop 10 | 11 | InModuleScope SitecoreDockerTools $TestScope -------------------------------------------------------------------------------- /image/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "PowerShell: Interactive Session", 9 | "type": "PowerShell", 10 | "request": "launch", 11 | "cwd": "" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /powershell/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "PowerShell: Interactive Session", 9 | "type": "PowerShell", 10 | "request": "launch", 11 | "cwd": "" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /image/src/dev-patches/RobotDetectionOff/App_Config/Environment/RobotDetectionOff.config: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /powershell/src/Private/Get-EnvFileContent.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | function Get-EnvFileContent { 4 | Param ( 5 | [Parameter(Mandatory = $true)] 6 | [ValidateScript( { Test-Path $_ -IsValid })] 7 | [string] 8 | $File 9 | ) 10 | 11 | if (!(Test-Path -Path $File)) { 12 | throw "$File not found." 13 | } 14 | try { 15 | return (Get-Content $File -Raw).Replace("\", "\\") | ConvertFrom-StringData 16 | } 17 | catch { 18 | throw "Error processing $File" 19 | } 20 | } -------------------------------------------------------------------------------- /powershell/src/SitecoreDockerTools.psm1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | #Requires -RunAsAdministrator 4 | 5 | # Get Functions 6 | $private = @(Get-ChildItem -Path (Join-Path $PSScriptRoot Private) -Include *.ps1 -File -Recurse) 7 | $public = @(Get-ChildItem -Path (Join-Path $PSScriptRoot Public) -Include *.ps1 -File -Recurse) 8 | 9 | # Dot source to scope 10 | # Private must be sourced first - usage in public functions during load 11 | ($private + $public) | ForEach-Object { 12 | try { 13 | . $_.FullName 14 | } 15 | catch { 16 | Write-Warning $_.Exception.Message 17 | } 18 | } -------------------------------------------------------------------------------- /image/test/TestUtils.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(HelpMessage="The block of tests to run in the scope of the module")] 3 | [ScriptBlock]$TestScope = $null 4 | ) 5 | 6 | Function Test-ParamIsMandatory { 7 | param( 8 | [Parameter(Mandatory=$true)] 9 | [string]$CommandName, 10 | [Parameter(Mandatory=$true)] 11 | [string]$Parameter, 12 | [string]$SetName = '' 13 | ) 14 | 15 | $cmd = Get-Command $CommandName 16 | $attr = $cmd.Parameters.$Parameter.Attributes | Where-Object { $_.TypeId -eq [System.Management.Automation.ParameterAttribute] } 17 | if($SetName) { 18 | $attr = $attr | Where-Object { $_.ParameterSetName -eq $SetName } 19 | } 20 | $attr.Mandatory 21 | } -------------------------------------------------------------------------------- /image/src/dev-patches/DeviceDetectionOff/App_Config/Environment/DeviceDetectionOff.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /image/src/dev-patches/DiagnosticsOff/App_Config/Environment/DiagnosticsOff.config: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /powershell/test/Public/SitecoreDockerTools.Tests.ps1: -------------------------------------------------------------------------------- 1 | $ModuleScriptName = 'SitecoreDockerTools.psm1' 2 | $ModuleManifestName = 'SitecoreDockerTools.psd1' 3 | $ModuleScriptPath = "$PSScriptRoot\..\..\src\$ModuleScriptName" 4 | $ModuleManifestPath = "$PSScriptRoot\..\..\src\$ModuleManifestName" 5 | 6 | if (!(Get-Module PSScriptAnalyzer -ErrorAction SilentlyContinue)) { 7 | Install-Module -Name PSScriptAnalyzer -Repository PSGallery -Force 8 | } 9 | 10 | Describe 'Module Tests' { 11 | It 'imports successfully' { 12 | { Import-Module -Name $ModuleScriptPath -ErrorAction Stop } | Should -Not -Throw 13 | } 14 | 15 | It 'passes default PSScriptAnalyzer rules' { 16 | Invoke-ScriptAnalyzer -Path $ModuleScriptPath | Should -BeNullOrEmpty 17 | } 18 | } 19 | 20 | Describe 'Module Manifest Tests' { 21 | It 'passes Test-ModuleManifest' { 22 | Write-Host $ModuleManifestPath 23 | Test-ModuleManifest -Path $ModuleManifestPath | Should -Not -BeNullOrEmpty 24 | $? | Should -Be $true 25 | } 26 | } -------------------------------------------------------------------------------- /image/src/dev-patches/InitMessagesOff/App_Config/Environment/InitMessagesOff.config: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sitecore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /powershell/test/Public/Import-Certificate.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Import-LoadedCertificate' { 5 | It 'requires $Certificate and $StoreName and $StoreLocation' { 6 | $result = Test-ParamIsMandatory -Command Import-LoadedCertificate -Parameter Certificate 7 | $result | Should Be $true 8 | $result = Test-ParamIsMandatory -Command Import-LoadedCertificate -Parameter StoreName 9 | $result | Should Be $true 10 | $result = Test-ParamIsMandatory -Command Import-LoadedCertificate -Parameter StoreLocation 11 | $result | Should Be $true 12 | } 13 | } 14 | 15 | Describe 'Import-CertificateForSigning' { 16 | It 'requires $SignerCertificate and $SignerCertificatePassword' { 17 | $result = Test-ParamIsMandatory -Command Import-CertificateForSigning -Parameter SignerCertificate 18 | $result | Should Be $true 19 | $result = Test-ParamIsMandatory -Command Import-CertificateForSigning -Parameter SignerCertificatePassword 20 | $result | Should Be $true 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /image/src/Dockerfile: -------------------------------------------------------------------------------- 1 | # escape=` 2 | 3 | ARG BASE_IMAGE 4 | ARG BUILD_IMAGE 5 | 6 | FROM ${BUILD_IMAGE} as build 7 | 8 | SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"] 9 | 10 | # Create working directories 11 | RUN New-Item -Path 'C:\\temp' -ItemType 'Directory' -Force | Out-Null; ` 12 | New-Item -Path 'C:\\tools' -ItemType 'Directory' -Force | Out-Null; ` 13 | New-Item -Path 'C:\\tools\\bin' -ItemType 'Directory' -Force | Out-Null; 14 | 15 | # Install NuGet 16 | ADD https://dist.nuget.org/win-x86-commandline/v5.2.0/nuget.exe /temp/ 17 | 18 | # Install Microsoft XDT assembly 19 | RUN & 'C:\\temp\\nuget.exe' install 'Microsoft.Web.Xdt' -Version '3.0.0' -OutputDirectory 'C:\\temp'; ` 20 | Copy-Item -Path 'C:\\temp\\Microsoft.Web.Xdt*\\lib\\netstandard2.0\\*.dll' -Destination 'C:\\tools\\bin'; ` 21 | Remove-Item -Path (Get-Item -Path 'C:\\temp\\Microsoft.Web.Xdt*\\').FullName -Recurse -Force; 22 | 23 | # Add entrypoints and scripts and patches 24 | COPY /entrypoints/ /tools/entrypoints/ 25 | COPY /scripts/ /tools/scripts/ 26 | COPY /dev-patches/ /tools/dev-patches/ 27 | 28 | FROM ${BASE_IMAGE} 29 | 30 | # Copy resulting tools 31 | COPY --from=build /tools/ /tools/ -------------------------------------------------------------------------------- /image/src/scripts/Install-ConfigurationFolder.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Applies a directory of Sitecore configuration patches to a destination path / web root. 4 | .DESCRIPTION 5 | Applies a directory of Sitecore configuration patches to a destination path / web root. Any .config 6 | files will be copied. The structure of the directory should match the structure of the intended destination in the web root. 7 | .PARAMETER Path 8 | Specifies the path of the target web root. 9 | .PARAMETER ConfigurationPath 10 | Specifies the path of the configuration patch collection. 11 | .EXAMPLE 12 | PS C:\> .\Install-ConfigurationFolder.ps1 -Path 'C:\inetpub\wwwroot' -PatchPath 'C:\tools\dev-patches\CustomErrorsOff' 13 | .INPUTS 14 | None 15 | .OUTPUTS 16 | None 17 | #> 18 | [CmdletBinding()] 19 | Param ( 20 | [Parameter(Mandatory = $true)] 21 | [ValidateScript( { Test-Path $_ -PathType Container })] 22 | [string]$Path, 23 | 24 | [Parameter(Mandatory = $true)] 25 | [ValidateScript( { Test-Path $_ -PathType Container })] 26 | [string]$PatchPath 27 | ) 28 | 29 | # We need to iterate children, otherwise Copy-Item includes the folder itself when copying 30 | Get-ChildItem $PatchPath | Copy-Item -Destination $Path -Filter *.config -Recurse -Force -Container -------------------------------------------------------------------------------- /powershell/test/Private/Utilities.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'WriteLines' { 5 | 6 | BeforeEach { 7 | Push-Location $TestDrive 8 | } 9 | 10 | AfterEach { 11 | Pop-Location 12 | } 13 | 14 | It 'throws on invalid path' { 15 | { WriteLines -File "z:\fail.txt" } | Should -Throw 16 | } 17 | 18 | It 'throws on unrooted path' { 19 | { WriteLines -File ".\file.txt" } | Should -Throw 20 | } 21 | 22 | It 'throws if lock retry limit has been reached' { 23 | $random = Get-Random 24 | $file = Join-Path -Path $TestDrive -ChildPath $random 25 | New-Item -Path $file 26 | $fileLock = [System.IO.File]::Open($file, 'Open', 'ReadWrite', 'None') 27 | 28 | { WriteLines -File $file -Content "Test" -Retries 5} | Should -Throw 29 | 30 | $fileLock.Close() 31 | } 32 | 33 | It 'writes content to file'{ 34 | $file = Join-Path -Path $TestDrive -ChildPath "normalfile.txt" 35 | $content = "ABC123" 36 | 37 | WriteLines -File $file -Content $content 38 | 39 | $result = Get-Content -Path $file 40 | $result | Should -Be $content 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /powershell/src/Private/Utilities.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | function WriteLines 4 | { 5 | Param ( 6 | [Parameter(Mandatory = $true)] 7 | [ValidateScript({ Test-Path $_ -IsValid })] 8 | [ValidateScript({ [System.IO.Path]::IsPathRooted($_) })] 9 | [string] 10 | $File, 11 | 12 | [string[]] 13 | $Content, 14 | 15 | [System.Text.Encoding] 16 | $Encoding = [System.Text.Encoding]::UTF8, 17 | 18 | [int] 19 | $Retries = 10 20 | ) 21 | 22 | $enc = $Encoding 23 | $crlf = $enc.GetBytes([Environment]::NewLine) 24 | $tries = 0 25 | $fileLock = $false 26 | 27 | if (!(Test-Path -Path $File)) { 28 | New-Item -Path $File 29 | } 30 | 31 | do { 32 | try { 33 | $fileLock = [System.IO.File]::Open($File, 'Open', 'ReadWrite', 'None') 34 | } 35 | catch { 36 | Write-Warning -Message "Failed to get lock on file. $File" 37 | $tries++ 38 | Start-Sleep -Milliseconds 100 39 | } 40 | } until ($fileLock -or ($tries -eq $Retries)) 41 | 42 | if ($tries -eq $Retries) { 43 | throw "Unable to get lock on file $File after $Retries attempt(s)." 44 | } 45 | 46 | $fileLock.SetLength(0) 47 | 48 | foreach ($line in $Content) { 49 | $newLine = $enc.GetBytes($line) 50 | $fileLock.Write($newLine, 0, $newLine.Length) 51 | $fileLock.Write($crlf, 0, $crlf.Length) 52 | } 53 | 54 | $fileLock.Close() 55 | } -------------------------------------------------------------------------------- /image/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${relativeFile}: the current opened file relative to workspaceRoot 5 | // ${fileBasename}: the current opened file's basename 6 | // ${fileDirname}: the current opened file's dirname 7 | // ${fileExtname}: the current opened file's extension 8 | // ${cwd}: the current working directory of the spawned process 9 | { 10 | // See https://go.microsoft.com/fwlink/?LinkId=733558 11 | // for the documentation about the tasks.json format 12 | "version": "2.0.0", 13 | 14 | // Start PowerShell 15 | "windows": { 16 | "command": "${env:windir}/System32/WindowsPowerShell/v1.0/powershell.exe", 17 | //"command": "${env:ProgramFiles}/PowerShell/6.0.0/powershell.exe", 18 | "args": [ "-NoProfile", "-ExecutionPolicy", "Bypass" ] 19 | }, 20 | 21 | // Associate with test task runner 22 | "tasks": [ 23 | { 24 | "label": "Test", 25 | "type": "shell", 26 | "group": { 27 | "kind": "test", 28 | "isDefault": true 29 | }, 30 | "args": [ 31 | "Write-Host 'Invoking Pester...';", 32 | "Invoke-Pester -Script test -PesterOption @{IncludeVSCodeMarker=$true};", 33 | "Write-Host 'Completed Test task in task runner.';" 34 | ], 35 | "problemMatcher": "$pester" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /powershell/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${relativeFile}: the current opened file relative to workspaceRoot 5 | // ${fileBasename}: the current opened file's basename 6 | // ${fileDirname}: the current opened file's dirname 7 | // ${fileExtname}: the current opened file's extension 8 | // ${cwd}: the current working directory of the spawned process 9 | { 10 | // See https://go.microsoft.com/fwlink/?LinkId=733558 11 | // for the documentation about the tasks.json format 12 | "version": "2.0.0", 13 | 14 | // Start PowerShell 15 | "windows": { 16 | "command": "${env:windir}/System32/WindowsPowerShell/v1.0/powershell.exe", 17 | //"command": "${env:ProgramFiles}/PowerShell/6.0.0/powershell.exe", 18 | "args": [ "-NoProfile", "-ExecutionPolicy", "Bypass" ] 19 | }, 20 | 21 | // Associate with test task runner 22 | "tasks": [ 23 | { 24 | "label": "Test", 25 | "type": "shell", 26 | "group": { 27 | "kind": "test", 28 | "isDefault": true 29 | }, 30 | "args": [ 31 | "Write-Host 'Invoking Pester...';", 32 | "Invoke-Pester -Script test -PesterOption @{IncludeVSCodeMarker=$true};", 33 | "Write-Host 'Completed Test task in task runner.';" 34 | ], 35 | "problemMatcher": "$pester" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /powershell/src/Public/Create-PfxFile.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Exports a certificate to a PFX file. 6 | .DESCRIPTION 7 | Exports the specified certificate to a PFX file at the given path, using the provided password to protect the PFX file. 8 | .PARAMETER Certificate 9 | The certificate to be exported. 10 | .PARAMETER OutCertPath 11 | The file path where the PFX file will be saved. 12 | .PARAMETER Password 13 | The password to protect the PFX file. 14 | .INPUTS 15 | None. You cannot pipe objects to Create-PfxFile. 16 | .OUTPUTS 17 | None. Create-PfxFile does not generate any output. 18 | .EXAMPLE 19 | PS C:\> $cert = Get-Item Cert:\CurrentUser\My\THUMBPRINT 20 | PS C:\> $password = ConvertTo-SecureString -String "password" -AsPlainText -Force 21 | PS C:\> Create-PfxFile -Certificate $cert -OutCertPath "C:\path\to\certificate.pfx" -Password $password 22 | #> 23 | function Create-PfxFile{ 24 | param ( 25 | [Parameter(Mandatory = $true)] 26 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 27 | $Certificate, 28 | 29 | [Parameter(Mandatory = $true)] 30 | [string]$OutCertPath, 31 | 32 | [Parameter(Mandatory = $true)] 33 | [SecureString]$Password 34 | ) 35 | 36 | Write-Information -MessageData "Exporting '$($Certificate.Thumbprint)' certificate into Pfx." -InformationAction Continue 37 | 38 | $pfxContent = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $Password) 39 | 40 | [System.IO.File]::WriteAllBytes($OutCertPath, $pfxContent) 41 | } -------------------------------------------------------------------------------- /powershell/src/Public/Load-Certificate.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Loads a certificate from a specified path. 6 | .DESCRIPTION 7 | Loads a certificate from the specified file path. If a password is provided, it will be used to decrypt the certificate. 8 | .PARAMETER CertPath 9 | The file path to the certificate to be loaded. 10 | .PARAMETER CertPassword 11 | The password for the certificate, if it is encrypted. This parameter is optional. 12 | .INPUTS 13 | None. You cannot pipe objects to Load-Certificate. 14 | .OUTPUTS 15 | System.Security.Cryptography.X509Certificates.X509Certificate2. The loaded certificate. 16 | .EXAMPLE 17 | PS C:\> Load-Certificate -CertPath "C:\path\to\certificate.pfx" 18 | PS C:\> Load-Certificate -CertPath "C:\path\to\certificate.pfx" -CertPassword (ConvertTo-SecureString -String "password" -AsPlainText -Force) 19 | #> 20 | function Load-Certificate { 21 | param( 22 | [Parameter(Mandatory = $true)] 23 | [string] 24 | $CertPath, 25 | 26 | [Parameter()] 27 | [SecureString] 28 | $CertPassword = $null 29 | ) 30 | 31 | Write-Information -MessageData "Loading '$CertPath' certificate." -InformationAction Continue 32 | 33 | # Reading into memory first as a workaround for 34 | # https://github.com/dotnet/runtime/issues/27826 35 | $certContent = [System.IO.File]::ReadAllBytes($CertPath) 36 | 37 | if ($CertPassword -ne $null) { 38 | $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @($certContent, $CertPassword) 39 | }else { 40 | $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$certContent) 41 | } 42 | 43 | return $cert 44 | } -------------------------------------------------------------------------------- /image/src/scripts/Get-PatchFolders.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Gets all the subfolders that exist under the provided path which are listed in the SITECORE_DEVELOPMENT_TRANSFORMS environment variable. 4 | .DESCRIPTION 5 | Splits the folder names in SITECORE_DEVELOPMENT_TRANSFORMS and finds matching patch folders under the provided path. 6 | .PARAMETER Path 7 | Specifies the path to search. 8 | .EXAMPLE 9 | PS C:\> .\Get-PatchFolders.ps1 -Path c:\tools\dev-patches 10 | .INPUTS 11 | None 12 | .OUTPUTS 13 | None 14 | #> 15 | Function Get-PatchFolders { 16 | Param ( 17 | [Parameter(Mandatory = $true)] 18 | [ValidateScript( { Test-Path $_ -PathType Container })] 19 | [string]$Path 20 | ) 21 | 22 | $folders = @() 23 | 24 | # Example: SITECORE_DEVELOPMENT_PATCHES=CustomErrorsOn,DebugOn,OptimizeCompilationsOn 25 | $folderNames = $env:SITECORE_DEVELOPMENT_PATCHES 26 | if (-not $folderNames) { 27 | return $folders 28 | } 29 | 30 | $illegalCharacters = [System.IO.Path]::GetInvalidFileNameChars() 31 | $folderNames.Split(",") | ForEach-Object { 32 | $patchFolder = $_ 33 | 34 | $validName = $true 35 | $illegalCharacters | ForEach-Object { 36 | if ($patchFolder.IndexOf($_) -gt 0) { 37 | Write-Host "** Sitecore development patch folder name $patchFolder is invalid" 38 | $validName = $false 39 | return 40 | } 41 | } 42 | if (-not $validName) { 43 | return 44 | } 45 | 46 | $folder = Join-Path $Path $patchFolder 47 | if (-not (Test-Path $folder)) { 48 | Write-Host "** Sitecore development patch folder $patchFolder not found in $Path" 49 | return 50 | } 51 | $folders += (Get-Item $folder) 52 | } 53 | $folders 54 | } -------------------------------------------------------------------------------- /image/src/entrypoints/worker/Development.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory = $false)] 4 | [hashtable]$WatchDirectoryParameters 5 | ) 6 | 7 | # Setup 8 | $ErrorActionPreference = "Stop" 9 | $InformationPreference = "Continue" 10 | $timeFormat = "HH:mm:ss:fff" 11 | $executable = "C:\\service\\$($env:WORKER_EXECUTABLE_NAME_ENV)" 12 | 13 | # Print start message 14 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: starting..." 15 | 16 | # Check to see if we should start the Watch-Directory.ps1 script 17 | $watchDirectoryJobName = "Watch-Directory.ps1" 18 | $useWatchDirectory = $null -ne $WatchDirectoryParameters -bor (Test-Path -Path "C:\deploy" -PathType "Container") -eq $true 19 | 20 | if ($useWatchDirectory) 21 | { 22 | # Setup default parameters if none is supplied 23 | if ($null -eq $WatchDirectoryParameters) 24 | { 25 | $WatchDirectoryParameters = @{ Path = "C:\deploy"; Destination = "C:\service"; } 26 | } 27 | $WatchDirectoryParameters["Executable"] = $executable 28 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: '$watchDirectoryJobName' validating..." 29 | 30 | # First a trial-run to catch any parameter validation / setup errors 31 | $WatchDirectoryParameters["WhatIf"] = $true 32 | & "C:\tools\scripts\Watch-Directory.ps1" @WatchDirectoryParameters 33 | $WatchDirectoryParameters["WhatIf"] = $false 34 | 35 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: '$watchDirectoryJobName' started." 36 | 37 | & "C:\tools\scripts\Watch-Directory.ps1" @WatchDirectoryParameters 38 | } 39 | else 40 | { 41 | Write-Host ("$(Get-Date -Format $timeFormat): Development ENTRYPOINT: Skipping start of '$watchDirectoryJobName'. To enable you should mount a directory into '$watchFolder'.") 42 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: ready!" 43 | & "$executable" 44 | } -------------------------------------------------------------------------------- /powershell/test/TestUtils.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(HelpMessage="The block of tests to run in the scope of the module")] 3 | [ScriptBlock]$TestScope = $null 4 | ) 5 | 6 | Function Test-ParamIsMandatory { 7 | param( 8 | [Parameter(Mandatory=$true)] 9 | [string]$CommandName, 10 | [Parameter(Mandatory=$true)] 11 | [string]$Parameter, 12 | [string]$SetName = '' 13 | ) 14 | 15 | $cmd = Get-Command $CommandName 16 | $attr = $cmd.Parameters.$Parameter.Attributes | Where-Object { $_.TypeId -eq [System.Management.Automation.ParameterAttribute] } 17 | if($SetName) { 18 | $attr = $attr | Where-Object { $_.ParameterSetName -eq $SetName } 19 | } 20 | $attr.Mandatory 21 | } 22 | 23 | Function Test-ParamValidateSet { 24 | param( 25 | [Parameter(Mandatory=$true)] 26 | [string]$CommandName, 27 | [Parameter(Mandatory=$true)] 28 | [string]$Parameter, 29 | [string[]]$Values 30 | ) 31 | 32 | $cmd = Get-Command $CommandName 33 | $attr = $cmd.Parameters.$Parameter.Attributes | Where-Object { $_.TypeId -eq [System.Management.Automation.ValidateSetAttribute] } 34 | 35 | Compare-Sets $attr.ValidValues $Values 36 | } 37 | 38 | Function Compare-Sets { 39 | param( 40 | [Parameter(Mandatory=$true)] 41 | [psobject[]]$Left, 42 | [Parameter(Mandatory=$true)] 43 | [psobject[]]$Right 44 | ) 45 | 46 | $results = Compare-Object $Left $Right 47 | if($results){ 48 | $formatter = { 49 | $obj = New-Object psobject -Property @{ 50 | State = if($_.SideIndicator.Contains('>')) { 'Missing' } else { 'Extra' } 51 | Value = $_.InputObject 52 | } 53 | 54 | Write-Host "$($Obj.State) => $($obj.Value)" 55 | } 56 | 57 | $results | ForEach-Object $formatter 58 | return $false 59 | } 60 | 61 | $true 62 | } -------------------------------------------------------------------------------- /powershell/test/Private/Get-EnvFileContent.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Get-EnvFileContent' { 5 | 6 | BeforeEach { 7 | Push-Location $TestDrive 8 | } 9 | 10 | AfterEach { 11 | Pop-Location 12 | } 13 | 14 | It 'throws on invalid path' { 15 | { Get-EnvFileContent -File "z:\fail.txt" } | Should -Throw 16 | } 17 | 18 | It 'throws on unrooted path' { 19 | { Get-EnvFileContent -File ".\file.txt" } | Should -Throw 20 | } 21 | 22 | It 'reads content from file the absolute path' { 23 | $envFile = Join-Path $TestDrive '.env' 24 | $content = @( 25 | 'VAR1=VAL1', 26 | 'VAR2=VAL2', 27 | 'VAR3=VAL3' 28 | ) 29 | Set-Content $envFile -Value $content 30 | 31 | $compare = @{ 32 | VAR1 = 'VAL1' 33 | VAR2 = 'VAL2' 34 | VAR3 = 'VAL3' 35 | } 36 | 37 | $result = Get-EnvFileContent -File $envFile 38 | $result.Count | Should -Be $compare.Keys.Count 39 | $result.Get_Item('VAR1') | Should -Be $compare.Get_Item('VAR1') 40 | } 41 | 42 | It 'reads content from file using the relative path' { 43 | $envFile = '.env' 44 | $content = @( 45 | 'VAR1=VAL1', 46 | 'VAR2=VAL2', 47 | 'VAR3=VAL3' 48 | ) 49 | Set-Content $envFile -Value $content 50 | 51 | $compare = @{ 52 | VAR1 = 'VAL1' 53 | VAR2 = 'VAL2' 54 | VAR3 = 'VAL3' 55 | } 56 | 57 | $result = Get-EnvFileContent -File $envFile 58 | $result.Count | Should -Be $compare.Keys.Count 59 | $result.Get_Item('VAR1') | Should -Be $compare.Get_Item('VAR1') 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /powershell/src/Public/Remove-HostsEntry.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Removes a host entry from the system hosts file. 6 | .DESCRIPTION 7 | Removes a host entry with the specified Hostname from the system hosts file (if it exist). 8 | A backup of the current hosts file is taken before updating. 9 | .PARAMETER Hostname 10 | The hostname to remove. 11 | .INPUTS 12 | None. You cannot pipe objects to Remove-HostsEntry. 13 | .OUTPUTS 14 | None. Remove-HostsEntry does not generate any output. 15 | .EXAMPLE 16 | PS C:\> Remove-HostsEntry 'my.host.name' 17 | #> 18 | function Remove-HostsEntry 19 | { 20 | Param ( 21 | [Parameter(Mandatory = $true, Position = 0)] 22 | [ValidateNotNullOrEmpty()] 23 | [string] 24 | $Hostname, 25 | 26 | [string] 27 | $Path = (Join-Path -Path $env:windir -ChildPath "system32\drivers\etc\hosts") 28 | ) 29 | 30 | if (-not (Test-Path $Path)) { 31 | Write-Warning "No hosts file found, hosts have not been updated" 32 | return 33 | } 34 | 35 | # Create backup 36 | Copy-Item $Path "$Path.backup" 37 | Write-Verbose "Created backup of hosts file to $Path.backup" 38 | 39 | # Build regex match pattern 40 | $pattern = '^[0-9a-f.:]+\s+' + [Regex]::Escape($HostName) + '\s*$' 41 | 42 | $hostsContent = @(Get-Content -Path $Path -Encoding UTF8) 43 | 44 | if (-not $hostsContent) { 45 | Write-Verbose "The hosts file is empty, hosts have not been updated" 46 | return 47 | } 48 | 49 | # Check if exists 50 | $updatedHostsContent = $hostsContent | Select-String -Pattern $pattern -NotMatch 51 | if ($null -ne $updatedHostsContent -and @(Compare-Object -ReferenceObject $hostsContent -DifferenceObject $updatedHostsContent).Count -eq 0) { 52 | Write-Verbose "No existing host entry found for hostname '$HostName'" 53 | return 54 | } 55 | 56 | # Remove it 57 | WriteLines -File $Path -Content $updatedHostsContent -Encoding ([System.Text.Encoding]::UTF8) 58 | Write-Verbose -Message "Host entry for hostname '$HostName' has been removed" 59 | } -------------------------------------------------------------------------------- /powershell/src/Public/Add-HostsEntry.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Adds a host entry to the system hosts file. 6 | .DESCRIPTION 7 | Adds a host entry with the specified Hostname and IPAddress to the system hosts file (if it does not already exist). 8 | A backup of the current hosts file is taken before updating. 9 | .PARAMETER Hostname 10 | The hostname to use for the entry. 11 | .PARAMETER IPAddress 12 | The IP address to use for the entry. Default is 127.0.0.1. 13 | .INPUTS 14 | None. You cannot pipe objects to Add-HostsEntry. 15 | .OUTPUTS 16 | None. Add-HostsEntry does not generate any output. 17 | .EXAMPLE 18 | PS C:\> Add-HostsEntry 'my.host.name' 19 | #> 20 | function Add-HostsEntry 21 | { 22 | Param ( 23 | [Parameter(Mandatory = $true, Position = 0)] 24 | [ValidateNotNullOrEmpty()] 25 | [string] 26 | $Hostname, 27 | 28 | [string] 29 | [ValidateNotNullOrEmpty()] 30 | $IPAddress = "127.0.0.1", 31 | 32 | [string] 33 | $Path = (Join-Path -Path $env:windir -ChildPath "system32\drivers\etc\hosts") 34 | ) 35 | 36 | if (-not (Test-Path $Path)) { 37 | Write-Warning "No hosts file found, hosts have not been updated" 38 | return 39 | } 40 | 41 | # Create backup 42 | Copy-Item $Path "$Path.backup" 43 | Write-Verbose "Created backup of hosts file to $Path.backup" 44 | 45 | # Build regex match pattern 46 | $pattern = '^' + [Regex]::Escape($IPAddress) + '\s+' + [Regex]::Escape($HostName) + '\s*$' 47 | 48 | $hostsContent = @(Get-Content -Path $Path -Encoding UTF8) 49 | 50 | # Check if exists 51 | $existingEntries = $hostsContent -match $pattern 52 | if ($existingEntries.Count -gt 0) { 53 | Write-Verbose "Existing host entry found for $IPAddress with hostname '$HostName'" 54 | return 55 | } 56 | 57 | # Add it 58 | $hostsContent += "$IPAddress`t$HostName" 59 | WriteLines -File $Path -Content $hostsContent -Encoding ([System.Text.Encoding]::UTF8) 60 | Write-Verbose "Host entry for $IPAddress with hostname '$HostName' has been added" 61 | } -------------------------------------------------------------------------------- /powershell/src/Public/ConvertTo-CompressedBase64String.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Converts file contents to GZip compressed, Base64 encoded value. 6 | .DESCRIPTION 7 | Converts file contents to GZip compressed, Base64 encoded value. 8 | .PARAMETER Stream 9 | Specifies the file stream. Either Stream or Path is required. 10 | .PARAMETER Path 11 | Specifies the file path. Either Stream or Path is required. 12 | .EXAMPLE 13 | PS C:\> ConvertTo-CompressedBase64String -Path C:\file.txt 14 | .EXAMPLE 15 | PS C:\> [System.IO.File]::OpenRead('C:\file.txt') | ConvertTo-CompressedBase64String 16 | .INPUTS 17 | System.IO.FileStream. You can pipe in the Stream parameter. 18 | .OUTPUTS 19 | System.String. The GZip compressed, Base64 encoded string. 20 | #> 21 | function ConvertTo-CompressedBase64String 22 | { 23 | [CmdletBinding(DefaultParameterSetName = 'FromPath')] 24 | Param ( 25 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'FromStream')] 26 | [System.IO.FileStream] 27 | $Stream, 28 | 29 | [Parameter(Mandatory = $true, ParameterSetName = 'FromPath')] 30 | [ValidateScript({ Test-Path $_ -PathType Leaf })] 31 | [string] 32 | $Path 33 | ) 34 | 35 | $encodedString = $null 36 | if ($PSCmdlet.ParameterSetName -eq 'FromPath') { 37 | $Stream = [System.IO.File]::OpenRead($Path) 38 | } 39 | 40 | try { 41 | $memory = [System.IO.MemoryStream]::new() 42 | $gzip = [System.IO.Compression.GZipStream]::new($memory, [System.IO.Compression.CompressionLevel]::Optimal, $false) 43 | $Stream.CopyTo($gzip) 44 | $gzip.Close() 45 | 46 | # base64 encode the gzipped content 47 | $encodedString = [System.Convert]::ToBase64String($memory.ToArray()) 48 | } 49 | finally { 50 | # cleanup 51 | if ($null -ne $gzip) { 52 | $gzip.Dispose() 53 | $gzip = $null 54 | } 55 | 56 | if ($null -ne $memory) { 57 | $memory.Dispose() 58 | $memory = $null 59 | } 60 | 61 | $Stream.Dispose() 62 | $Stream = $null 63 | } 64 | 65 | return $encodedString 66 | } -------------------------------------------------------------------------------- /powershell/src/Public/Get-SitecoreCertificateAsBase64String.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Generates a new self-signed certificate in its Base64 encoded form. 6 | .DESCRIPTION 7 | Generates a new self-signed certificate and returns the certificate in its password-protected, Base64 encoded form. 8 | .PARAMETER Password 9 | Specifies the password to be used for securing the certificate. 10 | .PARAMETER DnsName 11 | Specifies the DnsName to use for the certificate. Uses "localhost" by default. 12 | .EXAMPLE 13 | PS C:\> Get-SitecoreCertificateAsBase64String -DnsName "localhost" -Password (ConvertTo-SecureString -String "Password12345" -Force -AsPlainText) 14 | .INPUTS 15 | System.SecureString. You can pipe in the Password parameter. 16 | .OUTPUTS 17 | System.String. The Base64 encoded string. 18 | #> 19 | function Get-SitecoreCertificateAsBase64String 20 | { 21 | Param ( 22 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 23 | [securestring] 24 | $Password, 25 | 26 | [ValidateNotNullOrEmpty()] 27 | [string[]] 28 | $DnsName = "localhost", 29 | 30 | [Parameter()] 31 | [ValidateSet(512, 1024, 2048, 4096)] 32 | [int] 33 | $KeyLength = 2048 34 | ) 35 | 36 | $certRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( 37 | [X500DistinguishedName]::new("CN=$DnsName"), 38 | [System.Security.Cryptography.RSA]::Create($KeyLength), 39 | [System.Security.Cryptography.HashAlgorithmName]::SHA256, 40 | [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) 41 | 42 | $basicConstraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $false) 43 | $certRequest.CertificateExtensions.Add($basicConstraints) 44 | $subjectKeyIdentifier = [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($certRequest.PublicKey, $false) 45 | $certRequest.CertificateExtensions.Add($subjectKeyIdentifier) 46 | 47 | $certificate = $certRequest.CreateSelfSigned([datetime]::Now, [datetime]::Now.AddYears(5)) 48 | $certificateBytes = $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $Password) 49 | 50 | return [System.Convert]::ToBase64String($certificateBytes) 51 | } -------------------------------------------------------------------------------- /powershell/src/Public/Get-EnvFileVariable.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Gets the value of a variable in a Docker environment (.env) file. 6 | .DESCRIPTION 7 | Gets the value of a variable in a Docker environment (.env) file. 8 | Assumes .env file is in the current directory by default. 9 | .PARAMETER Variable 10 | Specifies the variable name. 11 | .PARAMETER Path 12 | Specifies the Docker environment (.env) file path. Assumes .env file is in the current directory by default. 13 | .EXAMPLE 14 | PS C:\> Get-EnvFileVariable -Variable VAR1 15 | .EXAMPLE 16 | PS C:\> Get-EnvFileVariable "VAR1" 17 | .EXAMPLE 18 | PS C:\> Get-EnvFileVariable -Variable VAR1 -Path .\src\.env 19 | .INPUTS 20 | System.String. 21 | .OUTPUTS 22 | System.String. Value of variable. System.Exception if key is not present 23 | #> 24 | function Get-EnvFileVariable { 25 | Param ( 26 | [Parameter(Mandatory = $true, Position = 0)] 27 | [ValidateNotNullOrEmpty()] 28 | [string] 29 | $Variable, 30 | 31 | [string] 32 | $Path = ".\.env" 33 | ) 34 | 35 | if (!(Test-Path $Path)) { 36 | throw "The environment file $Path does not exist" 37 | } 38 | 39 | $variables = Get-EnvFileContent $Path 40 | try { 41 | if ($variables.ContainsKey($variable)) { 42 | $rawVariable = $variables.Get_Item($variable) 43 | if (IsLiteral -Value $rawVariable) { 44 | #strip out the start/end single quotes if it's a literal value and un-escape. 45 | return $rawVariable.Substring(1, $rawVariable.Length - 2).Replace("''", "'") 46 | } 47 | return $rawVariable 48 | } 49 | else { 50 | throw "Unable to find value for $Variable in $Path" 51 | } 52 | } 53 | catch { 54 | throw "Unable to find value for $Variable in $Path" 55 | } 56 | } 57 | 58 | function IsLiteral { 59 | param( 60 | [Parameter(Mandatory = $true)] 61 | [string] 62 | $Value 63 | ) 64 | if(!$Value.StartsWith("'")){ return $false } 65 | #Is it a literal value? Test for an odd number of starting 's to avoid escaped values and ending with ' 66 | $nonQuoteIndex = [regex]::Match($Value, "[^']") 67 | return ($nonQuoteIndex.Success -and $nonQuoteIndex.Index % 2 -eq 1 -and $Value.EndsWith("'")) 68 | } -------------------------------------------------------------------------------- /powershell/test/Public/ConvertTo-CompressedBase64String.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | function GetExpectedResult([string] $path) { 5 | $stream = [System.IO.File]::OpenRead($path) 6 | $memory = [System.IO.MemoryStream]::new() 7 | $gzip = [System.IO.Compression.GZipStream]::new($memory, [System.IO.Compression.CompressionLevel]::Optimal, $false) 8 | $stream.CopyTo($gzip) 9 | $gzip.Close() 10 | $expectedResult = [System.Convert]::ToBase64String($memory.ToArray()) 11 | 12 | $gzip.Dispose() 13 | $gzip = $null 14 | $memory.Dispose() 15 | $memory = $null 16 | $stream.Dispose() 17 | $stream = $null 18 | 19 | return $expectedResult 20 | } 21 | 22 | Describe 'ConvertTo-CompressedBase64String' { 23 | 24 | BeforeAll { 25 | $testPath = Join-Path $TestDrive 'test.txt' 26 | Set-Content $testPath -value "Lorem ipsum dolor sit amet." 27 | $expectedResult = GetExpectedResult $testPath 28 | } 29 | 30 | It 'requires $Path or $Stream' { 31 | $result = Test-ParamIsMandatory -Command ConvertTo-CompressedBase64String -Parameter Path -SetName 'FromPath' 32 | $result | Should -Be $true 33 | $result = Test-ParamIsMandatory -Command ConvertTo-CompressedBase64String -Parameter Stream -SetName 'FromStream' 34 | $result | Should -Be $true 35 | } 36 | 37 | It 'throws if $Path is $null' { 38 | { ConvertTo-CompressedBase64String -Path $null } | Should -Throw 39 | } 40 | 41 | It 'throws if $Path is empty' { 42 | { ConvertTo-CompressedBase64String -Path "" } | Should -Throw 43 | } 44 | 45 | It 'throws if $Path is invalid' { 46 | { ConvertTo-CompressedBase64String -Path "NO:\\NO" } | Should -Throw 47 | } 48 | 49 | It 'throws if $Path is a folder' { 50 | { ConvertTo-CompressedBase64String -Path $TestDrive } | Should -Throw 51 | } 52 | 53 | It 'returns compressed base64 string from $Path' { 54 | $result = ConvertTo-CompressedBase64String -Path $testPath 55 | $result | Should -Be $expectedResult 56 | } 57 | 58 | It 'returns compressed base64 string from $Stream' { 59 | $result = [System.IO.File]::OpenRead($testPath) | ConvertTo-CompressedBase64String 60 | $result | Should -Be $expectedResult 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /powershell/src/Public/Set-EnvFileVariable.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Sets a variable in a Docker environment (.env) file. 6 | .DESCRIPTION 7 | Sets a variable in a Docker environment (.env) file. 8 | Assumes .env file is in the current directory by default. 9 | .PARAMETER Variable 10 | Specifies the variable name. 11 | .PARAMETER Value 12 | Specifies the variable value. 13 | .PARAMETER AsLiteral 14 | Specifies whether the Value should be written as a literal (i.e wrapped in single quotes) 15 | .PARAMETER Path 16 | Specifies the Docker environment (.env) file path. Assumes .env file is in the current directory by default. 17 | .EXAMPLE 18 | PS C:\> Set-EnvFileVariable -Variable VAR1 -Value "value one" 19 | .EXAMPLE 20 | PS C:\> "value one" | Set-EnvFileVariable "VAR1" 21 | .EXAMPLE 22 | PS C:\> Set-EnvFileVariable -Variable VAR1 -Value "value one" -Path .\src\.env 23 | .EXAMPLE 24 | PS C:\> Set-EnvFileVariable -Variable VAR1 -Value "literal $tring" -AsLiteral 25 | .INPUTS 26 | System.String. You can pipe in the Value parameter. 27 | .OUTPUTS 28 | None. 29 | #> 30 | function Set-EnvFileVariable 31 | { 32 | Param ( 33 | [Parameter(Mandatory = $true, Position = 0)] 34 | [ValidateNotNullOrEmpty()] 35 | [string] 36 | $Variable, 37 | 38 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 39 | [ValidateNotNull()] 40 | [AllowEmptyString()] 41 | [string] 42 | $Value, 43 | 44 | [switch] 45 | $AsLiteral = $false, 46 | 47 | [string] 48 | $Path = ".\.env" 49 | ) 50 | 51 | if (!(Test-Path $Path)) { 52 | throw "The environment file $Path does not exist" 53 | } 54 | 55 | if ($AsLiteral){ 56 | # Escape any ' to avoid terminating the value unexpectedly 57 | $Value = "'$($Value.Replace("'", "''"))'" 58 | } 59 | 60 | $found = $false 61 | 62 | $lines = @(Get-Content $Path -Encoding UTF8 | ForEach-Object { 63 | if ($_ -imatch "^$Variable=.*") { 64 | # Escape any '$' to prevent being used as a regex substitution 65 | $Value = $Value.Replace('$', '$$') 66 | $_ -ireplace "^$Variable=.*", "$Variable=$Value" 67 | $found = $true 68 | } 69 | else { 70 | $_ 71 | } 72 | }) 73 | 74 | if (!$found) { 75 | $lines += "$Variable=$Value" 76 | } 77 | 78 | WriteLines -File (Resolve-Path $Path) -Content $lines -Encoding ([System.Text.Encoding]::UTF8) 79 | } 80 | 81 | # For backward compatibility 82 | New-Alias -Name Set-DockerComposeEnvFileVariable -Value Set-EnvFileVariable -------------------------------------------------------------------------------- /image/src/entrypoints/iis/Development.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter(Mandatory = $false)] 4 | [hashtable]$WatchDirectoryParameters 5 | ) 6 | 7 | # Setup 8 | $ErrorActionPreference = "Stop" 9 | $InformationPreference = "Continue" 10 | $timeFormat = "HH:mm:ss:fff" 11 | 12 | # Print start message 13 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: starting..." 14 | 15 | # Check to see if we should start the Watch-Directory.ps1 script 16 | $watchDirectoryJobName = "Watch-Directory.ps1" 17 | $useWatchDirectory = $null -ne $WatchDirectoryParameters -bor (Test-Path -Path "C:\deploy" -PathType "Container") -eq $true 18 | 19 | if ($useWatchDirectory) 20 | { 21 | # Setup default parameters if none is supplied 22 | if ($null -eq $WatchDirectoryParameters) 23 | { 24 | $WatchDirectoryParameters = @{ Path = "C:\deploy"; Destination = "C:\inetpub\wwwroot"; } 25 | } 26 | 27 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: '$watchDirectoryJobName' validating..." 28 | 29 | # First a trial-run to catch any parameter validation / setup errors 30 | $WatchDirectoryParameters["WhatIf"] = $true 31 | & "C:\tools\scripts\Watch-Directory.ps1" @WatchDirectoryParameters 32 | $WatchDirectoryParameters["WhatIf"] = $false 33 | 34 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: '$watchDirectoryJobName' starting..." 35 | 36 | # Start Watch-Directory.ps1 in background 37 | Start-Job -Name $watchDirectoryJobName -ArgumentList $WatchDirectoryParameters -ScriptBlock { 38 | param([hashtable]$params) 39 | 40 | & "C:\tools\scripts\Watch-Directory.ps1" @params 41 | 42 | } | Out-Null 43 | 44 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: '$watchDirectoryJobName' started." 45 | } 46 | else 47 | { 48 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: Skipping start of '$watchDirectoryJobName'. To enable you should mount a directory into 'C:\deploy'." 49 | } 50 | 51 | # Apply any patch folders configured in SITECORE_DEVELOPMENT_PATCHES 52 | Write-Host "$(Get-Date -Format $timeFormat): Applying SITECORE_DEVELOPMENT_PATCHES..." 53 | Push-Location $PSScriptRoot\..\..\ 54 | try { 55 | . .\scripts\Get-PatchFolders.ps1 56 | Get-PatchFolders -Path dev-patches | ForEach-Object { 57 | Write-Host "$(Get-Date -Format $timeFormat): Applying development patches from $($_.Name)" 58 | & .\scripts\Invoke-XdtTransform.ps1 -XdtPath $_.FullName -Path $WatchDirectoryParameters.Destination 59 | & .\scripts\Install-ConfigurationFolder.ps1 -PatchPath $_.FullName -Path $WatchDirectoryParameters.Destination 60 | } 61 | } finally { 62 | Pop-Location 63 | } 64 | 65 | # Print ready message 66 | Write-Host "$(Get-Date -Format $timeFormat): Development ENTRYPOINT: ready!" 67 | 68 | & "C:\LogMonitor\LogMonitor.exe" "powershell" "C:\Run-W3SVCService.ps1" -------------------------------------------------------------------------------- /powershell/src/Public/Create-SqlServerCertificate.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Creates a self-signed certificate for SQL Server. 6 | .DESCRIPTION 7 | Creates a self-signed certificate with the specified CommonName, DnsName, and SignerCertificate. 8 | The certificate is configured for SSL Server Authentication and uses RSA with SHA256. 9 | .PARAMETER CommonName 10 | The common name (CN) to use for the certificate subject. 11 | .PARAMETER DnsName 12 | The DNS name to include in the certificate. 13 | .PARAMETER SignerCertificate 14 | The certificate used to sign the new certificate. 15 | .PARAMETER NotBefore 16 | The start date and time for the certificate validity period. Default is the current date and time. 17 | .PARAMETER NotAfter 18 | The end date and time for the certificate validity period. Default is 3285 days from the current date and time. 19 | .INPUTS 20 | None. You cannot pipe objects to Create-SqlServerCertificate. 21 | .OUTPUTS 22 | System.Security.Cryptography.X509Certificates.X509Certificate2. The created certificate. 23 | .EXAMPLE 24 | PS C:\> $signerCert = Get-Item Cert:\LocalMachine\My\{THUMBPRINT} 25 | PS C:\> Create-SqlServerCertificate -CommonName 'sql.server' -DnsName 'localhost' -SignerCertificate $signerCert 26 | #> 27 | function Create-SqlServerCertificate{ 28 | param( 29 | [Parameter(Mandatory=$true)] 30 | [Alias("CN")] 31 | [string] 32 | $CommonName, 33 | 34 | [Parameter(Mandatory=$true)] 35 | [string] 36 | $DnsName, 37 | 38 | [Parameter(Mandatory=$true)] 39 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 40 | $SignerCertificate, 41 | 42 | [Parameter()] 43 | [System.DateTimeOffset] 44 | $NotBefore = [System.DateTimeOffset]::UtcNow, 45 | 46 | [Parameter()] 47 | [System.DateTimeOffset] 48 | $NotAfter = [System.DateTimeOffset]::UtcNow.AddDays(3285) 49 | ) 50 | 51 | Write-Information -MessageData "Creating a certificate for SqlServer with '$($SignerCertificate.Thumbprint)' signer." -InformationAction Continue 52 | 53 | $certificateParams = @{ 54 | Type = "SSLServerAuthentication" 55 | Subject = "CN=$CommonName" 56 | DnsName = @($DnsName, 'localhost') 57 | KeyAlgorithm = "RSA" 58 | KeyLength = 2048 59 | HashAlgorithm = "SHA256" 60 | TextExtension = "2.5.29.37={text}1.3.6.1.5.5.7.3.1" 61 | NotBefore = $NotBefore.DateTime 62 | NotAfter = $NotAfter.DateTime 63 | KeySpec = "KeyExchange" 64 | Provider = "Microsoft RSA SChannel Cryptographic Provider" 65 | Signer = $SignerCertificate 66 | FriendlyName = "Sitecore Container Development Sql Server Certificate" 67 | }; 68 | 69 | $certificate = New-SelfSignedCertificate @certificateParams 70 | 71 | return $certificate 72 | } 73 | -------------------------------------------------------------------------------- /powershell/test/Public/Create-PfxFile.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Create-PfxFile' { 5 | $certPath = Join-Path -Path $TestDrive -ChildPath "certificate.pfx" 6 | $certPassword = ConvertTo-SecureString -String "password" -AsPlainText -Force 7 | 8 | BeforeAll { 9 | $certificate = New-SelfSignedCertificate -DnsName "mockCertificate" -CertStoreLocation "Cert:\CurrentUser\My" 10 | } 11 | 12 | AfterAll { 13 | if ($certificate -ne $null) { 14 | Remove-Item -Path $certificate.PSPath -Force 15 | } 16 | } 17 | 18 | It 'requires $Certificate and $OutCertPath and $Password' { 19 | $result = Test-ParamIsMandatory -Command Create-PfxFile -Parameter Certificate 20 | $result | Should Be $true 21 | $result = Test-ParamIsMandatory -Command Create-PfxFile -Parameter OutCertPath 22 | $result | Should Be $true 23 | $result = Test-ParamIsMandatory -Command Create-PfxFile -Parameter Password 24 | $result | Should Be $true 25 | } 26 | 27 | It 'throws if $Certificate is $null' { 28 | { Create-PfxFile -Certificate $null } | Should -Throw 29 | } 30 | 31 | It 'throws if $OutCertPath is $null' { 32 | { Create-PfxFile -OutCertPath $null } | Should -Throw 33 | } 34 | 35 | It 'throws if $Password is $null' { 36 | { Create-PfxFile -Password $null } | Should -Throw 37 | } 38 | 39 | Context 'When exporting a certificate to a PFX file' { 40 | It 'Should export the certificate successfully' { 41 | # Act 42 | Create-PfxFile -Certificate $certificate -OutCertPath $certPath -Password $certPassword 43 | 44 | # Assert 45 | Test-Path $certPath | Should -Be $true 46 | $exportedContent = [System.IO.File]::ReadAllBytes($certPath) 47 | $exportedContent.Length | Should -BeGreaterThan 0 48 | } 49 | } 50 | 51 | Context 'When the certificate is invalid' { 52 | It 'Should throw an error' { 53 | # Arrange 54 | $invalidCertificate = $null 55 | 56 | # Act & Assert 57 | { Create-PfxFile -Certificate $invalidCertificate -OutCertPath $certPath -Password $certPassword } | Should -Throw 58 | } 59 | } 60 | 61 | Context 'When the output path is invalid' { 62 | It 'Should throw an error' { 63 | # Arrange 64 | $invalidCertPath = Join-Path -Path $TestDrive -ChildPath "invalid\path\to\certificate.pfx" 65 | 66 | # Act & Assert 67 | { Create-PfxFile -Certificate $certificate -OutCertPath $invalidCertPath -Password $certPassword } | Should -Throw 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /image/test/scripts/Get-PatchFolders.Tests.ps1: -------------------------------------------------------------------------------- 1 | $here = Split-Path -Parent $MyInvocation.MyCommand.Path 2 | $sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.' 3 | . "$here\..\..\src\scripts\$sut" 4 | . $PSScriptRoot\..\TestUtils.ps1 5 | 6 | function Set-EnvironmentValue($Value) { 7 | $env:SITECORE_DEVELOPMENT_PATCHES = $Value 8 | } 9 | 10 | Describe 'Get-PatchFolders' { 11 | Mock Write-Host {} 12 | 13 | BeforeAll { 14 | Set-EnvironmentValue -Value $null 15 | } 16 | 17 | It 'requires $Path' { 18 | $result = Test-ParamIsMandatory -Command Get-PatchFolders -Parameter Path 19 | $result | Should -Be $true 20 | } 21 | 22 | It 'throws if $Path is a file' { 23 | $filePath = Join-Path $TestDrive 'file.txt' 24 | Set-Content $filePath 'foo' 25 | {Get-PatchFolders -Path $filePath} | Should -Throw 26 | } 27 | 28 | It 'returns nothing if the environment variable is empty' { 29 | $folders = (Get-PatchFolders -Path $TestDrive) 30 | $folders | Should -BeNullOrEmpty 31 | } 32 | 33 | It 'gets the configured patch folders' { 34 | Set-EnvironmentValue -Value 'foo,bar,baz' 35 | $test1 = New-Item -Path (Join-Path $TestDrive 'foo') -ItemType 'Directory' -Force 36 | $test2 = New-Item -Path (Join-Path $TestDrive 'bar') -ItemType 'Directory' -Force 37 | $test3 = New-Item -Path (Join-Path $TestDrive 'baz') -ItemType 'Directory' -Force 38 | $folders = (Get-PatchFolders -Path $TestDrive) 39 | $folders | Should -HaveCount 3 40 | 41 | $tests = @($test1, $test2, $test3) | ForEach-Object { $_.FullName } 42 | $folderNames = $folders | ForEach-Object { $_.FullName } 43 | $tests | ForEach-Object { $_ | Should -BeIn $folderNames } 44 | } 45 | 46 | It 'writes warning if a provided folder does not exist' { 47 | Set-EnvironmentValue -Value 'foo,idontexist,meeither' 48 | New-Item -Path (Join-Path $TestDrive 'foo') -ItemType 'Directory' -Force 49 | Get-PatchFolders -Path $TestDrive | Should -HaveCount 1 50 | Assert-MockCalled Write-Host -Exactly 1 -Scope It -ParameterFilter { $Object -match 'idontexist' } 51 | Assert-MockCalled Write-Host -Exactly 1 -Scope It -ParameterFilter { $Object -match 'meeither' } 52 | } 53 | 54 | It 'writes a warning if a provided path has invalid characters' { 55 | Set-EnvironmentValue -Value 'foo,subpath/leaking,something?strange,baz*bar' 56 | New-Item -Path (Join-Path $TestDrive 'foo') -ItemType 'Directory' -Force 57 | Get-PatchFolders -Path $TestDrive | Should -HaveCount 1 58 | Assert-MockCalled Write-Host -Exactly 1 -Scope It -ParameterFilter { $Object -match 'subpath/leaking' } 59 | Assert-MockCalled Write-Host -Exactly 1 -Scope It -ParameterFilter { $Object -match 'something\?strange' } 60 | Assert-MockCalled Write-Host -Exactly 1 -Scope It -ParameterFilter { $Object -match 'baz\*bar' } 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /powershell/test/Public/Create-SqlServerCertificate.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Create-SqlServerCertificate' { 5 | BeforeAll { 6 | $certStoreLocation = "Cert:\CurrentUser\My" 7 | $signerCert = New-SelfSignedCertificate -DnsName "signer.test" -CertStoreLocation $certStoreLocation 8 | } 9 | 10 | AfterAll { 11 | if ($signerCert -ne $null) { 12 | Remove-Item -Path $signerCert.PSPath -Force 13 | } 14 | } 15 | 16 | It 'requires $CommonName and $DnsName and $SignerCertificate' { 17 | $result = Test-ParamIsMandatory -Command Create-SqlServerCertificate -Parameter CommonName 18 | $result | Should Be $true 19 | $result = Test-ParamIsMandatory -Command Create-SqlServerCertificate -Parameter DnsName 20 | $result | Should Be $true 21 | $result = Test-ParamIsMandatory -Command Create-SqlServerCertificate -Parameter SignerCertificate 22 | $result | Should Be $true 23 | } 24 | 25 | It 'Optional parameter not provided' { 26 | { Create-SqlServerCertificate -CommonName "test" -DnsName "test" -SignerCertificate $signerCert } | Should -Not -Throw 27 | } 28 | 29 | It 'throws if $CommonName is $null or empty' { 30 | { Create-SqlServerCertificate -CommonName $null -DnsName "test" -SignerCertificate $signerCert } | Should -Throw 31 | { Create-SqlServerCertificate -CommonName "" -DnsName "test" -SignerCertificate $signerCert } | Should -Throw 32 | } 33 | 34 | It 'throws if $DnsName is $null or empty' { 35 | { Create-SqlServerCertificate -CommonName "test" -DnsName $null -SignerCertificate $signerCert } | Should -Throw 36 | { Create-SqlServerCertificate -CommonName "test" -DnsName "" -SignerCertificate $signerCert } | Should -Throw 37 | } 38 | 39 | It 'throws if $SignerCertificate is $null' { 40 | { Create-SqlServerCertificate -CommonName "test" -DnsName "test" -SignerCertificate $null } | Should -Throw 41 | } 42 | 43 | Context 'When creating a self-signed certificate' { 44 | It 'creates a certificate with the specified parameters' { 45 | # Arrange 46 | $commonName = 'test.sql.server' 47 | $dnsName = 'test.sql.server' 48 | 49 | # Act 50 | $certificate = Create-SqlServerCertificate -CommonName $commonName -DnsName $dnsName -SignerCertificate $signerCert 51 | 52 | write-host $certificate 53 | 54 | # Assert 55 | $certificate | Should -Not -BeNullOrEmpty 56 | $certificate.Subject | Should -Be "CN=$commonName" 57 | $certificate.DnsNameList | Should -Contain $dnsName 58 | $certificate.DnsNameList | Should -Contain 'localhost' 59 | $certificate.Issuer | Should -Be $signerCert.Subject 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /powershell/src/SitecoreDockerTools.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | RootModule = 'SitecoreDockerTools.psm1' 3 | GUID = '36f0f2d9-8a8a-461f-b54f-3fb109f30b70' 4 | Author = 'Sitecore Corporation A/S' 5 | CompanyName = 'Sitecore Corporation A/S' 6 | Copyright = 'Copyright (C) by Sitecore A/S' 7 | Description = 'PowerShell extensions for Docker-based Sitecore development' 8 | ModuleVersion = '0.0.1' 9 | PowerShellVersion = '5.1' 10 | 11 | # Modules that must be imported into the global environment prior to importing this module 12 | # RequiredModules = @() 13 | 14 | # Assemblies that must be loaded prior to importing this module 15 | # RequiredAssemblies = @() 16 | 17 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 18 | # ScriptsToProcess = @() 19 | 20 | # Type files (.ps1xml) to be loaded when importing this module 21 | # TypesToProcess = @() 22 | 23 | # Format files (.ps1xml) to be loaded when importing this module 24 | # FormatsToProcess = @() 25 | 26 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 27 | # NestedModules = @() 28 | 29 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 30 | FunctionsToExport = @('Get-SitecoreRandomString','Create-RSAKey','Create-SelfSignedCertificate','Create-SelfSignedCertificateWithSignature','Create-CertificateFile','Create-KeyFile','Create-PfxFile','Create-SqlServerCertificate','ConvertTo-CompressedBase64String','Get-SitecoreCertificateAsBase64String','Set-EnvFileVariable','Add-HostsEntry','Remove-HostsEntry','Write-SitecoreDockerWelcome','Get-EnvFileVariable','Import-LoadedCertificate','Import-CertificateForSigning','Load-Certificate') 31 | 32 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 33 | CmdletsToExport = @() 34 | 35 | # Variables to export from this module 36 | # VariablesToExport = '*' 37 | 38 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 39 | AliasesToExport = @('Set-DockerComposeEnvFileVariable') 40 | 41 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 42 | PrivateData = @{ 43 | PSData = @{ 44 | Tags = @('sitecore','docker','powershell') 45 | LicenseUri = 'https://doc.sitecore.net/~/media/C23E989268EC4FA588108F839675A5B6.pdf' 46 | ProjectUri = 'https://github.com/Sitecore/docker-tools' 47 | IconUri = 'https://mygetwwwsitecoreeu.blob.core.windows.net/feedicons/sc-packages.png' 48 | } 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /powershell/test/Public/Get-SitecoreCertificateAsBase64String.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | function rehydrateCertificate([string] $base64String, [securestring] $password) { 5 | $rawData = [System.Convert]::FromBase64String($base64String) 6 | return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($rawData, $password) 7 | } 8 | 9 | function getDnsName([System.Security.Cryptography.X509Certificates.X509Certificate2] $cert) 10 | { 11 | return $cert.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::DnsName, $false) 12 | } 13 | 14 | Describe 'Get-SitecoreCertificateAsBase64String' { 15 | 16 | It 'requires $Password' { 17 | $result = Test-ParamIsMandatory -Command Get-SitecoreCertificateAsBase64String -Parameter Password 18 | $result | Should -Be $true 19 | } 20 | 21 | It 'throws if $DnsName is null' { 22 | { Get-SitecoreCertificateAsBase64String -DnsName $null } | Should -Throw 23 | } 24 | 25 | It 'throws if $DnsName is empty' { 26 | { Get-SitecoreCertificateAsBase64String -DnsName "" } | Should -Throw 27 | } 28 | 29 | It 'generates certificate Base64 string with $Password' { 30 | $password = ConvertTo-SecureString -String "Test123" -Force -AsPlainText 31 | $result = Get-SitecoreCertificateAsBase64String -Password $password 32 | 33 | $result | Should -Not -BeNullOrEmpty 34 | $result.length | Should -BeGreaterThan 0 35 | { rehydrateCertificate $result $password } | Should -Not -Throw 36 | } 37 | 38 | It 'uses localhost as default $DnsName' { 39 | $password = ConvertTo-SecureString -String "Test123" -Force -AsPlainText 40 | $result = Get-SitecoreCertificateAsBase64String -Password $password 41 | $cert = rehydrateCertificate $result $password 42 | $dnsName = getDnsName $cert 43 | $dnsName | Should -Be "localhost" 44 | } 45 | 46 | It 'uses provided $DnsName' { 47 | $password = ConvertTo-SecureString -String "Test123" -Force -AsPlainText 48 | $result = Get-SitecoreCertificateAsBase64String -DnsName "test.com" -Password $password 49 | $cert = rehydrateCertificate $result $password 50 | $dnsName = getDnsName $cert 51 | $dnsName | Should -Be "test.com" 52 | } 53 | 54 | It 'should not throw with default key length' { 55 | $password = ConvertTo-SecureString -String "Test123" -Force -AsPlainText 56 | { Get-SitecoreCertificateAsBase64String -DnsName "test.com" -Password $password } | Should -Not -Throw 57 | } 58 | 59 | It 'wrong rsa key length fails validation' { 60 | $password = ConvertTo-SecureString -String "Test123" -Force -AsPlainText 61 | { Get-SitecoreCertificateAsBase64String -DnsName "test.com" -Password $password -KeyLength 2000} | Should -Throw 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sitecore Docker Tools 2 | 3 | Sitecore Docker Tools are utilities which improve developer experience when running Sitecore in a Docker environment. This includes: 4 | 5 | * `sitecore-docker-tools-assets`, a Docker image with development scripts and entrypoints which can be used during Sitecore container builds. 6 | [![Image Build Status](https://dev.azure.com/sitecore-devex/docker-tools/_apis/build/status/DockerTools.Image?branchName=main)](https://dev.azure.com/sitecore-devex/docker-tools/_build/latest?definitionId=9&branchName=main) 7 | * `SitecoreDockerTools`, a PowerShell module with functions used on the Sitecore container host to initialize the Sitecore Docker environment. 8 | [![PowrShell Build Status](https://dev.azure.com/sitecore-devex/docker-tools/_apis/build/status/DockerTools.PowerShell?branchName=main)](https://dev.azure.com/sitecore-devex/docker-tools/_build/latest?definitionId=10&branchName=main) 9 | 10 | ## Usage 11 | 12 | Released versions of these utilities can be found on the Sitecore Container Registry and the Sitecore PowerShell Gallery. Usage details can be found in the [Sitecore container development documentation](https://doc.sitecore.com/developers/100/developer-tools/en/containers-in-sitecore-development.html). 13 | 14 | ### Docker Image 15 | The scripts found in the Docker image are intended to be copied in via your custom `Dockerfile`. 16 | 17 | ```Dockerfile 18 | FROM ${TOOLS_IMAGE} as tools 19 | FROM ${PARENT_IMAGE} 20 | COPY --from=tools C:\tools C:\tools 21 | ``` 22 | 23 | You can enable the [development entrypoint](https://doc.sitecore.com/developers/100/developer-tools/en/deploying-files-into-running-containers.html#idp15256) in your `docker-compose` override. 24 | 25 | ```yml 26 | entrypoint: powershell.exe -Command "& C:\tools\entrypoints\iis\Development.ps1" 27 | ``` 28 | 29 | The development entrypoint also enables the application of development-specific configuration patches and configuration transforms at runtime via the `SITECORE_DEVELOPMENT_PATCHES` environment variable. You can see [available patches here](image/src/dev-patches). 30 | 31 | ```yml 32 | environment: 33 | SITECORE_DEVELOPMENT_PATCHES: DevEnvOn,CustomErrorsOff,DebugOn,DiagnosticsOff,InitMessagesOff,RobotDetectionOff 34 | ``` 35 | 36 | ### PowerShell Module 37 | The PowerShell module can be installed and imported from the Sitecore PowerShell Gallery. 38 | 39 | ```powershell 40 | Register-PSRepository -Name SitecoreGallery -SourceLocation https://nuget.sitecore.com/resources/v2/ 41 | Install-Module SitecoreDockerTools 42 | Import-Module SitecoreDockerTools 43 | 44 | # See available commands 45 | Get-Command -Module SitecoreDockerTools 46 | ``` 47 | 48 | ## Building/Using from Source 49 | 50 | ### Docker Image 51 | ```powershell 52 | cd image\src 53 | docker-compose build 54 | ``` 55 | 56 | ### PowerShell Module 57 | ```powershell 58 | Import-Module .\powershell\src\SitecoreDockerTools.psd1 59 | ``` 60 | 61 | ## Running Tests 62 | 63 | Unit tests require use of [Pester v4](https://pester.dev/docs/v4/introduction/installation). 64 | 65 | From the root folder of either project: 66 | 67 | ```powershell 68 | Import-Module Pester -RequiredVersion 4.9.0 69 | Invoke-Pester -Path .\test\* 70 | ``` 71 | -------------------------------------------------------------------------------- /powershell/src/Public/Import-Certificate.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Imports a loaded certificate into a specified store. 6 | .DESCRIPTION 7 | Imports a loaded certificate into the specified certificate store and location. 8 | .PARAMETER Certificate 9 | The certificate to be imported. 10 | .PARAMETER StoreName 11 | The name of the store where the certificate will be imported. 12 | .PARAMETER StoreLocation 13 | The location of the store where the certificate will be imported. 14 | .INPUTS 15 | None. You cannot pipe objects to Import-LoadedCertificate. 16 | .OUTPUTS 17 | None. Import-LoadedCertificate does not generate any output. 18 | .EXAMPLE 19 | PS C:\> Import-LoadedCertificate -Certificate $cert -StoreName "My" -StoreLocation "CurrentUser" 20 | #> 21 | function Import-LoadedCertificate { 22 | param( 23 | [Parameter(Mandatory = $true)] 24 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 25 | $Certificate, 26 | 27 | [Parameter(Mandatory = $true)] 28 | [string] 29 | $StoreName, 30 | 31 | [Parameter(Mandatory = $true)] 32 | $StoreLocation 33 | ) 34 | 35 | Write-Information -MessageData "Importing '$($Certificate.Thumbprint)' certificate into '$StoreName' store of '$StoreLocation' location." -InformationAction Continue 36 | 37 | $store = [System.Security.Cryptography.X509Certificates.X509Store]::new($StoreName, $StoreLocation) 38 | 39 | try { 40 | $store.Open("ReadWrite") 41 | $store.Add($Certificate) 42 | } 43 | finally { 44 | $store.Close() 45 | } 46 | } 47 | 48 | <# 49 | .SYNOPSIS 50 | Imports a certificate for signing. 51 | .DESCRIPTION 52 | Imports a certificate into the specified certificate store for signing purposes. 53 | .PARAMETER SignerCertificate 54 | The certificate to be imported as signer. 55 | .PARAMETER SignerCertificatePassword 56 | The password for the certificate to be imported as signer. 57 | .INPUTS 58 | None. You cannot pipe objects to Import-CertificateForSigning. 59 | .OUTPUTS 60 | System.Security.Cryptography.X509Certificates.X509Certificate2. The imported signer certificate. 61 | .EXAMPLE 62 | PS C:\> Import-CertificateForSigning -SignerCertificate $cert -SignerCertificatePassword $password 63 | #> 64 | function Import-CertificateForSigning{ 65 | param( 66 | [Parameter(Mandatory = $true)] 67 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 68 | $SignerCertificate, 69 | 70 | [Parameter(Mandatory = $true)] 71 | [SecureString]$SignerCertificatePassword 72 | ) 73 | 74 | Write-Information -MessageData "Importing '$($SignerCertificate.Thumbprint)' certificate for signing." -InformationAction Continue 75 | 76 | $pfxContent = $SignerCertificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $SignerCertificatePassword) 77 | 78 | $importCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( 79 | $pfxContent, 80 | $SignerCertificatePassword, 81 | [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet 82 | ) 83 | 84 | Import-LoadedCertificate -Certificate $importCertificate -StoreName "My" -StoreLocation "CurrentUser" 85 | 86 | return $importCertificate 87 | } -------------------------------------------------------------------------------- /powershell/test/Public/Get-EnvFileVariable.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Get-EnvFileVariable' { 5 | 6 | BeforeEach { 7 | Push-Location $TestDrive 8 | } 9 | 10 | AfterEach { 11 | Pop-Location 12 | } 13 | 14 | $envFile = '.env' 15 | $content = @( 16 | 'VAR1=VAL1', 17 | 'VAR2=VAL2', 18 | 'VAR3=VAL3', 19 | 'VAR4=''VAL4$Literal''', 20 | "VAR5=''VAL5''Escaped''", 21 | "VAR6='VAL6''EscapedLiteral'", 22 | "VAR7='VAL7" 23 | "VAR8='''VAL8'" 24 | ) 25 | Set-Content "$TestDrive\$envFile" -Value $content 26 | 27 | It 'requires $Variable' { 28 | $result = Test-ParamIsMandatory -Command Get-EnvFileVariable -Parameter Variable 29 | $result | Should -Be $true 30 | } 31 | 32 | It 'throws if $Path is invalid' { 33 | { Get-EnvFileVariable -Variable "foo" -Value "bar" -Path "$.baz" } | Should -Throw 34 | } 35 | 36 | It 'throws if $Variable is $null or empty' { 37 | { Get-EnvFileVariable -Variable $null -Value "bar" -Path $envFile } | Should -Throw 38 | { Get-EnvFileVariable -Variable "" -Value "bar" -Path $envFile } | Should -Throw 39 | } 40 | 41 | It 'throws if variable not found' { 42 | { Get-EnvFileVariable -Path $envFile -Variable 'VAR' } | Should -Throw 43 | } 44 | 45 | It 'reads variable correctly using default file path' { 46 | $value = 'VAL2' 47 | $result = Get-EnvFileVariable -Variable 'VAR2' 48 | $result | Should -Be $value 49 | } 50 | 51 | It 'reads variable correctly using relative file path' { 52 | $value = 'VAL2' 53 | $result = Get-EnvFileVariable -Path $envFile -Variable 'VAR2' 54 | $result | Should -Be $value 55 | } 56 | 57 | It 'reads variable correctly using absolute file path' { 58 | $value = 'VAL2' 59 | $result = Get-EnvFileVariable -Path "$TestDrive\$envFile" -Variable 'VAR2' 60 | $result | Should -Be $value 61 | } 62 | 63 | It 'reads variable correctly using a literal value' { 64 | $value = 'VAL4$Literal' 65 | $result = Get-EnvFileVariable -Variable 'VAR4' 66 | $result | Should -Be $value 67 | } 68 | 69 | It 'reads variable correctly using quotes in non-literal strings' { 70 | $value = "''VAL5''Escaped''" 71 | $result = Get-EnvFileVariable -Variable 'VAR5' 72 | $result | Should -Be $value 73 | } 74 | 75 | It 'reads variable correctly using escaped quotes in literals' { 76 | $value = "VAL6'EscapedLiteral" 77 | $result = Get-EnvFileVariable -Variable 'VAR6' 78 | $result | Should -Be $value 79 | } 80 | 81 | It 'reads variable correctly using non-literal strings starting with quote' { 82 | $value = "'VAL7" 83 | $result = Get-EnvFileVariable -Variable 'VAR7' 84 | $result | Should -Be $value 85 | } 86 | It 'reads variable correctly using literal strings starting with quote' { 87 | $value = "'VAL8" 88 | $result = Get-EnvFileVariable -Variable 'VAR8' 89 | $result | Should -Be $value 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /image/src/scripts/Invoke-XdtTransform.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Applies XDT transformations to files. 4 | .DESCRIPTION 5 | Applies XDT transformation on either a single file (file transform) or an entire folder (folder transform). 6 | For folder transform, it assumes: (1) XDT transformation files end in ".xdt", and (2) folder structures match. 7 | .PARAMETER Path 8 | Specifies either the target file to transform (file transform) or a target folder to apply transforms (folder transform). 9 | .PARAMETER XdtPath 10 | Specifies either the XDT transform file (file transform) or a folder containing XDT transform files (folder transform). 11 | .PARAMETER XdtDllPath 12 | Specifies the location of the Microsoft.Web.XmlTransform.dll assembly. Default is 'C:\tools\bin\Microsoft.Web.XmlTransform.dll'. 13 | .EXAMPLE 14 | PS C:\> .\Invoke-XdtTransform.ps1 -Path 'C:\inetpub\wwwroot\Web.config' -XdtPath 'C:\transforms\Web.config.xdt' 15 | Example of file transform. 16 | .EXAMPLE 17 | PS C:\> .\Invoke-XdtTransform.ps1 -Path 'C:\inetpub\wwwroot' -XdtPath 'C:\transforms' 18 | Example of folder transform. 19 | .INPUTS 20 | None 21 | .OUTPUTS 22 | None 23 | #> 24 | [CmdletBinding()] 25 | Param ( 26 | [Parameter(Mandatory = $true)] 27 | [ValidateScript( { Test-Path $_ })] 28 | [string]$Path, 29 | 30 | [Parameter(Mandatory = $true)] 31 | [ValidateScript( { Test-Path $_ })] 32 | [string]$XdtPath, 33 | 34 | [Parameter(Mandatory = $false)] 35 | [ValidateScript( { Test-Path $_ -PathType 'Leaf' })] 36 | [string]$XdtDllPath = "C:\tools\bin\Microsoft.Web.XmlTransform.dll" 37 | ) 38 | 39 | if (((Test-Path $Path -PathType Container) -and (Test-Path $XdtPath -PathType Leaf)) -or 40 | ((Test-Path $Path -PathType Leaf) -and (Test-Path $XdtPath -PathType Container))) { 41 | throw "'Path' and 'XdtPath' parameter types must match (both files or both folders)" 42 | } 43 | 44 | Add-Type -Path $XdtDllPath 45 | 46 | function ApplyTransform($filePath, $xdtFilePath) { 47 | Write-Verbose "Applying XDT transformation '$xdtFilePath' on '$filePath'..." 48 | 49 | $target = New-Object Microsoft.Web.XmlTransform.XmlTransformableDocument; 50 | $target.PreserveWhitespace = $true 51 | $target.Load($filePath); 52 | 53 | $transformation = New-Object Microsoft.Web.XmlTransform.XmlTransformation($xdtFilePath); 54 | 55 | if ($transformation.Apply($target) -eq $false) 56 | { 57 | throw "XDT transformation failed." 58 | } 59 | 60 | $target.Save($filePath); 61 | } 62 | 63 | if (Test-Path $Path -PathType Leaf) { 64 | 65 | # File transform 66 | ApplyTransform $Path $XdtPath 67 | 68 | } else { 69 | 70 | # Folder transform 71 | $transformations = @(Get-ChildItem $XdtPath -File -Include "*.xdt" -Recurse) 72 | 73 | if ($transformations.Length -eq 0) { 74 | Write-Verbose "No transformations in '$XdtPath'" 75 | return 76 | } 77 | 78 | $transformations | ForEach-Object { 79 | 80 | # Assume folder structures match 81 | $targetFullPath = (Resolve-Path $Path).Path 82 | $xdtFullPath = (Resolve-Path $XdtPath).Path 83 | $targetFilePath = $_.FullName.Replace($xdtFullPath, $targetFullPath).Replace(".xdt", "") 84 | 85 | if (-not(Test-Path $targetFilePath -PathType Leaf)) { 86 | Write-Verbose "No matching file '$targetFilePath' for transformation '$($_.FullName)'. Skipping..." 87 | return 88 | } 89 | 90 | ApplyTransform $targetFilePath $_.FullName 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /image/test/scripts/Install-ConfigurationFolder.Tests.ps1: -------------------------------------------------------------------------------- 1 | $here = Split-Path -Parent $MyInvocation.MyCommand.Path 2 | $sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.' 3 | $script = "$here\..\..\src\scripts\$sut" 4 | . $PSScriptRoot\..\TestUtils.ps1 5 | 6 | Describe 'Install-ConfigurationFolder.ps1' { 7 | 8 | It 'requires $Path' { 9 | $result = Test-ParamIsMandatory -Command $script -Parameter Path 10 | $result | Should -Be $true 11 | } 12 | 13 | It 'requires $PatchPath' { 14 | $result = Test-ParamIsMandatory -Command $script -Parameter PatchPath 15 | $result | Should -Be $true 16 | } 17 | 18 | It 'throws if $Path is not a folder' { 19 | $filePath = Join-Path $TestDrive 'file.txt' 20 | Set-Content $filePath 'foo' 21 | $folderPath = Join-Path $TestDrive 'folder' 22 | New-Item -Path $folderPath -ItemType 'Directory' -Force 23 | {& $script -Path $filePath -PatchPath $folderPath} | Should -Throw 24 | } 25 | 26 | It 'throws if $PatchPath is not a folder' { 27 | $filePath = Join-Path $TestDrive 'file.txt' 28 | Set-Content $filePath 'foo' 29 | $folderPath = Join-Path $TestDrive 'folder' 30 | New-Item -Path $folderPath -ItemType 'Directory' -Force 31 | {& $script -Path $folderPath -PatchPath $filePath} | Should -Throw 32 | } 33 | 34 | It 'copies configuration files' { 35 | $destination = New-Item -Path (Join-Path $TestDrive 'webroot') -ItemType 'Directory' -Force 36 | $patches = New-Item -Path (Join-Path $TestDrive 'patches') -ItemType 'Directory' -Force 37 | $patch = New-Item -Path (Join-Path $patches 'Patch.config') -Force 38 | Set-Content $patch -Value '' 39 | 40 | & $script -Path $destination -PatchPath $patches 41 | (Join-Path $destination '\Patch.config') | Should -FileContentMatchExactly '' 42 | } 43 | 44 | It 'copies configuration files to a matching path' { 45 | $destination = New-Item -Path (Join-Path $TestDrive 'webroot') -ItemType 'Directory' -Force 46 | $patches = New-Item -Path (Join-Path $TestDrive 'patches') -ItemType 'Directory' -Force 47 | New-Item -Path (Join-Path $patches '\App_Config') -ItemType 'Directory' -Force 48 | New-Item -Path (Join-Path $patches '\App_Config\Environment') -ItemType 'Directory' -Force 49 | $patch = New-Item -Path (Join-Path $patches '\App_Config\Environment\Patch.config') -Force 50 | Set-Content $patch -Value '' 51 | 52 | & $script -Path $destination -PatchPath $patches 53 | (Join-Path $destination '\App_Config\Environment\Patch.config') | Should -FileContentMatchExactly '' 54 | } 55 | 56 | It 'does not copy non-config files' { 57 | $destination = New-Item -Path (Join-Path $TestDrive 'webroot') -ItemType 'Directory' -Force 58 | $patches = New-Item -Path (Join-Path $TestDrive 'patches') -ItemType 'Directory' -Force 59 | New-Item -Path (Join-Path $patches '\App_Config') -ItemType 'Directory' -Force 60 | $transform = New-Item -Path (Join-Path $patches '\App_Config\Patch.xdt') -Force 61 | Set-Content $transform -Value '' 62 | 63 | & $script -Path $destination -PatchPath $patches 64 | (Join-Path $destination '\App_Config\Patch.xdt') | Should -Not -Exist 65 | } 66 | 67 | It 'overwrites existing files' { 68 | $destination = New-Item -Path (Join-Path $TestDrive 'webroot') -ItemType 'Directory' -Force 69 | $existingPatch = New-Item -Path (Join-Path $destination 'Patch.config') -Force 70 | Set-Content $existingPatch -Value '' 71 | 72 | $patches = New-Item -Path (Join-Path $TestDrive 'patches') -ItemType 'Directory' -Force 73 | $patch = New-Item -Path (Join-Path $patches 'Patch.config') -Force 74 | Set-Content $patch -Value '' 75 | 76 | & $script -Path $destination -PatchPath $patches 77 | (Join-Path $destination '\Patch.config') | Should -FileContentMatchExactly '' 78 | } 79 | 80 | } -------------------------------------------------------------------------------- /powershell/build/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - main 5 | - dev 6 | paths: 7 | include: 8 | - 'powershell/*' 9 | exclude: 10 | - 'image/*' 11 | 12 | pr: 13 | branches: 14 | include: 15 | - main 16 | - dev 17 | paths: 18 | include: 19 | - 'powershell/*' 20 | exclude: 21 | - 'image/*' 22 | 23 | resources: 24 | - repo: self 25 | 26 | variables: 27 | sitecoreVersion: 10.4 28 | revision: $[counter(format('sitecoreVersion{0}', variables['sitecoreVersion']), 0)] 29 | moduleVer: $(sitecoreVersion).$(revision) 30 | ${{ if eq(variables['Build.SourceBranchName'], 'main') }}: 31 | prerelease: '' 32 | ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: 33 | prerelease: '-unstable' 34 | buildVer: $(moduleVer)$(prerelease) 35 | 36 | pool: Default 37 | 38 | stages: 39 | - stage: Build 40 | 41 | jobs: 42 | - job: Build 43 | 44 | steps: 45 | 46 | - task: PowerShell@2 47 | displayName: Update manifest version 48 | inputs: 49 | targetType: 'inline' 50 | script: | 51 | if ("$(prerelease)") { 52 | Update-ModuleManifest -Path .\SitecoreDockerTools.psd1 -ModuleVersion $(moduleVer) -Prerelease $(prerelease) 53 | Write-Host "Updated module manifest. ModuleVersion: $(moduleVer), Prerelease: $(prerelease)" 54 | } else { 55 | Update-ModuleManifest -Path .\SitecoreDockerTools.psd1 -ModuleVersion $(moduleVer) 56 | Write-Host "Updated module manifest. ModuleVersion: $(moduleVer)" 57 | } 58 | failOnStderr: true 59 | workingDirectory: '$(Build.SourcesDirectory)/powershell/src' 60 | - task: NuGetCommand@2 61 | displayName: NuGet pack 62 | inputs: 63 | command: 'pack' 64 | packagesToPack: '$(Build.SourcesDirectory)/powershell/src/SitecoreDockerTools.nuspec' 65 | versioningScheme: byEnvVar 66 | versionEnvVar: buildVer 67 | buildProperties: 'VERSION=$(buildVer)' 68 | - task: PublishBuildArtifacts@1 69 | inputs: 70 | PathtoPublish: '$(Build.ArtifactStagingDirectory)' 71 | ArtifactName: 'NuGetPackage' 72 | publishLocation: 'Container' 73 | 74 | - stage: Test 75 | dependsOn: Build 76 | 77 | jobs: 78 | - job: Pester 79 | displayName: Run Pester tests 80 | steps: 81 | 82 | - task: Pester@9 83 | inputs: 84 | scriptFolder: "$(Build.SourcesDirectory)/powershell/test/*" 85 | resultsFile: "$(Build.SourcesDirectory)/powershell/test/Test-Pester.XML" 86 | usePSCore: False 87 | - task: PublishTestResults@2 88 | inputs: 89 | testResultsFormat: "NUnit" 90 | testResultsFiles: "$(Build.SourcesDirectory)/powershell/test/Test-Pester.XML" 91 | failTaskOnFailedTests: true 92 | 93 | - stage: Deploy 94 | dependsOn: Test 95 | condition: ne(variables['Build.Reason'], 'PullRequest') 96 | 97 | jobs: 98 | - job: Deploy 99 | workspace: 100 | clean: all 101 | steps: 102 | 103 | - checkout: none 104 | - task: DownloadPipelineArtifact@2 105 | inputs: 106 | buildType: 'current' 107 | artifactName: 'NuGetPackage' 108 | itemPattern: '**' 109 | targetPath: '$(Pipeline.Workspace)' 110 | - task: NuGetToolInstaller@1 111 | inputs: 112 | versionSpec: 5.x 113 | - task: PowerShell@2 114 | inputs: 115 | targetType: 'inline' 116 | script: | 117 | New-Item -Path '$(Pipeline.Workspace)\NuGet.config' -ItemType File -Force 118 | Add-Content -Path '$(Pipeline.Workspace)\NuGet.config' -Value ' ' 119 | nuget sources add -NonInteractive -Name Temp -Source $(NuGetFeedUrl) -ConfigFile '$(Pipeline.Workspace)\NuGet.config' 120 | nuget push -NonInteractive -Source Temp '$(Pipeline.Workspace)/**/*.nupkg' -ConfigFile '$(Pipeline.Workspace)\NuGet.config' 121 | failOnStderr: true -------------------------------------------------------------------------------- /powershell/test/Public/Load-Certificate.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Load-Certificate' { 5 | BeforeAll { 6 | # Generate a self-signed certificate with password 7 | $certPath = Join-Path -Path $TestDrive -ChildPath "certificate.pfx" 8 | $certificate = New-SelfSignedCertificate -DnsName "mockCertificate" -CertStoreLocation "Cert:\CurrentUser\My" 9 | $certPassword = ConvertTo-SecureString -String "password" -AsPlainText -Force 10 | Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -FilePath $certPath -Password $certPassword 11 | 12 | # Generate a self-signed certificate without password 13 | $certWithoutPasswordPath = Join-Path -Path $TestDrive -ChildPath "certificateWithoutPassword.pfx" 14 | $certificateWithoutPassword = New-SelfSignedCertificate -DnsName "mockCertificate" -CertStoreLocation "Cert:\CurrentUser\My" 15 | Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($certificateWithoutPassword.Thumbprint)" -FilePath $certWithoutPasswordPath -Password (New-Object System.Security.SecureString) 16 | } 17 | 18 | AfterAll { 19 | if ($certificate -ne $null) { 20 | Remove-Item -Path $certificate.PSPath -Force 21 | } 22 | if ($certificateWithoutPassword -ne $null) { 23 | Remove-Item -Path $certificateWithoutPassword.PSPath -Force 24 | } 25 | } 26 | 27 | It 'requires $CertPath' { 28 | $result = Test-ParamIsMandatory -Command Load-Certificate -Parameter CertPath 29 | $result | Should -Be $true 30 | } 31 | 32 | It 'throws if $CertPath is $null or empty' { 33 | { Load-Certificate -CertPath $null } | Should -Throw 34 | { Load-Certificate -CertPath "" } | Should -Throw 35 | } 36 | 37 | It 'throws if $CertPath is $null or empty' { 38 | { Load-Certificate -CertPath $null } | Should -Throw 39 | { Load-Certificate -CertPath "" } | Should -Throw 40 | } 41 | 42 | Context 'When loading a certificate with a password' { 43 | It 'Should load the certificate successfully' { 44 | # Act 45 | $cert = Load-Certificate -CertPath $certPath -CertPassword $certPassword 46 | 47 | # Assert 48 | $cert | Should -BeOfType "System.Security.Cryptography.X509Certificates.X509Certificate2" 49 | } 50 | } 51 | 52 | Context 'When loading a certificate without a password' { 53 | It 'Should load the certificate successfully' { 54 | # Arrange 55 | $certWithoutPasswordPath = Join-Path -Path $TestDrive -ChildPath "certificateWithoutPassword.pfx" 56 | Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -FilePath $certWithoutPasswordPath -Password (New-Object System.Security.SecureString) 57 | 58 | # Act 59 | $cert = Load-Certificate -CertPath $certWithoutPasswordPath 60 | 61 | # Assert 62 | $cert | Should -BeOfType "System.Security.Cryptography.X509Certificates.X509Certificate2" 63 | } 64 | } 65 | 66 | Context 'When the certificate path is invalid' { 67 | It 'Should throw an error' { 68 | # Arrange 69 | $invalidCertPath = Join-Path -Path $TestDrive -ChildPath "invalid\path\to\certificate.pfx" 70 | 71 | # Act & Assert 72 | { Load-Certificate -CertPath $invalidCertPath } | Should -Throw 73 | } 74 | } 75 | 76 | Context 'When the certificate password is incorrect' { 77 | It 'Should throw an error' { 78 | # Arrange 79 | $wrongPassword = ConvertTo-SecureString -String "wrongpassword" -AsPlainText -Force 80 | 81 | # Act & Assert 82 | { Load-Certificate -CertPath $certPath -CertPassword $wrongPassword } | Should -Throw 83 | } 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /powershell/src/Public/Write-SitecoreDockerWelcome.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Welcomes you to Docker-based Sitecore development. 6 | .DESCRIPTION 7 | :) 8 | .EXAMPLE 9 | PS C:\> Write-SitecoreDockerWelcome 10 | .INPUTS 11 | None. 12 | .OUTPUTS 13 | None. 14 | #> 15 | function Write-SitecoreDockerWelcome { 16 | 17 | $lighthouse = @" 18 | \ + / /``\ 19 | \ | / 20 | \ | / /``\ 21 | \ / \ / 22 | \ /______\ / 23 | / |___|___| \ 24 | / |;| |;| \ 25 | / \\. . / \ 26 | / ||: . | \ /``\ 27 | / ||: | \ 28 | ||: | 29 | ||: .| 30 | || | 31 | |: | 32 | ||:__ . | ------------------ 33 | ___________ _ ||_| |_.| |________________| |___________________ 34 | ......................................... | | .................. 35 | .............................. -------------------------------------- ......... 36 | ........ _________ .......... | | | ......... 37 | / | | | | 38 | / / ------------------------------------------------------ 39 | | | | | | | 40 | | | | | | | 41 | ------ --------------------------------------------------------------- 42 | \ _____ _______ ________ ______ ______ _____ ________ _____ / 43 | \ / ____|__ __|__ __| ___/ / ____/ ___ | __ \ | ___| / 44 | ~~\ | (___ | | | | | |__ | | | | | | |__) || |__ /~~ 45 | ~~~\ \____\ | | | | | __| | | | | | | _ / | __| /~~~ 46 | ~~~~\ ____| )__| |__ | | | |___| |___ | |___| | | \ \ | |___ /~~~~ 47 | ~~~~~\ |_____/|________| |_| |____ / \_____/\____/ |__| \_ \_____| /~~~~~ 48 | ~~~~~~\ /~~~~~~ 49 | ~~~~~~~\ /====~~~ 50 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 51 | "@ 52 | $index = 1 53 | ($lighthouse -split "\r?\n|\r") | ForEach-Object { 54 | $line = $_ 55 | 56 | # lighthouse rays 57 | if ($index -lt 11) { 58 | $col = 1 59 | [char[]]$line | ForEach-Object { 60 | # find light rays by column 61 | if (($col -gt 5 -and $col -lt 15) -or ($col -gt 29 -and $col -lt 39)) { 62 | Write-Host $_ -ForegroundColor Yellow -NoNewline 63 | } 64 | else { 65 | Write-Host $_ -NoNewline 66 | } 67 | $col++ 68 | } 69 | Write-Host 70 | } 71 | 72 | # grass 73 | elseif ($index -gt 16 -and $index -lt 20) { 74 | [char[]]$line | ForEach-Object { 75 | # find grass by character 76 | if ($_ -eq '.') { 77 | Write-Host $_ -ForegroundColor Green -NoNewline 78 | } 79 | else { 80 | Write-Host $_ -NoNewline 81 | } 82 | } 83 | Write-Host 84 | } 85 | 86 | # ship / water / logo 87 | elseif ($index -gt 24) { 88 | $col = 1 89 | [char[]]$line | ForEach-Object { 90 | # find letters by column and line 91 | if ($col -gt 8 -and $col -lt 72 -and $index -lt 33) { 92 | Write-Host $_ -ForegroundColor Red -NoNewline 93 | } 94 | # find water by character 95 | elseif ($_ -eq "~" -or $_ -eq "=") { 96 | Write-Host $_ -ForegroundColor Blue -NoNewline 97 | } 98 | else { 99 | Write-Host $_ -NoNewline 100 | } 101 | $col++ 102 | } 103 | Write-Host 104 | } 105 | 106 | else { 107 | Write-Host $_ 108 | } 109 | $index++ 110 | } 111 | } -------------------------------------------------------------------------------- /powershell/test/Public/Get-SitecoreRandomString.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Get-SitecoreRandomString' { 5 | 6 | It 'requires $Length' { 7 | $result = Test-ParamIsMandatory -Command Get-SitecoreRandomString -Parameter Length 8 | $result | Should -Be $true 9 | } 10 | 11 | It 'throws if $Length is less than 1' { 12 | { Get-SitecoreRandomString -Length 0 } | Should -Throw 13 | } 14 | 15 | It 'generates string' { 16 | $key = Get-SitecoreRandomString 10 17 | $key | Should -Not -BeNullOrEmpty 18 | $key.length | Should -Be 10 19 | } 20 | 21 | It 'returns requested length' { 22 | $random = Get-Random -Minimum 8 -Maximum 128 23 | $key = Get-SitecoreRandomString -Length $random 24 | $key.length | Should -Be $random 25 | } 26 | 27 | It 'does not include whitespace in string' { 28 | $key = Get-SitecoreRandomString 100 29 | $key | Should -Not -Match " " 30 | } 31 | 32 | It 'throws if Length is less than 4 () and EnforceComplexty requested' -TestCases @( 33 | @{ Length = 1}, 34 | @{ Length = 3} 35 | ){ 36 | param($Length) 37 | { Get-SitecoreRandomString -Length $Length -EnforceComplexity } | Should -Throw 38 | } 39 | 40 | It 'throws if there are no allowed character types' { 41 | $random = Get-Random -Minimum 10 -Maximum 20 42 | 43 | { Get-SitecoreRandomString -Length $random -DisallowCaps -DisallowLower -DisallowNumbers -DisallowSpecial } | Should -Throw 44 | } 45 | 46 | It 'includes all character types in string by default' { 47 | $result = Get-SitecoreRandomString 100 48 | $result | Should -MatchExactly "[A-Z]" 49 | $result | Should -MatchExactly "[a-z]" 50 | $result | Should -MatchExactly "[0-9]" 51 | $result | Should -MatchExactly "[^a-zA-Z0-9]" 52 | } 53 | 54 | It 'excludes requested character type: Caps' { 55 | $random = Get-Random -Minimum 10 -Maximum 20 56 | $result = Get-SitecoreRandomString -Length $random -DisallowCaps 57 | $result | Should -Not -MatchExactly "[A-Z]" 58 | } 59 | 60 | It 'excludes requested character type: Lowercase' { 61 | $random = Get-Random -Minimum 10 -Maximum 20 62 | $result = Get-SitecoreRandomString -Length $random -DisallowLower 63 | $result | Should -Not -MatchExactly "[a-z]" 64 | } 65 | 66 | It 'excludes requested character type: Numbers' { 67 | $random = Get-Random -Minimum 10 -Maximum 20 68 | $result = Get-SitecoreRandomString -Length $random -DisallowNumbers 69 | $result | Should -Not -MatchExactly "[0-9]" 70 | } 71 | 72 | It 'excludes requested character type: Special' { 73 | $random = Get-Random -Minimum 10 -Maximum 20 74 | $result = Get-SitecoreRandomString -Length $random -DisallowSpecial 75 | $result | Should -Not -MatchExactly "[^a-zA-Z0-9]" 76 | } 77 | 78 | It 'meets complexity requirements' { 79 | $random = Get-Random -Minimum 15 -Maximum 25 80 | $result = Get-SitecoreRandomString -Length $random -EnforceComplexity 81 | 82 | $nums = 0 83 | $caps = 0 84 | $lower = 0 85 | $special = 0 86 | 87 | $charArray = $result.ToCharArray() 88 | 89 | foreach ($character in $charArray) { 90 | 91 | if ([byte]$character -ge 33 -and [byte]$character -le 47) { 92 | $special = 1 93 | } 94 | if ([byte]$character -ge 48 -and [byte]$character -le 57) { 95 | $nums = 1 96 | } 97 | if ([byte]$character -ge 58 -and [byte]$character -le 64) { 98 | $special = 1 99 | } 100 | if ([byte]$character -ge 65 -and [byte]$character -le 90) { 101 | $caps = 1 102 | } 103 | if ([byte]$character -ge 91 -and [byte]$character -le 96) { 104 | $special = 1 105 | } 106 | if ([byte]$character -ge 97 -and [byte]$character -le 122) { 107 | $lower = 1 108 | } 109 | if ([byte]$character -ge 123 -and [byte]$character -le 126) { 110 | $special = 1 111 | } 112 | } 113 | $total = $nums + $caps + $lower + $special 114 | 115 | $total | Should -eq 4 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /image/build/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - main 5 | - dev 6 | paths: 7 | include: 8 | - 'image/*' 9 | exclude: 10 | - 'powershell/*' 11 | 12 | pr: 13 | branches: 14 | include: 15 | - main 16 | - dev 17 | paths: 18 | include: 19 | - 'image/*' 20 | exclude: 21 | - 'powershell/*' 22 | 23 | resources: 24 | - repo: self 25 | 26 | variables: 27 | sitecoreVersion: 10.3.0 28 | revision: $[counter(format('sitecoreVersion{0}', variables['sitecoreVersion']), 100)] 29 | osName: ltsc2022 30 | baseImage: mcr.microsoft.com/windows/nanoserver:$(osName) 31 | buildImage: mcr.microsoft.com/windows/servercore:$(osName) 32 | buildNumber: $(Build.BuildID) 33 | azureContainerRegistry: $(ACR_ContainerRegistry) 34 | azureSubscriptionEndpoint: AKSServiceConnections 35 | ${{ if eq(variables['Build.SourceBranchName'], 'main') }}: 36 | stability: '' 37 | namespace: 'tools' 38 | ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: 39 | stability: '-unstable' 40 | namespace: 'experimental' 41 | 42 | pool: Default 43 | 44 | stages: 45 | 46 | - stage: Versioning 47 | 48 | jobs: 49 | - job: Tagging 50 | steps: 51 | 52 | - task: PowerShell@2 53 | name: Tags 54 | displayName: Generate tags 55 | inputs: 56 | targetType: 'inline' 57 | script: | 58 | Write-Host "Pulling base image $(baseImage)..." 59 | docker pull $(baseImage) 60 | 61 | [string] $osVersion = (docker image inspect $(baseImage) | ConvertFrom-Json).OsVersion 62 | Write-Host "Image OS version is '$osVersion'" 63 | 64 | [string] $longTag = "$(sitecoreVersion).$(revision).$(buildNumber)-$osVersion-$(osName)$(stability)" 65 | [string] $shortTag = "$(sitecoreVersion)-$(osName)$(stability)" 66 | Write-Host "Setting long tag to '$longTag'" 67 | Write-Host "Setting short tag to '$shortTag'" 68 | Write-Host "##vso[task.setvariable variable=longTag;isOutput=true]$longTag" 69 | Write-Host "##vso[task.setvariable variable=shortTag;isOutput=true]$shortTag" 70 | 71 | - stage: Build 72 | dependsOn: Versioning 73 | 74 | jobs: 75 | - job: Build 76 | displayName: Build image 77 | variables: 78 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 79 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 80 | steps: 81 | 82 | - task: DockerCompose@0 83 | displayName: Building image 84 | inputs: 85 | containerregistrytype: Azure Container Registry 86 | azureSubscriptionEndpoint: $(azureSubscriptionEndpoint) 87 | azureContainerRegistry: $(azureContainerRegistry) 88 | dockerComposeFile: '**/docker-compose.yml' 89 | dockerComposeFileArgs: | 90 | REGISTRY=$(azureContainerRegistry)/$(namespace)/ 91 | VERSION=$(longTag) 92 | BASE_IMAGE=$(baseImage) 93 | BUILD_IMAGE=$(buildImage) 94 | action: Build services 95 | additionalImageTags: '$(shortTag)' 96 | arguments: '--force-rm' 97 | currentWorkingDirectory: '$(Build.SourcesDirectory)/image/src' 98 | 99 | - stage: Test 100 | dependsOn: Build 101 | 102 | jobs: 103 | - job: Pester 104 | displayName: Run Pester tests 105 | steps: 106 | 107 | - task: Pester@9 108 | inputs: 109 | scriptFolder: "$(Build.SourcesDirectory)/image/test/*" 110 | resultsFile: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 111 | usePSCore: False 112 | - task: PublishTestResults@2 113 | inputs: 114 | testResultsFormat: "NUnit" 115 | testResultsFiles: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 116 | failTaskOnFailedTests: true 117 | 118 | - stage: Push 119 | dependsOn: 120 | - Versioning 121 | - Test 122 | condition: ne(variables['Build.Reason'], 'PullRequest') 123 | 124 | jobs: 125 | - job: Push 126 | displayName: Push image 127 | variables: 128 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 129 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 130 | steps: 131 | 132 | - task: DockerCompose@0 133 | displayName: Pushing image 134 | inputs: 135 | containerregistrytype: Azure Container Registry 136 | azureSubscriptionEndpoint: $(azureSubscriptionEndpoint) 137 | azureContainerRegistry: $(azureContainerRegistry) 138 | dockerComposeFile: '**/docker-compose.yml' 139 | dockerComposeFileArgs: | 140 | REGISTRY=$(azureContainerRegistry)/$(namespace)/ 141 | VERSION=$(longTag) 142 | BASE_IMAGE=$(baseImage) 143 | BUILD_IMAGE=$(buildImage) 144 | action: Push services 145 | additionalImageTags: '$(shortTag)' 146 | currentWorkingDirectory: '$(Build.SourcesDirectory)/image/src' -------------------------------------------------------------------------------- /image/src/scripts/Deploy-TdsWdpPackages.ps1: -------------------------------------------------------------------------------- 1 | [cmdletbinding()] 2 | param( 3 | [Parameter(Mandatory = $false)] 4 | [string]$PackagesPath = ".\packages", 5 | [switch]$ViewLogs 6 | ) 7 | 8 | #Extracts WDP packages to web root 9 | function ExtractPackages($packagesPath) { 10 | Write-Output "Beginning package extraction" 11 | 12 | Get-ChildItem -Path $packagesPath -Filter "*.wdp.zip" | ForEach-Object { Expand-Archive -Path $_.FullName -DestinationPath C:\temp\TDS -Force } 13 | Move-Item -Path C:\temp\TDS\Content\Website\Bin\*.WebDeployClient.dll -Destination C:\inetpub\wwwroot\bin -Force 14 | Move-Item -Path C:\temp\TDS\Content\Website\temp\* -Destination C:\inetpub\wwwroot\temp -Force 15 | Remove-Item -Path C:\temp\TDS -Recurse -Force 16 | 17 | # Ensure TDS has permissions to delete items after install 18 | cmd /C icacls C:\inetpub\wwwroot\temp\WebDeployItems /grant 'IIS AppPool\DefaultAppPool:(OI)(CI)M' 19 | 20 | Write-Output "Package extraction complete" 21 | } 22 | 23 | #Invokes the deployment and waits for completion 24 | function InvokeDeployment($viewLogs) { 25 | Write-Output "Beginning package deployment" 26 | 27 | $baseAPIUrl = "http://localhost:80/api/TDS/WebDeploy/" 28 | 29 | #Create the request urls 30 | $statusRequest = "$($baseAPIUrl)Status" 31 | $invokeRequest = "$($baseAPIUrl)Invoke" 32 | $removeRequest = "$($baseAPIUrl)Remove" 33 | $logRequest = "$($baseAPIUrl)Messages?flush=true" 34 | 35 | #Get the current status 36 | $retryCount = 20 37 | $requestComplete = $false; 38 | while($retryCount -ge 0) 39 | { 40 | try 41 | { 42 | $statusResponse = Invoke-RestMethod -Uri $statusRequest -TimeoutSec 60 43 | 44 | $requestComplete = $true 45 | break 46 | } 47 | catch 48 | { 49 | Write-Warning "Retrying connection to $statusRequest" 50 | $retryCount-- 51 | } 52 | } 53 | 54 | if (!$requestComplete) 55 | { 56 | Write-Error "Could not contact server at $statusRequest" 57 | 58 | exit 59 | } 60 | 61 | #See if a deployment is taking place 62 | if ($statusResponse.DeploymentStatus -eq "Complete" -or $statusResponse.DeploymentStatus -eq "Idle" -or $statusResponse.DeploymentStatus -eq "Failed") { 63 | #Call the Invoke method to start the deployment. This may not be needed if the server is restarting, but if no Assemblies or configs change 64 | #it will be needed 65 | $invokeResponse = Invoke-RestMethod -Uri $invokeRequest -TimeoutSec 60 66 | 67 | if ($invokeResponse -ne "Ok") 68 | { 69 | throw "Request to start deployment failed" 70 | } 71 | 72 | Write-Output "Starting Deployment" 73 | } 74 | 75 | #Wait a bit to allow a deployment to start 76 | Start-Sleep -Seconds 2 77 | 78 | #Get the current status to see which deploy folder is being deployed 79 | $statusResponse = Invoke-RestMethod -Uri $statusRequest -TimeoutSec 60 80 | $currentDeploymentFolder = $statusResponse.CurrentDeploymentFolder 81 | 82 | while ($true) { 83 | #Get the current status 84 | $statusResponse = Invoke-RestMethod -Uri $statusRequest -TimeoutSec 60 85 | Write-Verbose "Server Deploy State: $($statusResponse.DeploymentStatus)" 86 | 87 | #If the deployment folder has changed, complete the progress 88 | if ($statusResponse.CurrentDeploymentFolder -ne $currentDeploymentFolder) 89 | { 90 | Write-Progress -Completed -Activity "Deploying Web Deploy Package from $currentDeploymentFolder" 91 | } 92 | 93 | #Update the progress bar 94 | Write-Progress -PercentComplete $statusResponse.ProgressPercent -Activity "Deploying Web Deploy Package from $($statusResponse.CurrentDeploymentFolder)" 95 | 96 | #If the user wants deployment logs, write the logs 97 | if ($viewLogs) { 98 | $logResponse = Invoke-RestMethod -Uri $logRequest -TimeoutSec 60 99 | 100 | Write-Output $logResponse 101 | } 102 | 103 | #Stop if all deployment folders have been deployed 104 | if ($statusResponse.DeploymentStatus -eq "Complete" -or $statusResponse.DeploymentStatus -eq "Idle") { 105 | break 106 | } 107 | 108 | if ($statusResponse.DeploymentStatus -eq "Failed") { 109 | Write-Error "Errors detected during deployment. Please see logs for more details" 110 | 111 | exit 1 #set exit code to failure if there is a problem with the deployment 112 | } 113 | 114 | Start-Sleep -Seconds 5 115 | } 116 | 117 | Write-Output "Removing installer service from webserver" 118 | 119 | Invoke-RestMethod -Uri $removeRequest -TimeoutSec 600 120 | 121 | Write-Progress -Completed -Activity "Deploying Web Deploy Package from $($statusResponse.CurrentDeploymentFolder)" 122 | } 123 | 124 | if (-not ([System.IO.Path]::IsPathRooted($PackagesPath))) { 125 | $currentDirectory = Split-Path $MyInvocation.MyCommand.Path 126 | $PackagesPath = (Get-Item -Path ($currentDirectory + "\" + $PackagesPath)).FullName 127 | } 128 | 129 | ExtractPackages $PackagesPath 130 | 131 | #Wait until server has recognized that files may have changed and begins its restart 132 | Start-Sleep -s 5 133 | 134 | InvokeDeployment $ViewLogs 135 | 136 | Write-Output "Package deployment complete" -------------------------------------------------------------------------------- /image/test/scripts/Watch-Directory.Tests.ps1: -------------------------------------------------------------------------------- 1 | $here = Split-Path -Parent $MyInvocation.MyCommand.Path 2 | $sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.' 3 | $script = "$here\..\..\src\scripts\$sut" 4 | . $PSScriptRoot\..\TestUtils.ps1 5 | 6 | Describe 'Watch-Directory.ps1' { 7 | 8 | # Supress info messages from Watch-Directory.ps1. Can set to "Continue" if necessary for debugging. 9 | $InformationPreference = "SilentlyContinue" 10 | 11 | BeforeAll { 12 | $dummyFile = Join-Path $TestDrive 'dummy.txt' 13 | Set-Content $dummyFile -Value 'dummy' 14 | } 15 | 16 | It 'requires $Path' { 17 | $result = Test-ParamIsMandatory -Command $script -Parameter Path 18 | $result | Should -Be $true 19 | } 20 | 21 | It 'requires $Destination' { 22 | $result = Test-ParamIsMandatory -Command $script -Parameter Destination 23 | $result | Should -Be $true 24 | } 25 | 26 | It 'throws if invalid $Path' { 27 | {& $script -Path 'C:\DoesNotExist' -Destination $TestDrive} | Should -Throw 28 | {& $script -Path $dummyFile -Destination $TestDrive} | Should -Throw 29 | {& $script -Path $null -Destination $TestDrive} | Should -Throw 30 | } 31 | 32 | It 'throws if invalid $Destination' { 33 | {& $script -Path $TestDrive -Destination 'C:\DoesNotExist'} | Should -Throw 34 | {& $script -Path $TestDrive -Destination $dummyFile} | Should -Throw 35 | {& $script -Path $TestDrive -Destination $null} | Should -Throw 36 | } 37 | 38 | It 'copies existing files' { 39 | $src = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 40 | $dst = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 41 | New-Item -Path "$($src)\file.txt" -ItemType 'File' 42 | 43 | & $script -Path $src -Destination $dst -Timeout 100 44 | 45 | "$($dst)\file.txt" | Should -Exist 46 | } 47 | 48 | It 'copies new files' { 49 | $src = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 50 | $dst = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 51 | 52 | $copiesNewFilesScript = { 53 | param ($src, $dst, $script) 54 | Start-Sleep -Milliseconds 500; 55 | New-Item -Path "$($src)\file.txt"; 56 | & $script -Path $src -Destination $dst -Timeout 2000 -Sleep 100 57 | } 58 | 59 | $job = Start-Job -ScriptBlock $copiesNewFilesScript -ArgumentList $src, $dst, $script 60 | $job | Wait-Job | Remove-Job 61 | 62 | "$($dst)\file.txt" | Should -Exist 63 | } 64 | 65 | # Note: current test isn't working correct, "dst" folder always contains "file.txt". 66 | It 'deletes files' -Skip { 67 | $src = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 68 | $dst = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 69 | New-Item -Path "$($src)\file.txt" -ItemType 'File' 70 | New-Item -Path "$($dst)\file.txt" -ItemType 'File' 71 | 72 | $job = Start-Job -ScriptBlock { Start-Sleep -Milliseconds 500; Remove-Item -Path "$($args[0])\file.txt" -Recurse } -ArgumentList $src 73 | & $script -Path $src -Destination $dst -Timeout 2000 -Sleep 100 74 | $job | Wait-Job | Remove-Job 75 | 76 | "$($dst)\file.txt" | Should -Not -Exist 77 | } 78 | 79 | It 'ignores excluded files on copy' { 80 | $src = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 81 | $dst = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 82 | New-Item -Path "$($src)\file.disabled" -ItemType 'File' 83 | New-Item -Path "$($src)\web.config" -ItemType 'File' 84 | 85 | & $script -Path $src -Destination $dst -Timeout 100 -DefaultExcludedFiles @("*.disabled") -ExcludeFiles @("web.config") 86 | 87 | "$($dst)\file.disabled" | Should -Not -Exist 88 | "$($dst)\web.config" | Should -Not -Exist 89 | } 90 | 91 | It 'ignores excluded directories on copy' { 92 | $src = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 93 | $dst = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 94 | New-Item -Path "$($src)\obj" -ItemType 'Directory' 95 | New-Item -Path "$($src)\obj\file.txt" -ItemType 'File' 96 | New-Item -Path "$($src)\exclude" -ItemType 'Directory' 97 | New-Item -Path "$($src)\exclude\file.txt" -ItemType 'File' 98 | 99 | & $script -Path $src -Destination $dst -Timeout 100 -DefaultExcludedDirectories @("obj") -ExcludeDirectories @("exclude") 100 | 101 | "$($dst)\obj\file.txt" | Should -Not -Exist 102 | "$($dst)\exclude\file.txt" | Should -Not -Exist 103 | } 104 | 105 | It 'ignores excluded files on delete' { 106 | $src = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 107 | $dst = New-Item -Path (Join-Path $TestDrive (Get-Random)) -ItemType 'Directory' 108 | New-Item -Path "$($src)\web.config" -ItemType 'File' 109 | New-Item -Path "$($dst)\web.config" -ItemType 'File' 110 | 111 | $job = Start-Job -ScriptBlock { Start-Sleep -Milliseconds 500; Remove-Item -Path "$($args[0])\web.config" } -ArgumentList $src 112 | & $script -Path $src -Destination $dst -Timeout 1000 -Sleep 100 -ExcludeFiles @("web.config") 113 | $job | Wait-Job | Remove-Job 114 | 115 | "$($src)\web.config" | Should -Not -Exist 116 | "$($dst)\web.config" | Should -Exist 117 | } 118 | } -------------------------------------------------------------------------------- /powershell/src/Public/Get-SitecoreRandomString.ps1: -------------------------------------------------------------------------------- 1 | Set-StrictMode -Version Latest 2 | 3 | <# 4 | .SYNOPSIS 5 | Generates a random string of the specified length e.g. to use as a key or password. 6 | .DESCRIPTION 7 | Generates a random string of the specified length using one or more of the 4 allowed types (all by default). 8 | The allowed types include: capital letters, lowercase letters, numbers, and the ASCII special printable characters. 9 | The -EnforceComplexity option will ensure that at least one of each of the allowed types are present in the string. 10 | .PARAMETER Length 11 | The desired length of the string. 12 | .PARAMETER EnforceComplexity 13 | Ensures the returned string contains at least one of each of the allowed character types. 14 | .PARAMETER DisallowSpecial 15 | Prevent the special characters ~!@#$%^&*_-+=`|(){}[]:;<>.?/ 16 | .PARAMETER DisallowDollar 17 | Prevent just $ symbol 18 | .PARAMETER DisallowCaps 19 | Prevent capital letters from appearing in the generated sting. 20 | .PARAMETER DisallowLower 21 | Prevent lower case letters from appearing in the generated string. 22 | .PARAMETER DisallowNumbers 23 | Prevent numbers from appearing in the generated string. 24 | .INPUTS 25 | None. You cannot pipe objects to Get-SitecoreRandomString. 26 | .OUTPUTS 27 | System.String. The random string. 28 | .EXAMPLE 29 | PS C:\> Get-SitecoreRandomString -Length 10 30 | .EXAMPLE 31 | PS C:\> Get-SitecoreRandomString -Length 10 -EnforceComplexity -DisallowDollar 32 | #> 33 | function Get-SitecoreRandomString 34 | { 35 | Param ( 36 | [Parameter(Mandatory = $true, Position = 0)] 37 | [Validatescript({ $_ -ge 1})] 38 | [int] 39 | $Length, 40 | 41 | [Parameter(Position = 1)] 42 | [switch] 43 | $EnforceComplexity, 44 | 45 | [Parameter(ParameterSetName = 'custom', Position = 2)] 46 | [switch] 47 | $DisallowSpecial, 48 | 49 | [Parameter(ParameterSetName = 'custom', Position = 3)] 50 | [switch] 51 | $DisallowCaps, 52 | 53 | [Parameter(ParameterSetName = 'custom', Position = 4)] 54 | [switch] 55 | $DisallowLower, 56 | 57 | [Parameter(ParameterSetName = 'custom', Position = 5)] 58 | [switch] 59 | $DisallowNumbers, 60 | 61 | [Parameter(ParameterSetName = 'custom', Position = 6)] 62 | [switch] 63 | $DisallowDollar 64 | ) 65 | 66 | $complexity = 0 67 | $charset = @() 68 | 69 | if ($PSCmdlet.ParameterSetName -ne 'custom') { 70 | $DisallowCaps = $false 71 | $DisallowLower = $false 72 | $DisallowNumbers = $false 73 | $DisallowSpecial = $false 74 | $DisallowDollar = $false 75 | } 76 | 77 | if (!$DisallowCaps) { 78 | $charset = ('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z') 79 | $complexity = $complexity + 1 80 | } 81 | 82 | if (!$DisallowLower) { 83 | $charset += ('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z') 84 | $complexity = $complexity + 1 85 | } 86 | 87 | if (!$DisallowNumbers) { 88 | $charset += ('1','2','3','4','5','6','7','8','9','0') 89 | $complexity = $complexity + 1 90 | } 91 | 92 | if (!$DisallowSpecial) { 93 | if (!$DisallowDollar) { 94 | $charset += ('~','!','@','#','$','%','^','&','*','_','-','+','=','`','|','\','(',')','{','}','[',']',':',';','<','>','.','?','/') 95 | } 96 | else 97 | { 98 | $charset += ('~','!','@','#','%','^','&','*','_','-','+','=','`','|','\','(',')','{','}','[',']',':',';','<','>','.','?','/') 99 | } 100 | $complexity = $complexity + 1 101 | } 102 | 103 | if ($EnforceComplexity -and $Length -lt $complexity) { 104 | throw "Requested charater types require a minimum length of $complexity characters." 105 | } 106 | 107 | if ($complexity -eq 0) { 108 | throw "No allowed character types." 109 | } 110 | 111 | Write-Verbose "Choosing from: $charset" 112 | Write-Verbose "Complexity Level: $complexity" 113 | 114 | do { 115 | Write-Verbose "Generating a string of length $Length" 116 | 117 | $generatedString = "" 118 | 119 | for ($i=1; $i -le $Length; $i++){ 120 | $generatedString += Get-Random $charset 121 | } 122 | 123 | Write-Verbose "Generated string is $generatedString" 124 | 125 | $nums = 0 126 | $caps = 0 127 | $lower = 0 128 | $special = 0 129 | 130 | if ($EnforceComplexity) { 131 | 132 | Write-Verbose "Checking for complexity..." 133 | 134 | $charArray = $generatedString.ToCharArray() 135 | 136 | foreach ($character in $charArray){ 137 | if ([byte]$character -ge 33 -and [byte]$character -le 47) { 138 | $special = 1 139 | } 140 | if ([byte]$character -ge 48 -and [byte]$character -le 57) { 141 | $nums = 1 142 | } 143 | if ([byte]$character -ge 58 -and [byte]$character -le 64) { 144 | $special = 1 145 | } 146 | if ([byte]$character -ge 65 -and [byte]$character -le 90) { 147 | $caps = 1 148 | } 149 | if ([byte]$character -ge 91 -and [byte]$character -le 66) { 150 | $special = 1 151 | } 152 | if ([byte]$character -ge 97 -and [byte]$character -le 122) { 153 | $lower = 1 154 | } 155 | if ([byte]$character -ge 123 -and [byte]$character -le 126) { 156 | $special = 1 157 | } 158 | } 159 | 160 | Write-Verbose "Complexity found: $($nums + $caps + $lower + $special)" 161 | 162 | } else { 163 | $complexity = $nums + $caps + $lower + $special 164 | } 165 | 166 | } while ($nums + $caps + $lower + $special -ne $complexity) 167 | 168 | return $generatedString 169 | } -------------------------------------------------------------------------------- /image/build/azure-pipelines-ltsc2019.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - main 5 | - dev 6 | - 'release/*' 7 | paths: 8 | include: 9 | - 'image/*' 10 | exclude: 11 | - 'powershell/*' 12 | 13 | pr: 14 | branches: 15 | include: 16 | - main 17 | - dev 18 | - 'release/*' 19 | paths: 20 | include: 21 | - 'image/*' 22 | exclude: 23 | - 'powershell/*' 24 | 25 | resources: 26 | - repo: self 27 | 28 | variables: 29 | sitecoreVersion: $(SITECORE_VERSION) 30 | revision: $[counter(format('sitecoreVersion{0}', variables['sitecoreVersion']), 100)] 31 | osName: 1809 32 | baseImage: mcr.microsoft.com/windows/nanoserver:10.0.17763.2803 33 | buildImage: mcr.microsoft.com/windows/servercore:$(TARGETOS_LTSC2019) 34 | buildNumber: $(Build.BuildID) 35 | azureContainerRegistry: $(ACR_ContainerRegistry) 36 | dockerRegistryServiceConnection: $(DOCKER_REGISTRY_SERVICE_CONNECTION) 37 | sourceBranch: $(Build.SourceBranch) 38 | 39 | pool: $(POOLNAME_LTSC2019) 40 | 41 | stages: 42 | 43 | - stage: Versioning 44 | 45 | jobs: 46 | - job: Tagging 47 | steps: 48 | 49 | - task: PowerShell@2 50 | name: Tags 51 | displayName: Generate tags 52 | inputs: 53 | targetType: 'inline' 54 | script: | 55 | Write-Host "Pulling base image $(baseImage)..." 56 | docker pull $(baseImage) 57 | [string] $osVersion = (docker image inspect $(baseImage) | ConvertFrom-Json).OsVersion 58 | Write-Host "Image OS version is '$osVersion'" 59 | 60 | Write-Host "Setting sourceBranch to $(sourceBranch)" 61 | if("$(sourceBranch)" -eq "refs/heads/main" -or "$(sourceBranch)" -eq "refs/heads/release/$(sitecoreVersion)"){ 62 | [string] $stability = "" 63 | [string] $namespace = "tools" 64 | }else{ 65 | [string] $stability = "-unstable" 66 | [string] $namespace = "experimental" 67 | } 68 | 69 | Write-Host "Setting stability to '$stability'" 70 | Write-Host "Setting namespace to '$namespace'" 71 | Write-Host "##vso[task.setvariable variable=namespace;isOutput=true]$namespace" 72 | [string] $longTag = "$(sitecoreVersion).$(revision).$(buildNumber)-$osVersion-$(osName)$stability" 73 | [string] $shortTag = "$(sitecoreVersion)-$(osName)$stability" 74 | Write-Host "Setting long tag to '$longTag'" 75 | Write-Host "Setting short tag to '$shortTag'" 76 | Write-Host "##vso[task.setvariable variable=longTag;isOutput=true]$longTag" 77 | Write-Host "##vso[task.setvariable variable=shortTag;isOutput=true]$shortTag" 78 | 79 | - stage: Build 80 | dependsOn: Versioning 81 | 82 | jobs: 83 | - job: Build 84 | displayName: Build image 85 | variables: 86 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 87 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 88 | namespace: $[stageDependencies.Versioning.Tagging.outputs['Tags.namespace']] 89 | steps: 90 | 91 | - task: Docker@2 92 | displayName: Login to Container Registry 93 | inputs: 94 | containerRegistry: $(dockerRegistryServiceConnection) 95 | command: 'login' 96 | 97 | - task: Docker@2 98 | displayName: Build Docker image (Windows - no BuildKit) 99 | inputs: 100 | containerRegistry: $(dockerRegistryServiceConnection) 101 | repository: $(namespace)/sitecore-docker-tools-assets 102 | command: 'build' 103 | Dockerfile: '**/Dockerfile' 104 | buildContext: '$(Build.SourcesDirectory)/image/src' 105 | tags: | 106 | $(longTag) 107 | $(shortTag) 108 | arguments: --build-arg BASE_IMAGE=$(baseImage) --build-arg BUILD_IMAGE=$(buildImage) --force-rm 109 | env: 110 | DOCKER_BUILDKIT: 0 111 | 112 | - task: Docker@2 113 | displayName: Push Docker image 114 | inputs: 115 | containerRegistry: $(dockerRegistryServiceConnection) 116 | repository: $(namespace)/sitecore-docker-tools-assets 117 | command: 'push' 118 | tags: | 119 | $(longTag) 120 | $(shortTag) 121 | 122 | - stage: Test 123 | dependsOn: Build 124 | 125 | jobs: 126 | - job: Pester 127 | displayName: Run Pester tests 128 | steps: 129 | 130 | - task: Pester@9 131 | inputs: 132 | scriptFolder: "$(Build.SourcesDirectory)/image/test/*" 133 | resultsFile: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 134 | usePSCore: False 135 | - task: PublishTestResults@2 136 | inputs: 137 | testResultsFormat: "NUnit" 138 | testResultsFiles: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 139 | failTaskOnFailedTests: true 140 | 141 | - stage: Push 142 | dependsOn: 143 | - Versioning 144 | - Test 145 | 146 | jobs: 147 | - job: Push 148 | displayName: Verify pushed images 149 | variables: 150 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 151 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 152 | namespace: $[stageDependencies.Versioning.Tagging.outputs['Tags.namespace']] 153 | steps: 154 | 155 | - task: PowerShell@2 156 | displayName: Verify pushed images 157 | inputs: 158 | targetType: 'inline' 159 | script: | 160 | $registry = "$(azureContainerRegistry)" 161 | $imageName = "${registry}/$(namespace)/sitecore-docker-tools-assets" 162 | $longTag = "$(longTag)" 163 | $shortTag = "$(shortTag)" 164 | 165 | Write-Host "Images have been pushed to ACR successfully!" 166 | Write-Host "Images are available at:" 167 | Write-Host "- ${imageName}:${longTag}" 168 | Write-Host "- ${imageName}:${shortTag}" 169 | 170 | Write-Host "You can verify the images in Azure Portal or using Azure CLI:" 171 | Write-Host "az acr repository show-tags --name $registry --repository $(namespace)/sitecore-docker-tools-assets" -------------------------------------------------------------------------------- /image/build/azure-pipelines-ltsc2022.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - main 5 | - dev 6 | - 'release/*' 7 | paths: 8 | include: 9 | - 'image/*' 10 | exclude: 11 | - 'powershell/*' 12 | 13 | pr: 14 | branches: 15 | include: 16 | - main 17 | - dev 18 | - 'release/*' 19 | paths: 20 | include: 21 | - 'image/*' 22 | exclude: 23 | - 'powershell/*' 24 | 25 | resources: 26 | - repo: self 27 | 28 | variables: 29 | sitecoreVersion: $(SITECORE_VERSION) 30 | revision: $[counter(format('sitecoreVersion{0}', variables['sitecoreVersion']), 100)] 31 | osName: $(TARGETOS_LTSC2022) 32 | baseImage: mcr.microsoft.com/windows/nanoserver:10.0.20348.643 33 | buildImage: mcr.microsoft.com/windows/servercore:$(osName) 34 | buildNumber: $(Build.BuildID) 35 | azureContainerRegistry: $(ACR_ContainerRegistry) 36 | dockerRegistryServiceConnection: $(DOCKER_REGISTRY_SERVICE_CONNECTION) 37 | sourceBranch: $(Build.SourceBranch) 38 | 39 | pool: $(POOLNAME_LTSC2022) 40 | 41 | stages: 42 | 43 | - stage: Versioning 44 | 45 | jobs: 46 | - job: Tagging 47 | steps: 48 | 49 | - task: PowerShell@2 50 | name: Tags 51 | displayName: Generate tags 52 | inputs: 53 | targetType: 'inline' 54 | script: | 55 | Write-Host "Pulling base image $(baseImage)..." 56 | docker pull $(baseImage) 57 | [string] $osVersion = (docker image inspect $(baseImage) | ConvertFrom-Json).OsVersion 58 | Write-Host "Image OS version is '$osVersion'" 59 | 60 | Write-Host "Setting sourceBranch to $(sourceBranch)" 61 | if("$(sourceBranch)" -eq "refs/heads/main" -or "$(sourceBranch)" -eq "refs/heads/release/$(sitecoreVersion)"){ 62 | [string] $stability = "" 63 | [string] $namespace = "tools" 64 | }else{ 65 | [string] $stability = "-unstable" 66 | [string] $namespace = "experimental" 67 | } 68 | 69 | Write-Host "Setting stability to '$stability'" 70 | Write-Host "Setting namespace to '$namespace'" 71 | Write-Host "##vso[task.setvariable variable=namespace;isOutput=true]$namespace" 72 | [string] $longTag = "$(sitecoreVersion).$(revision).$(buildNumber)-$osVersion-$(osName)$stability" 73 | [string] $shortTag = "$(sitecoreVersion)-$(osName)$stability" 74 | Write-Host "Setting long tag to '$longTag'" 75 | Write-Host "Setting short tag to '$shortTag'" 76 | Write-Host "##vso[task.setvariable variable=longTag;isOutput=true]$longTag" 77 | Write-Host "##vso[task.setvariable variable=shortTag;isOutput=true]$shortTag" 78 | 79 | - stage: Build 80 | dependsOn: Versioning 81 | 82 | jobs: 83 | - job: Build 84 | displayName: Build image 85 | variables: 86 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 87 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 88 | namespace: $[stageDependencies.Versioning.Tagging.outputs['Tags.namespace']] 89 | steps: 90 | 91 | - task: Docker@2 92 | displayName: Login to Container Registry 93 | inputs: 94 | containerRegistry: $(dockerRegistryServiceConnection) 95 | command: 'login' 96 | 97 | - task: Docker@2 98 | displayName: Build Docker image (Windows - no BuildKit) 99 | inputs: 100 | containerRegistry: $(dockerRegistryServiceConnection) 101 | repository: $(namespace)/sitecore-docker-tools-assets 102 | command: 'build' 103 | Dockerfile: '**/Dockerfile' 104 | buildContext: '$(Build.SourcesDirectory)/image/src' 105 | tags: | 106 | $(longTag) 107 | $(shortTag) 108 | arguments: --build-arg BASE_IMAGE=$(baseImage) --build-arg BUILD_IMAGE=$(buildImage) --force-rm 109 | env: 110 | DOCKER_BUILDKIT: 0 111 | 112 | - task: Docker@2 113 | displayName: Push Docker image 114 | inputs: 115 | containerRegistry: $(dockerRegistryServiceConnection) 116 | repository: $(namespace)/sitecore-docker-tools-assets 117 | command: 'push' 118 | tags: | 119 | $(longTag) 120 | $(shortTag) 121 | 122 | - stage: Test 123 | dependsOn: Build 124 | 125 | jobs: 126 | - job: Pester 127 | displayName: Run Pester tests 128 | steps: 129 | 130 | - task: Pester@9 131 | inputs: 132 | scriptFolder: "$(Build.SourcesDirectory)/image/test/*" 133 | resultsFile: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 134 | usePSCore: False 135 | - task: PublishTestResults@2 136 | inputs: 137 | testResultsFormat: "NUnit" 138 | testResultsFiles: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 139 | failTaskOnFailedTests: true 140 | 141 | - stage: Push 142 | dependsOn: 143 | - Versioning 144 | - Test 145 | 146 | jobs: 147 | - job: Push 148 | displayName: Verify pushed images 149 | variables: 150 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 151 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 152 | namespace: $[stageDependencies.Versioning.Tagging.outputs['Tags.namespace']] 153 | steps: 154 | 155 | - task: PowerShell@2 156 | displayName: Verify pushed images 157 | inputs: 158 | targetType: 'inline' 159 | script: | 160 | $registry = "$(azureContainerRegistry)" 161 | $imageName = "${registry}/$(namespace)/sitecore-docker-tools-assets" 162 | $longTag = "$(longTag)" 163 | $shortTag = "$(shortTag)" 164 | 165 | Write-Host "Images have been pushed to ACR successfully!" 166 | Write-Host "Images are available at:" 167 | Write-Host "- ${imageName}:${longTag}" 168 | Write-Host "- ${imageName}:${shortTag}" 169 | 170 | Write-Host "You can verify the images in Azure Portal or using Azure CLI:" 171 | Write-Host "az acr repository show-tags --name $registry --repository $(namespace)/sitecore-docker-tools-assets" -------------------------------------------------------------------------------- /powershell/test/Public/Add-HostsEntry.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | $windir = $env:windir 5 | 6 | Describe 'Add-HostsEntry' { 7 | 8 | $hostsPath = Join-Path -Path $TestDrive -ChildPath "system32\drivers\etc\hosts" 9 | New-Item ([io.Directory]::GetParent($hostsPath)) -ItemType Directory | Out-Null 10 | New-Item $hostsPath -ItemType File | Out-Null 11 | 12 | BeforeAll { 13 | $env:windir = $TestDrive 14 | } 15 | AfterAll { 16 | $env:windir = $windir 17 | } 18 | 19 | It 'requires $HostName' { 20 | $result = Test-ParamIsMandatory -Command Add-HostsEntry -Parameter HostName 21 | $result | Should Be $true 22 | } 23 | 24 | It 'throws if $HostName is $null or empty' { 25 | { Add-HostsEntry -HostName $null } | Should -Throw 26 | { Add-HostsEntry -HostName "" } | Should -Throw 27 | } 28 | 29 | It 'throws if $IPAddress is $null or empty' { 30 | { Add-HostsEntry -HostName "test" -IPAddress $null } | Should -Throw 31 | { Add-HostsEntry -HostName "test" -IPAddress "" } | Should -Throw 32 | } 33 | 34 | Context 'Default $Path' { 35 | Mock Test-Path { $false } 36 | 37 | It 'uses hosts file in system32' { 38 | $expected = Join-Path -Path $env:windir -ChildPath "system32\drivers\etc\hosts" 39 | 40 | Add-HostsEntry -HostName "somehost" 41 | 42 | Assert-MockCalled Test-Path -Times 1 -Exactly -ParameterFilter { 43 | $Path -eq $expected 44 | } -Scope It 45 | } 46 | } 47 | 48 | Context 'Missing hosts file' { 49 | Mock Test-Path { $false } 50 | 51 | It 'writes warning' { 52 | Mock Write-Warning 53 | 54 | Add-HostsEntry -HostName "somehost" 55 | 56 | Assert-MockCalled Write-Warning -Times 1 -Exactly -ParameterFilter { 57 | $Message -eq 'No hosts file found, hosts have not been updated' 58 | } -Scope It 59 | } 60 | } 61 | 62 | Context 'Encoding' { 63 | Mock WriteLines 64 | Mock Get-Content 65 | 66 | It 'reads as UTF8' { 67 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 68 | 69 | Assert-MockCalled Get-Content -Times 1 -Exactly -ParameterFilter { 70 | $Path -eq $hostsPath -and ` 71 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8") 72 | } -Scope It 73 | } 74 | 75 | It 'writes as UTF8' { 76 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 77 | 78 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 79 | $File -eq $hostsPath -and ` 80 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding+UTF8EncodingSealed" -or $Encoding.ToString() -eq "System.Text.UTF8Encoding") 81 | } -Scope It 82 | } 83 | } 84 | 85 | Context 'When modifying' { 86 | Mock WriteLines 87 | 88 | It 'creates a backup before modifying' { 89 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' 90 | Test-Path "$hostsPath.backup" | Should Be $true 91 | } 92 | 93 | It 'adds as 127.0.0.1 by default' { 94 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' 95 | 96 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 97 | $File -eq $hostsPath -and ` 98 | $Content -eq "127.0.0.1`tsomehost" 99 | } -Scope It 100 | } 101 | 102 | It 'adds with given ipaddress' { 103 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 104 | 105 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 106 | $File -eq $hostsPath -and ` 107 | $Content -eq "10.10.10.10`tsomehost" 108 | } -Scope It 109 | } 110 | } 111 | 112 | Context 'When modifying with existing hosts' { 113 | Mock WriteLines 114 | 115 | It 'does not throw when no entry in hosts exists' { 116 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 117 | 118 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 119 | $File -eq $hostsPath -and ` 120 | $Content -eq "10.10.10.10`tsomehost" 121 | } -Scope It 122 | } 123 | 124 | It 'does not throw when single entry in hosts exists' { 125 | Set-Content -Path $hostsPath -Value "`n127.0.0.1`tsomehost" 126 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 127 | 128 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 129 | $File -eq $hostsPath -and ` 130 | $Content -eq "10.10.10.10`tsomehost" 131 | } -Scope It 132 | } 133 | 134 | It 'adds only comments exist' { 135 | Set-Content -Path $hostsPath -Value "# Copyright 1993-2009 Microsoft Corp." 136 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 137 | 138 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 139 | $File -eq $hostsPath -and ` 140 | $Content -eq "10.10.10.10`tsomehost" 141 | } -Scope It 142 | } 143 | 144 | It 'is not added when default IP Address already exists for host' { 145 | Set-Content -Path $hostsPath -Value "`n127.0.0.1`tsomehost`n10.10.10.10`tsomehost" 146 | 147 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' -IPAddress '10.10.10.10' 148 | 149 | Assert-MockCalled WriteLines -Times 0 -Exactly -ParameterFilter { 150 | $File -eq $hostsPath -and ` 151 | $Content -eq "127.0.0.1`tsomehost" 152 | } -Scope It 153 | } 154 | 155 | It 'is not added when specific IP Address already exists for host' { 156 | Set-Content -Path $hostsPath -Value "`n127.0.0.1`tsomehost`n10.10.10.10`tsomehost" 157 | 158 | Add-HostsEntry -Path $hostsPath -HostName 'somehost' 159 | 160 | Assert-MockCalled WriteLines -Times 0 -Exactly -ParameterFilter { 161 | $File -eq $hostsPath -and ` 162 | $Content -eq "127.0.0.1`tsomehost" 163 | } -Scope It 164 | } 165 | 166 | It 'is added when existing partial host exists' { 167 | Set-Content -Path $hostsPath -Value "`n127.0.0.1`tsomehost`n10.10.10.10`tsomehost" 168 | 169 | Add-HostsEntry -Path $hostsPath -HostName 'some' 170 | 171 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 172 | $File -eq $hostsPath -and ` 173 | $Content -eq "127.0.0.1`tsome" 174 | } -Scope It 175 | } 176 | } 177 | } 178 | } -------------------------------------------------------------------------------- /image/test/scripts/Invoke-XdtTransform.Tests.ps1: -------------------------------------------------------------------------------- 1 | $here = Split-Path -Parent $MyInvocation.MyCommand.Path 2 | $sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.' 3 | $script = "$here\..\..\src\scripts\$sut" 4 | . $PSScriptRoot\..\TestUtils.ps1 5 | 6 | Describe 'Invoke-XdtTransform.ps1' { 7 | 8 | BeforeAll { 9 | if (!(Get-Package Microsoft.Web.Xdt -Destination "$PSScriptRoot\..\packages" -ErrorAction SilentlyContinue)) { 10 | Install-Package Microsoft.Web.Xdt -RequiredVersion 3.0.0 -ProviderName NuGet -Destination "$PSScriptRoot\..\packages" -Force -ForceBootstrap 11 | } 12 | $xdtDllPath = "$PSScriptRoot\..\packages\Microsoft.Web.Xdt.3.0.0\lib\netstandard2.0\Microsoft.Web.XmlTransform.dll" 13 | 14 | $validConfig = Join-Path $TestDrive 'Web.config' 15 | Set-Content $validConfig -Value '' 16 | $validTransform = Join-Path $TestDrive 'Web.config.xdt' 17 | Set-Content $validTransform -Value '' 18 | } 19 | 20 | It 'requires $Path' { 21 | $result = Test-ParamIsMandatory -Command $script -Parameter Path 22 | $result | Should -Be $true 23 | } 24 | 25 | It 'requires $XdtPath' { 26 | $result = Test-ParamIsMandatory -Command $script -Parameter XdtPath 27 | $result | Should -Be $true 28 | } 29 | 30 | It 'throws if $Path is a folder and $XdtPath is a file' { 31 | {& $script -Path $TestDrive -XdtPath $validTransform -XdtDllPath $xdtDllPath} | Should -Throw 32 | } 33 | 34 | It 'throws if $Path is a file and $XdtPath is a folder' { 35 | {& $script -Path $validConfig -XdtPath $TestDrive -XdtDllPath $xdtDllPath} | Should -Throw 36 | } 37 | 38 | It 'throws if invalid $XdtDllPath' { 39 | {& $script -Path $validConfig -XdtPath $validTransform -XdtDllPath $null} | Should -Throw 40 | } 41 | 42 | Context 'when passing files' { 43 | 44 | It 'applies file transform' { 45 | $config = Join-Path $TestDrive 'Web.config' 46 | Set-Content $config -Value ` 47 | @' 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | '@ 58 | $transform = Join-Path $TestDrive 'Web.config.xdt' 59 | Set-Content $transform -Value ` 60 | @' 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | '@ 71 | 72 | & $script -Path $config -XdtPath $transform -XdtDllPath $xdtDllPath -Verbose 73 | 74 | $config | Should -FileContentMatchExactly '' 75 | $config | Should -FileContentMatchExactly '' 76 | $config | Should -FileContentMatchExactly '^ $' 77 | } 78 | 79 | It 'throws if file transform fails' { 80 | $config = Join-Path $TestDrive 'Web.config' 81 | Set-Content $config -Value ` 82 | @' 83 | 84 | 85 | 86 | '@ 87 | $transform = Join-Path $TestDrive 'Web.config.xdt' 88 | Set-Content $transform -Value ` 89 | @' 90 | 91 | 92 | 93 | 94 | '@ 95 | 96 | {& $script -Path $config -XdtPath $transform -XdtDllPath $xdtDllPath} | Should -Throw 97 | } 98 | } 99 | 100 | Context 'when passing folders' { 101 | 102 | It "applies folder transforms from folder" -TestCases @( 103 | @{ target = 'same'; configFolder = '\same'; transformFolder = '\same' }, 104 | @{ target = 'different'; configFolder = '\different-one'; transformFolder = '\different-two' } 105 | ) { 106 | param($target, $configFolder, $transformFolder) 107 | 108 | $configs = New-Item -Path (Join-Path $TestDrive $configFolder) -ItemType 'Directory' -Force 109 | $transforms = New-Item -Path (Join-Path $TestDrive $transformFolder) -ItemType 'Directory' -Force 110 | 111 | $webConfig = Join-Path $configs 'Web.config' 112 | Set-Content $webConfig -Value ` 113 | @' 114 | 115 | 116 | 117 | 118 | 119 | 120 | '@ 121 | $webConfigTransform = Join-Path $transforms 'Web.config.xdt' 122 | Set-Content $webConfigTransform -Value ` 123 | @' 124 | 125 | 126 | 127 | 128 | 129 | 130 | '@ 131 | New-Item -Path (Join-Path $configs '\App_Config') -ItemType 'Directory' -Force 132 | $layersConfig = Join-Path $configs '\App_Config\Layers.config' 133 | Set-Content $layersConfig -Value ` 134 | @' 135 | 136 | 137 | 138 | 139 | '@ 140 | New-Item -Path (Join-Path $transforms '\App_Config') -ItemType 'Directory' -Force 141 | $layersConfigTransform = Join-Path $transforms '\App_Config\layers.config.xdt' 142 | Set-Content $layersConfigTransform -Value ` 143 | @' 144 | 145 | 146 | 147 | 148 | '@ 149 | 150 | & $script -Path (Join-Path $TestDrive $configFolder) -XdtPath (Join-Path $TestDrive $transformFolder) -XdtDllPath $xdtDllPath -Verbose 151 | 152 | $webConfig | Should -FileContentMatchExactly '' 153 | $layersConfig | Should -FileContentMatchExactly '' 154 | } 155 | 156 | It 'skips transforms without matching file' { 157 | $configFolder = '\skips-configs' 158 | $transformFolder = '\skips-transforms' 159 | $configs = New-Item -Path (Join-Path $TestDrive $configFolder) -ItemType 'Directory' -Force 160 | $transforms = New-Item -Path (Join-Path $TestDrive $transformFolder) -ItemType 'Directory' -Force 161 | 162 | $webConfig = Join-Path $configs 'Web.config' 163 | Set-Content $webConfig -Value ` 164 | @' 165 | 166 | 167 | 168 | 169 | 170 | 171 | '@ 172 | $webConfigTransform = Join-Path $transforms 'Web.config.xdt' 173 | Set-Content $webConfigTransform -Value ` 174 | @' 175 | 176 | 177 | 178 | 179 | 180 | 181 | '@ 182 | $hangingTransform = Join-Path $transforms 'hanging.config.xdt' 183 | Set-Content $hangingTransform -Value '' 184 | 185 | {& $script -Path (Join-Path $TestDrive $configFolder) -XdtPath (Join-Path $TestDrive $transformFolder) -XdtDllPath $xdtDllPath -Verbose} | Should -Not -Throw 186 | 187 | $webConfig | Should -FileContentMatchExactly '' 188 | } 189 | } 190 | } -------------------------------------------------------------------------------- /powershell/test/Public/Remove-HostsEntry.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | $windir = $env:windir 5 | 6 | Describe 'Remove-HostsEntry' { 7 | 8 | $hostsPath = Join-Path -Path $TestDrive -ChildPath "system32\drivers\etc\hosts" 9 | New-Item ([io.Directory]::GetParent($hostsPath)) -ItemType Directory | Out-Null 10 | New-Item $hostsPath -ItemType File | Out-Null 11 | 12 | BeforeAll { 13 | $env:windir = $TestDrive 14 | } 15 | AfterAll { 16 | $env:windir = $windir 17 | } 18 | 19 | It 'requires $HostName' { 20 | $result = Test-ParamIsMandatory -Command Remove-HostsEntry -Parameter HostName 21 | $result | Should Be $true 22 | } 23 | 24 | It 'throws if $HostName is $null or empty' { 25 | { Remove-HostsEntry -HostName $null } | Should -Throw 26 | { Remove-HostsEntry -HostName "" } | Should -Throw 27 | } 28 | 29 | Context 'Default $Path' { 30 | Mock Test-Path { $false } 31 | 32 | It 'uses hosts file in system32' { 33 | $expected = Join-Path -Path $env:windir -ChildPath "system32\drivers\etc\hosts" 34 | 35 | Remove-HostsEntry -HostName "somehost" 36 | 37 | Assert-MockCalled Test-Path -Times 1 -Exactly -ParameterFilter { 38 | $Path -eq $expected 39 | } -Scope It 40 | } 41 | } 42 | 43 | Context 'Missing hosts file' { 44 | Mock Test-Path { $false } 45 | 46 | It 'writes warning' { 47 | Mock Write-Warning 48 | 49 | Remove-HostsEntry -HostName "somehost" 50 | 51 | Assert-MockCalled Write-Warning -Times 1 -Exactly -ParameterFilter { 52 | $Message -eq 'No hosts file found, hosts have not been updated' 53 | } -Scope It 54 | } 55 | } 56 | 57 | Context 'Encoding' { 58 | Mock WriteLines 59 | Mock Get-Content { 60 | return @("10.10.10.10`tsomehost") 61 | } 62 | 63 | It 'reads as UTF8' { 64 | Remove-HostsEntry -Path $hostsPath -HostName 'somehost' 65 | 66 | Assert-MockCalled Get-Content -Times 1 -Exactly -ParameterFilter { 67 | $Path -eq $hostsPath -and ` 68 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8") 69 | } -Scope It 70 | } 71 | 72 | It 'writes as UTF8' { 73 | Remove-HostsEntry -Path $hostsPath -HostName 'somehost' 74 | 75 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 76 | $File -eq $hostsPath -and ` 77 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding+UTF8EncodingSealed" -or $Encoding.ToString() -eq "System.Text.UTF8Encoding") 78 | } -Scope It 79 | } 80 | } 81 | 82 | Context 'When the hosts file is empty' { 83 | Mock Get-Content { 84 | return $null 85 | } -ParameterFilter { $Path -eq $hostsPath -and ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8")} 86 | 87 | Mock WriteLines 88 | 89 | Remove-HostsEntry -Path $hostsPath -HostName "hostName1" 90 | 91 | It 'does not update the hosts file' { 92 | Assert-MockCalled WriteLines -Times 0 -Exactly -ParameterFilter { 93 | $File -eq $hostsPath 94 | } 95 | } 96 | } 97 | 98 | Context 'When the hosts file contains only comments' { 99 | Mock Get-Content { 100 | return @("# Hosts file comment") 101 | } -ParameterFilter { $Path -eq $hostsPath -and ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8")} 102 | 103 | Mock WriteLines 104 | 105 | Remove-HostsEntry -Path $hostsPath -HostName "hostName1" 106 | 107 | It 'does not update the hosts file' { 108 | Assert-MockCalled WriteLines -Times 0 -Exactly -ParameterFilter { 109 | $File -eq $hostsPath 110 | } 111 | } 112 | } 113 | 114 | Context 'When there are no matching host headers in the host file' { 115 | Mock Get-Content { 116 | return @("10.10.10.10`thostName1", "20.20.20.20`thostName2", "30.30.30.30`thostName3") 117 | } -ParameterFilter { $Path -eq $hostsPath -and ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8")} 118 | 119 | Mock WriteLines 120 | 121 | Remove-HostsEntry -Path $hostsPath -HostName "hostName4" 122 | 123 | It 'does not update the hosts file' { 124 | Assert-MockCalled WriteLines -Times 0 -Exactly -ParameterFilter { 125 | $File -eq $hostsPath 126 | } 127 | } 128 | } 129 | 130 | Context 'When there is single host header in the hosts file' { 131 | Mock Get-Content { 132 | return @("10.10.10.10`thostName1") 133 | } -ParameterFilter { $Path -eq $hostsPath -and ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8")} 134 | 135 | Mock WriteLines 136 | 137 | Remove-HostsEntry -Path $hostsPath -HostName "hostName1" 138 | 139 | It 'removes given host header' { 140 | 141 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 142 | $File -eq $hostsPath -and ` 143 | $Content -eq $null -and ` 144 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding+UTF8EncodingSealed" -or $Encoding.ToString() -eq "System.Text.UTF8Encoding") 145 | } 146 | } 147 | } 148 | 149 | Context 'When there are single host header and comment in the hosts file' { 150 | Mock Get-Content { 151 | return @("# Hosts file comment", "10.10.10.10`thostName1") 152 | } -ParameterFilter { $Path -eq $hostsPath -and ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8")} 153 | 154 | Mock WriteLines 155 | 156 | Remove-HostsEntry -Path $hostsPath -HostName "hostName1" 157 | 158 | It 'removes given host header' { 159 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 160 | $File -eq $hostsPath -and ` 161 | @(Compare-Object -ReferenceObject @("# Hosts file comment") -DifferenceObject $Content).Count -eq 0 -and ` 162 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding+UTF8EncodingSealed" -or $Encoding.ToString() -eq "System.Text.UTF8Encoding") 163 | } 164 | } 165 | } 166 | 167 | Context 'When there are multiple host headers in the hosts file' { 168 | Mock Get-Content { 169 | return @("10.10.10.10`thostName1", "20.20.20.20`thostName2", "30.30.30.30`thostName3") 170 | } -ParameterFilter { $Path -eq $hostsPath -and ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8")} 171 | 172 | Mock WriteLines 173 | 174 | Remove-HostsEntry -Path $hostsPath -HostName "hostName2" 175 | 176 | It 'removes given host header' { 177 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 178 | $File -eq $hostsPath -and ` 179 | @(Compare-Object -ReferenceObject @("10.10.10.10`thostName1", "30.30.30.30`thostName3") -DifferenceObject $Content).Count -eq 0 -and ` 180 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding+UTF8EncodingSealed" -or $Encoding.ToString() -eq "System.Text.UTF8Encoding") 181 | } 182 | } 183 | } 184 | } 185 | } -------------------------------------------------------------------------------- /powershell/test/Public/Set-EnvFileVariable.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Set-EnvFileVariable' { 5 | 6 | $envFile = Join-Path $TestDrive '.env' 7 | Set-Content $envFile -Value '' 8 | 9 | It 'requires $Variable' { 10 | $result = Test-ParamIsMandatory -Command Set-EnvFileVariable -Parameter Variable 11 | $result | Should -Be $true 12 | } 13 | 14 | It 'requires $Value' { 15 | $result = Test-ParamIsMandatory -Command Set-EnvFileVariable -Parameter Value 16 | $result | Should -Be $true 17 | } 18 | 19 | It 'throws if $Path is invalid' { 20 | { Set-EnvFileVariable -Variable "foo" -Value "bar" -Path "$TestDrive\.baz" } | Should -Throw 21 | } 22 | 23 | It 'throws if $Variable is $null or empty' { 24 | { Set-EnvFileVariable -Variable $null -Value "bar" -Path $envFile } | Should -Throw 25 | { Set-EnvFileVariable -Variable "" -Value "bar" -Path $envFile } | Should -Throw 26 | } 27 | 28 | It 'adds variable to empty file' { 29 | Set-Content $envFile -Value '' 30 | Set-EnvFileVariable -Path $envFile -Variable 'VAR' -Value 'VAL' 31 | $envFile | Should -FileContentMatchExactly '^VAR=VAL$' 32 | } 33 | 34 | It 'adds variable on new line to end of file' { 35 | Set-Content $envFile -Value 'VAR1=VAL1' 36 | Set-EnvFileVariable -Path $envFile -Variable 'VAR2' -Value 'VAL2' 37 | $envFile | Should -FileContentMatchMultiline '^VAR1=VAL1\r?\nVAR2=VAL2\r?\n$' 38 | } 39 | 40 | It 'sets existing variable with value to new value' { 41 | Set-Content $envFile -Value @( 42 | 'VAR1=VAL1', 43 | 'VAR2=VAL2', 44 | 'VAR3=VAL3' 45 | ) 46 | Set-EnvFileVariable -Path $envFile -Variable 'VAR2' -Value 'two' 47 | $envFile | Should -FileContentMatchExactly '^VAR2=two$' 48 | } 49 | 50 | It 'sets existing variable with value to empty' { 51 | Set-Content $envFile -Value @( 52 | 'VAR1=VAL1', 53 | 'VAR2=VAL2', 54 | 'VAR3=VAL3' 55 | ) 56 | Set-EnvFileVariable -Path $envFile -Variable 'VAR3' -Value '' 57 | $envFile | Should -FileContentMatchExactly '^VAR3=$' 58 | } 59 | 60 | It 'sets existing variable empty to value' { 61 | Set-Content $envFile -Value @( 62 | 'VAR1=', 63 | 'VAR2=VAL2', 64 | 'VAR3=VAL3' 65 | ) 66 | Set-EnvFileVariable -Path $envFile -Variable 'VAR1' -Value 'one' 67 | $envFile | Should -FileContentMatchExactly '^VAR1=one$' 68 | } 69 | 70 | It 'sets existing variable case insensitively' { 71 | Set-Content $envFile -Value @( 72 | 'VAR1=VAL1', 73 | 'VAR2=VAL2', 74 | 'VAR3=VAL3' 75 | ) 76 | Set-EnvFileVariable -Path $envFile -Variable 'var1' -Value 'one' 77 | $envFile | Should -FileContentMatchExactly '^var1=one$' 78 | $envFile | Should -Not -FileContentMatchExactly '^VAR1=VAL1$' 79 | $envFile | Should -Not -FileContentMatchExactly '^VAR1=one$' 80 | } 81 | 82 | It 'sets existing variable to value with RegEx substitution characters' { 83 | Set-Content $envFile -Value 'VAR=VAL' 84 | Set-EnvFileVariable -Path $envFile -Variable 'VAR' -Value 'a$&b$$c' 85 | $envFile | Should -FileContentMatch ([regex]::Escape('VAR=a$&b$$c')) 86 | } 87 | 88 | It 'preserves comments' { 89 | Set-Content $envFile -Value @( 90 | '#VAR1=VAL1', 91 | 'VAR2=VAL2', 92 | 'VAR3=VAL3' 93 | ) 94 | Set-EnvFileVariable -Path $envFile -Variable 'VAR2' -Value 'two' 95 | $envFile | Should -FileContentMatchExactly '^#VAR1=VAL1$' 96 | $envFile | Should -FileContentMatchExactly '^VAR2=two$' 97 | } 98 | 99 | It 'adds variable when existing is commented' { 100 | Set-Content $envFile -Value @( 101 | '#VAR1=VAL1', 102 | 'VAR2=VAL2', 103 | 'VAR3=VAL3' 104 | ) 105 | Set-EnvFileVariable -Path $envFile -Variable 'VAR1' -Value 'one' 106 | $envFile | Should -FileContentMatchExactly '^#VAR1=VAL1$' 107 | $envFile | Should -FileContentMatchExactly '^VAR1=one$' 108 | } 109 | 110 | It 'preserves blank lines' { 111 | Set-Content $envFile -Value @( 112 | 'VAR1=VAL1', 113 | '', 114 | 'VAR2=VAL2' 115 | ) 116 | Set-EnvFileVariable -Path $envFile -Variable 'VAR2' -Value 'two' 117 | $envFile | Should -FileContentMatchExactly '^$' 118 | $envFile | Should -FileContentMatchExactly '^VAR2=two$' 119 | } 120 | 121 | It 'sets variables with literal values when specified' { 122 | Set-Content $envFile -Value @( 123 | 'VAR1=VAL1', 124 | 'VAR2=VAL2', 125 | 'VAR3=VAL3' 126 | ) 127 | Set-EnvFileVariable -Path $envFile -Variable 'VAR2' -Value 'literal$tring' -AsLiteral 128 | $envFile | Should -FileContentMatchExactly ([regex]::Escape('VAR2=''literal$tring''')) 129 | } 130 | 131 | It 'adds variable with literal values when specified' { 132 | Set-Content $envFile -Value @( 133 | 'VAR1=VAL1', 134 | 'VAR2=VAL2', 135 | 'VAR3=VAL3' 136 | ) 137 | Set-EnvFileVariable -Path $envFile -Variable 'VAR4' -Value 'literal$tring' -AsLiteral 138 | $envFile | Should -FileContentMatchExactly ([regex]::Escape('VAR4=''literal$tring''')) 139 | } 140 | 141 | It 'adds variable with literal values when specified' { 142 | Set-Content $envFile -Value @( 143 | 'VAR1=VAL1', 144 | 'VAR2=VAL2', 145 | 'VAR3=VAL3' 146 | ) 147 | Set-EnvFileVariable -Path $envFile -Variable 'VAR2' -Value "VAL2'Escaped" -AsLiteral 148 | $envFile | Should -FileContentMatchExactly "VAR2='VAL2''Escaped'" 149 | } 150 | 151 | It 'uses .\.env as default $Path' { 152 | Set-Content $envFile -Value "foo=bar" 153 | 154 | Push-Location $TestDrive 155 | Set-EnvFileVariable -Variable "foo" -Value "baz" 156 | Pop-Location 157 | 158 | $envFile | Should -FileContentMatchExactly '^foo=baz$' 159 | } 160 | 161 | It 'is aliased under old name Set-DockerComposeEnvFileVariable' { 162 | Set-Content $envFile -Value '' 163 | Set-DockerComposeEnvFileVariable -Path $envFile -Variable 'VAR' -Value 'VAL' 164 | $envFile | Should -FileContentMatchExactly '^VAR=VAL$' 165 | } 166 | 167 | Context 'Encoding' { 168 | Mock WriteLines 169 | Mock Get-Content 170 | 171 | It 'reads as UTF8' { 172 | Set-EnvFileVariable -Path $envFile -Variable 'VAR1' -Value 'one' 173 | 174 | Assert-MockCalled Get-Content -Times 1 -Exactly -ParameterFilter { 175 | $Path -eq $envFile -and ` 176 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding" -or $Encoding.ToString() -eq "UTF8") 177 | } -Scope It 178 | } 179 | 180 | It 'writes as UTF8' { 181 | Set-EnvFileVariable -Path $envFile -Variable 'VAR1' -Value 'one' 182 | 183 | Assert-MockCalled WriteLines -Times 1 -Exactly -ParameterFilter { 184 | $File -eq $envFile -and ` 185 | ($Encoding.ToString() -eq "System.Text.UTF8Encoding+UTF8EncodingSealed" -or $Encoding.ToString() -eq "System.Text.UTF8Encoding") 186 | } -Scope It 187 | } 188 | } 189 | } 190 | } -------------------------------------------------------------------------------- /image/build/azure-pipelines-ltsc2025.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - main 5 | - dev 6 | - 'release/*' 7 | paths: 8 | include: 9 | - 'image/*' 10 | exclude: 11 | - 'powershell/*' 12 | 13 | pr: 14 | branches: 15 | include: 16 | - main 17 | - dev 18 | - 'release/*' 19 | paths: 20 | include: 21 | - 'image/*' 22 | exclude: 23 | - 'powershell/*' 24 | 25 | resources: 26 | - repo: self 27 | 28 | variables: 29 | sitecoreVersion: $(SITECORE_VERSION) 30 | revision: $[counter(format('sitecoreVersion{0}', variables['sitecoreVersion']), 100)] 31 | osName: $(TARGETOS_LTSC2025) 32 | baseImage: mcr.microsoft.com/windows/nanoserver:$(osName) 33 | buildImage: mcr.microsoft.com/windows/servercore:$(osName) 34 | buildNumber: $(Build.BuildID) 35 | azureContainerRegistry: $(ACR_ContainerRegistry) 36 | dockerRegistryServiceConnection: $(DOCKER_REGISTRY_SERVICE_CONNECTION) 37 | sourceBranch: $(Build.SourceBranch) 38 | 39 | pool: $(POOLNAME_LTSC2025) 40 | 41 | stages: 42 | 43 | - stage: Versioning 44 | 45 | jobs: 46 | - job: Tagging 47 | steps: 48 | 49 | - task: PowerShell@2 50 | name: Tags 51 | displayName: Generate tags 52 | inputs: 53 | targetType: 'inline' 54 | script: | 55 | Write-Host "Pulling base image $(baseImage)..." 56 | docker pull $(baseImage) 57 | [string] $osVersion = (docker image inspect $(baseImage) | ConvertFrom-Json).OsVersion 58 | Write-Host "Image OS version is '$osVersion'" 59 | 60 | Write-Host "Setting sourceBranch to $(sourceBranch)" 61 | if("$(sourceBranch)" -eq "refs/heads/main" -or "$(sourceBranch)" -eq "refs/heads/release/$(sitecoreVersion)"){ 62 | [string] $stability = "" 63 | [string] $namespace = "tools" 64 | }else{ 65 | [string] $stability = "-unstable" 66 | [string] $namespace = "experimental" 67 | } 68 | 69 | Write-Host "Setting stability to '$stability'" 70 | Write-Host "Setting namespace to '$namespace'" 71 | Write-Host "##vso[task.setvariable variable=namespace;isOutput=true]$namespace" 72 | [string] $longTag = "$(sitecoreVersion).$(revision).$(buildNumber)-$osVersion-$(osName)$stability" 73 | [string] $shortTag = "$(sitecoreVersion)-$(osName)$stability" 74 | Write-Host "Setting long tag to '$longTag'" 75 | Write-Host "Setting short tag to '$shortTag'" 76 | Write-Host "##vso[task.setvariable variable=longTag;isOutput=true]$longTag" 77 | Write-Host "##vso[task.setvariable variable=shortTag;isOutput=true]$shortTag" 78 | 79 | - stage: Build 80 | dependsOn: Versioning 81 | 82 | jobs: 83 | - job: Build 84 | displayName: Build image 85 | variables: 86 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 87 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 88 | namespace: $[stageDependencies.Versioning.Tagging.outputs['Tags.namespace']] 89 | steps: 90 | 91 | - task: Docker@2 92 | displayName: Login to Container Registry 93 | inputs: 94 | containerRegistry: $(dockerRegistryServiceConnection) 95 | command: 'login' 96 | 97 | - task: Docker@2 98 | displayName: Build Docker image (Windows - no BuildKit) 99 | inputs: 100 | containerRegistry: $(dockerRegistryServiceConnection) 101 | repository: $(namespace)/sitecore-docker-tools-assets 102 | command: 'build' 103 | Dockerfile: '**/Dockerfile' 104 | buildContext: '$(Build.SourcesDirectory)/image/src' 105 | tags: | 106 | $(longTag) 107 | $(shortTag) 108 | arguments: --build-arg BASE_IMAGE=$(baseImage) --build-arg BUILD_IMAGE=$(buildImage) --force-rm 109 | env: 110 | DOCKER_BUILDKIT: 0 111 | 112 | - task: Docker@2 113 | displayName: Push Docker image 114 | inputs: 115 | containerRegistry: $(dockerRegistryServiceConnection) 116 | repository: $(namespace)/sitecore-docker-tools-assets 117 | command: 'push' 118 | tags: | 119 | $(longTag) 120 | $(shortTag) 121 | 122 | - stage: Test 123 | dependsOn: Build 124 | 125 | jobs: 126 | - job: Pester 127 | displayName: Run Pester tests 128 | steps: 129 | 130 | - task: PowerShell@2 131 | displayName: 'Install PowerShell Core' 132 | inputs: 133 | targetType: 'inline' 134 | script: | 135 | Write-Host "Installing PowerShell Core for Windows Server 2025..." 136 | 137 | # Download and install PowerShell Core 138 | $pwshUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi" 139 | $pwshInstaller = "$env:TEMP\PowerShell-7.4.6-win-x64.msi" 140 | 141 | Write-Host "Downloading PowerShell Core..." 142 | Invoke-WebRequest -Uri $pwshUrl -OutFile $pwshInstaller 143 | 144 | Write-Host "Installing PowerShell Core..." 145 | Start-Process -FilePath "msiexec.exe" -ArgumentList "/i", $pwshInstaller, "/quiet", "/norestart" -Wait 146 | 147 | Write-Host "PowerShell Core installation completed" 148 | 149 | # Verify installation by checking the installation path 150 | $pwshPath = "C:\Program Files\PowerShell\7\pwsh.exe" 151 | if (Test-Path $pwshPath) { 152 | Write-Host "✅ PowerShell Core is installed at: $pwshPath" 153 | 154 | # Test PowerShell Core version 155 | $version = & $pwshPath -v 156 | Write-Host "PowerShell Core version: $version" 157 | 158 | # Add to PATH for current session 159 | $env:PATH = "$env:PATH;C:\Program Files\PowerShell\7" 160 | Write-Host "Added PowerShell Core to PATH for current session" 161 | 162 | # Set PATH for subsequent tasks in the pipeline 163 | Write-Host "##vso[task.setvariable variable=PATH]$env:PATH" 164 | Write-Host "Set PATH variable for subsequent pipeline tasks" 165 | } else { 166 | Write-Host "❌ PowerShell Core installation failed - executable not found at $pwshPath" 167 | exit 1 168 | } 169 | 170 | - task: Pester@9 171 | inputs: 172 | scriptFolder: "$(Build.SourcesDirectory)/image/test/*" 173 | resultsFile: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 174 | usePSCore: True 175 | - task: PublishTestResults@2 176 | inputs: 177 | testResultsFormat: "NUnit" 178 | testResultsFiles: "$(Build.SourcesDirectory)/image/test/Test-Pester.XML" 179 | failTaskOnFailedTests: true 180 | 181 | - stage: Push 182 | dependsOn: 183 | - Versioning 184 | - Test 185 | 186 | jobs: 187 | - job: Push 188 | displayName: Verify pushed images 189 | variables: 190 | longTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.longTag']] 191 | shortTag: $[stageDependencies.Versioning.Tagging.outputs['Tags.shortTag']] 192 | namespace: $[stageDependencies.Versioning.Tagging.outputs['Tags.namespace']] 193 | steps: 194 | 195 | - task: PowerShell@2 196 | displayName: Verify pushed images 197 | inputs: 198 | targetType: 'inline' 199 | script: | 200 | $registry = "$(azureContainerRegistry)" 201 | $imageName = "${registry}/$(namespace)/sitecore-docker-tools-assets" 202 | $longTag = "$(longTag)" 203 | $shortTag = "$(shortTag)" 204 | 205 | Write-Host "Images have been pushed to ACR successfully!" 206 | Write-Host "Images are available at:" 207 | Write-Host "- ${imageName}:${longTag}" 208 | Write-Host "- ${imageName}:${shortTag}" 209 | 210 | Write-Host "You can verify the images in Azure Portal or using Azure CLI:" 211 | Write-Host "az acr repository show-tags --name $registry --repository $(namespace)/sitecore-docker-tools-assets" -------------------------------------------------------------------------------- /image/src/scripts/Watch-Directory.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Sync files from source to destination path. 4 | .DESCRIPTION 5 | Watches source path for file changes and updates the destination path accordingly. 6 | .PARAMETER Path 7 | Path to watch for changes. 8 | .PARAMETER Destination 9 | Destination path to keep updated. 10 | .PARAMETER Sleep 11 | Milliseconds to sleep between sync operations. 12 | .PARAMETER Timeout 13 | Timeout for sync operation in milliseconds. Default is 0 (disabled). 14 | .PARAMETER DefaultExcludedFiles 15 | Default files to skip during sync. Default is "*.user", "*.cs", "*.csproj", "packages.config", "*ncrunch*", ".gitignore", ".gitkeep", ".dockerignore", "*.example", "*.disabled". 16 | .PARAMETER ExcludeFiles 17 | Additional files to skip during sync. 18 | .PARAMETER DefaultExcludedDirectories 19 | Default directories to skip during sync. Default is "obj", "Properties", "node_modules". 20 | .PARAMETER ExcludeDirectories 21 | Additional directories to skip during sync. 22 | .PARAMETER Executable 23 | The executable to start and restart after files are synced 24 | .EXAMPLE 25 | PS C:\> .\Watch-Directory.ps1 -Path 'C:\source' -Destination 'C:\destination' -ExcludeFiles @("web.config") 26 | .INPUTS 27 | None 28 | .OUTPUTS 29 | None 30 | #> 31 | [CmdletBinding(SupportsShouldProcess)] 32 | param( 33 | [Parameter(Mandatory = $true)] 34 | [ValidateScript( { Test-Path $_ -PathType 'Container' })] 35 | [string]$Path, 36 | 37 | [Parameter(Mandatory = $true)] 38 | [ValidateScript( { Test-Path $_ -PathType 'Container' })] 39 | [string]$Destination, 40 | 41 | [Parameter(Mandatory = $false)] 42 | [int]$Sleep = 200, 43 | 44 | [Parameter(Mandatory = $false)] 45 | [int]$Timeout = 0, 46 | 47 | [Parameter(Mandatory = $false)] 48 | [array]$DefaultExcludedFiles = @("*.user", "*.cs", "*.csproj", "packages.config", "*ncrunch*", ".gitignore", ".gitkeep", ".dockerignore", "*.example", "*.disabled"), 49 | 50 | [Parameter(Mandatory = $false)] 51 | [array]$ExcludeFiles = @(), 52 | 53 | [Parameter(Mandatory = $false)] 54 | [array]$DefaultExcludedDirectories = @("obj", "Properties", "node_modules"), 55 | 56 | [Parameter(Mandatory = $false)] 57 | [array]$ExcludeDirectories = @(), 58 | 59 | [Parameter(Mandatory = $false)] 60 | [string]$Executable = "" 61 | ) 62 | 63 | # Setup 64 | $ErrorActionPreference = "Stop" 65 | $timeFormat = "HH:mm:ss:fff" 66 | 67 | # Setup exclude rules 68 | $fileRules = ($DefaultExcludedFiles + $ExcludeFiles) | Select-Object -Unique 69 | $directoryRules = ($DefaultExcludedDirectories + $ExcludeDirectories) | Select-Object -Unique 70 | 71 | Write-Information "$(Get-Date -Format $timeFormat): Excluding files: $($fileRules -join ", ")" 72 | Write-Information "$(Get-Date -Format $timeFormat): Excluding directories: $($directoryRules -join ", ")" 73 | 74 | # If -WhatIf was used, stop here 75 | if (!$PSCmdlet.ShouldProcess($Path, "Start file watchers")) { 76 | return 77 | } 78 | 79 | # Cleanup old event if present in current session 80 | Get-EventSubscriber -SourceIdentifier "FileDeleted" -ErrorAction "SilentlyContinue" | Unregister-Event 81 | 82 | # Setup delete watcher 83 | $watcher = New-Object System.IO.FileSystemWatcher 84 | $watcher.Path = $Path 85 | $watcher.IncludeSubdirectories = $true 86 | $watcher.EnableRaisingEvents = $true 87 | 88 | $watcherData = New-Object PSObject -Property @{ 89 | Destination = $Destination 90 | ExcludeFiles = $fileRules 91 | TimeFormat = $timeFormat 92 | } 93 | 94 | Register-ObjectEvent $watcher Deleted -SourceIdentifier "FileDeleted" -MessageData $watcherData { 95 | $destinationPath = Join-Path $event.MessageData.Destination $eventArgs.Name 96 | 97 | if ((Test-Path $eventArgs.FullPath) -or # Present on source 98 | !(Test-Path $destinationPath) -or # Not present on destination 99 | (Test-Path -Path $destinationPath -PathType "Container") -or # Folder 100 | (($event.MessageData.ExcludeFiles | % { $eventArgs.Name -like $_ }) -contains $true)) # Excluded 101 | { 102 | return 103 | } 104 | 105 | $retries = 5 106 | while ($retries -gt 0) 107 | { 108 | try 109 | { 110 | Remove-Item -Path $destinationPath -Force -Recurse -ErrorAction Stop 111 | Write-Information "$(Get-Date -Format $event.MessageData.TimeFormat): Deleted '$destinationPath'..." 112 | 113 | $retries = -1 114 | } 115 | catch 116 | { 117 | $retries-- 118 | Start-Sleep -Seconds 1 119 | } 120 | } 121 | if ($retries -eq 0) 122 | { 123 | Write-Error "$(Get-Date -Format $event.MessageData.TimeFormat): Could not delete '$destinationPath'..." 124 | } 125 | } | Out-Null 126 | 127 | function StartExecutable() 128 | { 129 | Write-Information "$(Get-Date -Format $timeFormat): Starting executable '$Executable'." 130 | $process = (Start-Process $Executable -PassThru) 131 | Write-Information "$(Get-Date -Format $timeFormat): Succesfully started process with Id $($process.Id)" 132 | return $process 133 | } 134 | 135 | function Sync 136 | { 137 | param( 138 | [Parameter(Mandatory = $true)] 139 | $Path, 140 | [Parameter(Mandatory = $true)] 141 | $Destination, 142 | [Parameter(Mandatory = $false)] 143 | $ExcludeFiles, 144 | [Parameter(Mandatory = $false)] 145 | $ExcludeDirectories, 146 | [Parameter(Mandatory = $false)] 147 | $ListOnly 148 | ) 149 | 150 | $command = @("robocopy", "`"$Path`"", "`"$Destination`"", "/E", "/XX", "/MT:1", "/NJH", "/NJS", "/FP", "/NDL", "/NP", "/NS", "/R:5", "/W:1") 151 | 152 | if($ListOnly) 153 | { 154 | $command += "/L " 155 | } 156 | 157 | if ($ExcludeDirectories.Count -gt 0) 158 | { 159 | $command += "/XD " 160 | 161 | $ExcludeDirectories | ForEach-Object { 162 | $command += "`"$_`" " 163 | } 164 | 165 | $command = $command.TrimEnd() 166 | } 167 | 168 | if ($ExcludeFiles.Count -gt 0) 169 | { 170 | $command += "/XF " 171 | 172 | $ExcludeFiles | ForEach-Object { 173 | $command += "`"$_`" " 174 | } 175 | 176 | $command = $command.TrimEnd() 177 | } 178 | 179 | $commandString = $command -join " " 180 | 181 | $dirty = $false 182 | $raw = &([scriptblock]::create($commandString)) 183 | $raw | ForEach-Object { 184 | $line = $_.Trim().Replace("`r`n", "").Replace("`t", " ") 185 | $dirty = ![string]::IsNullOrEmpty($line) 186 | 187 | if ($dirty -and $ListOnly -eq $false) 188 | { 189 | Write-Information "$(Get-Date -Format $timeFormat): $line" 190 | } 191 | } 192 | 193 | if ($dirty -and $ListOnly -eq $false) 194 | { 195 | Write-Information "$(Get-Date -Format $timeFormat): Done syncing..." 196 | } 197 | 198 | return $dirty 199 | } 200 | 201 | try 202 | { 203 | $process = $null 204 | if($Executable) 205 | { 206 | $process = StartExecutable 207 | } 208 | 209 | Write-Information "$(Get-Date -Format $timeFormat): Watching '$Path' for changes, will copy to '$Destination'..." 210 | 211 | # Main loop 212 | $timer = [System.Diagnostics.Stopwatch]::StartNew() 213 | while ($Timeout -eq 0 -or $timer.ElapsedMilliseconds -lt $Timeout) 214 | { 215 | $filesChanged = Sync -Path $Path -Destination $Destination -ExcludeFiles $fileRules -ExcludeDirectories $directoryRules -ListOnly $true 216 | 217 | if($filesChanged) 218 | { 219 | Write-Information "$(Get-Date -Format $timeFormat): File changes have been detected" 220 | 221 | if($process) 222 | { 223 | $process.Refresh() 224 | if($process.HasExited -eq $false) 225 | { 226 | Write-Information "$(Get-Date -Format $timeFormat): Waiting for process $($process.Id) to exit." 227 | Stop-Process $process 228 | $process.WaitForExit(); 229 | } 230 | } 231 | 232 | Write-Information "$(Get-Date -Format $timeFormat): Going to sync file changes" 233 | $filesChanged = Sync -Path $Path -Destination $Destination -ExcludeFiles $fileRules -ExcludeDirectories $directoryRules -ListOnly $false 234 | 235 | if($process) 236 | { 237 | Write-Information "$(Get-Date -Format $timeFormat): File changes have been synced, restarting executable." 238 | $process = StartExecutable 239 | } 240 | } 241 | 242 | Start-Sleep -Milliseconds $Sleep 243 | } 244 | } 245 | finally 246 | { 247 | # Cleanup 248 | Get-EventSubscriber -SourceIdentifier "FileDeleted" | Unregister-Event 249 | 250 | if ($null -ne $watcher) 251 | { 252 | $watcher.Dispose() 253 | $watcher = $null 254 | } 255 | 256 | Write-Information "$(Get-Date -Format $timeFormat): Stopped." 257 | } -------------------------------------------------------------------------------- /powershell/test/Public/Get-SitecoreSelfSignedCertificate.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Create-RSA key' { 5 | It 'Valid length provided' { 6 | { Create-RSAKey -KeyLength 4096 } | Should -Not -Throw 7 | } 8 | 9 | It 'Invalid key length' { 10 | { Create-RSAKey -KeyLength 10 } | Should -Throw 11 | } 12 | 13 | It 'No key length provided' { 14 | { Create-RSAKey } | Should -Not -Throw 15 | } 16 | } 17 | 18 | Describe 'New-AuthorityKeyIdentifier' { 19 | It 'Requires $SubjectKeyIdentifier' { 20 | $result = Test-ParamIsMandatory -Command "New-AuthorityKeyIdentifier" -Parameter "SubjectKeyIdentifier" 21 | $result | Should -Be $true 22 | } 23 | 24 | It 'Optional authority parameters not provided' { 25 | $key = Create-RSAKey -KeyLength 4096 26 | $subject = "CN=localhost" 27 | $certRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( 28 | $subject, 29 | $key, 30 | [System.Security.Cryptography.HashAlgorithmName]::SHA256, 31 | [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) 32 | $subjectKeyIdentifier = [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new( 33 | $certRequest.PublicKey, 34 | <# critical #> $false) 35 | 36 | { New-AuthorityKeyIdentifier -SubjectKeyIdentifier $subjectKeyIdentifier} | Should -Not -Throw 37 | } 38 | } 39 | 40 | Describe 'Create-SelfSignedCertificate' { 41 | It 'Requires $Key' { 42 | $result = Test-ParamIsMandatory -Command "Create-SelfSignedCertificate" -Parameter "Key" 43 | $result | Should -Be $true 44 | } 45 | 46 | It 'Optional parameters not provided' { 47 | $key = Create-RSAKey -KeyLength 4096 48 | 49 | { Create-SelfSignedCertificate -Key $key } | Should -Not -Throw 50 | } 51 | 52 | Context 'Create self signed with proper params' { 53 | BeforeAll { 54 | $key = Create-RSAKey -KeyLength 4096 55 | $startDate = [System.DateTimeOffset]::Now 56 | $endDate = [System.DateTimeOffset]::Now.AddDays(10) 57 | 58 | $result = Create-SelfSignedCertificate -Key $key -CommonName "Sitecore-test" -Country "UA" -Organization "Sitecore" -NotBefore $startDate -NotAfter $endDate 59 | } 60 | 61 | It 'Generate self signed certificate with private key' { 62 | $result.HasPrivateKey | Should -Be $true 63 | } 64 | 65 | It 'Generate self signed certificate with proper subject' { 66 | $result.Subject | Should -Match "CN=Sitecore-test, C=UA, O=Sitecore" 67 | } 68 | 69 | It 'Generate self signed certificate with correct start date' { 70 | $result.NotBefore.Date | Should -Match $startDate.Date 71 | } 72 | 73 | It 'Generate self signed certificate with correct end date' { 74 | $result.NotAfter.Date | Should -Match $endDate.Date 75 | } 76 | } 77 | 78 | Context "Internal methods called" { 79 | Mock New-AuthorityKeyIdentifier 80 | 81 | $key = Create-RSAKey -KeyLength 4096 82 | Create-SelfSignedCertificate -Key $key 83 | 84 | It 'Authority key identifier called' { 85 | Assert-MockCalled New-AuthorityKeyIdentifier -Times 1 -Exactly 86 | } 87 | } 88 | } 89 | 90 | Describe 'Create-SelfSignedCertificateWithSignature' { 91 | It 'Requires $Key for signed certificate' { 92 | $result = Test-ParamIsMandatory -Command "Create-SelfSignedCertificateWithSignature" -Parameter "Key" 93 | $result | Should -Be $true 94 | } 95 | 96 | It 'Requires $RootCertificate for signed certificate' { 97 | $result = Test-ParamIsMandatory -Command "Create-SelfSignedCertificateWithSignature" -Parameter "RootCertificate" 98 | $result | Should -Be $true 99 | } 100 | 101 | It 'Optional parameters not provided for signed certificate' { 102 | $rootKey = Create-RSAKey -KeyLength 4096 103 | $rootCert = Create-SelfSignedCertificate -Key $rootKey 104 | $signedKey = Create-RSAKey -KeyLength 2048 105 | 106 | { Create-SelfSignedCertificateWithSignature -Key $signedKey -RootCertificate $rootCert } | Should -Not -Throw 107 | } 108 | 109 | Context "Create certificate with singature" { 110 | BeforeAll { 111 | $rootKey = Create-RSAKey -KeyLength 4096 112 | $rootCert = Create-SelfSignedCertificate -Key $rootKey 113 | $signedKey = Create-RSAKey -KeyLength 2048 114 | $startDate = [System.DateTimeOffset]::Now 115 | $endDate = [System.DateTimeOffset]::Now.AddDays(10) 116 | 117 | $result = Create-SelfSignedCertificateWithSignature -Key $signedKey -RootCertificate $rootCert -CommonName "Sitecore-signed" -Country "UA" -Organization "Sitecore" -NotBefore $startDate -NotAfter $endDate 118 | } 119 | 120 | It 'Generate self signed certificate with signature and private key' { 121 | $result.HasPrivateKey | Should -Be $false 122 | } 123 | 124 | It 'Generate self signed certificate with signature and proper subject' { 125 | $result.Subject | Should -Match "CN=Sitecore-signed, C=UA, O=Sitecore" 126 | } 127 | 128 | It 'Generate self signed certificate with signature and correct start date' { 129 | $result.NotBefore.Date | Should -Match $startDate.Date 130 | } 131 | 132 | It 'Generate self signed certificate with signature and correct end date' { 133 | $result.NotAfter.Date | Should -Match $endDate.Date 134 | } 135 | } 136 | } 137 | 138 | Describe 'Create-CertificateFile' { 139 | It 'Requires $Certificate' { 140 | $result = Test-ParamIsMandatory -Command "Create-CertificateFile" -Parameter "Certificate" 141 | $result | Should -Be $true 142 | } 143 | 144 | It 'Certificate file should not be empty' { 145 | $outCertPath = "$TestDrive\localhost.crt" 146 | $key = Create-RSAKey -KeyLength 4096 147 | $certificate = Create-SelfSignedCertificate -Key $key 148 | Create-CertificateFile -Certificate $certificate -OutCertPath $outCertPath 149 | 150 | (Get-Item $outCertPath).Length | Should -Not -BeNullOrEmpty 151 | } 152 | 153 | Context "Certificate file created properly" { 154 | $outCertPath = "$TestDrive\localhost.crt" 155 | $key = Create-RSAKey -KeyLength 4096 156 | $certificate = Create-SelfSignedCertificate -Key $key 157 | Create-CertificateFile -Certificate $certificate -OutCertPath $outCertPath 158 | 159 | It 'Certificate file should not be empty' { 160 | (Get-Item $outCertPath).Length | Should -Not -BeNullOrEmpty 161 | } 162 | 163 | It 'Certificate file should contain entry message' { 164 | Get-Content $outCertPath | Should -Contain "-----BEGIN CERTIFICATE-----" 165 | } 166 | 167 | It 'Certificate file should contain end message' { 168 | Get-Content $outCertPath | Should -Contain "-----END CERTIFICATE-----" 169 | } 170 | } 171 | 172 | Context "Write host called for certificate file" { 173 | Mock Write-Host 174 | 175 | $key = Create-RSAKey -KeyLength 4096 176 | $certificate = Create-SelfSignedCertificate -Key $key 177 | Create-CertificateFile -Certificate $certificate -OutCertPath $TestDrive\localhost.crt 178 | 179 | It 'Write-Host called' { 180 | Assert-MockCalled Write-Host -Times 1 -Exactly 181 | } 182 | } 183 | } 184 | 185 | Describe 'Create-KeyFile' { 186 | It 'Requires $Key' { 187 | $result = Test-ParamIsMandatory -Command "Create-KeyFile" -Parameter "Key" 188 | $result | Should -Be $true 189 | } 190 | Context "Key file created properly" { 191 | $outKeyPath = "$TestDrive\localhost.key" 192 | $key = Create-RSAKey -KeyLength 4096 193 | Create-KeyFile -Key $key -OutKeyPath $outKeyPath 194 | 195 | It 'Key file should not be empty' { 196 | (Get-Item $outKeyPath).Length | Should -Not -BeNullOrEmpty 197 | } 198 | 199 | It 'Key file should contain entry message' { 200 | (Get-Content $outKeyPath) | Should -Contain "-----BEGIN PRIVATE KEY-----" 201 | } 202 | 203 | It 'Key file should contain end message' { 204 | (Get-Content $outKeyPath) | Should -Contain "-----END PRIVATE KEY-----" 205 | } 206 | } 207 | 208 | Context "Write host called for key file" { 209 | Mock Write-Host 210 | 211 | $key = Create-RSAKey -KeyLength 4096 212 | Create-KeyFile -Key $key -OutKeyPath $TestDrive\localhost.key 213 | 214 | It 'Write-Host called' { 215 | Assert-MockCalled Write-Host -Times 1 -Exactly 216 | } 217 | } 218 | } 219 | } -------------------------------------------------------------------------------- /powershell/src/Private/RSAKeyTool.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNPOSIS 3 | Convert a PrivateKey from the certificate store into a PKCS8 formatted file. 4 | #> 5 | 6 | class RSAKeyUtils 7 | { 8 | static [byte[]] PrivateKeyToPKCS8([System.Security.Cryptography.RSAParameters]$privateKey) 9 | { 10 | [AsnType]$n = [RSAKeyUtils]::CreateIntegerPos($privateKey.Modulus) 11 | [AsnType]$e = [RSAKeyUtils]::CreateIntegerPos($privateKey.Exponent) 12 | [AsnType]$d = [RSAKeyUtils]::CreateIntegerPos($privateKey.D) 13 | [AsnType]$p = [RSAKeyUtils]::CreateIntegerPos($privateKey.P) 14 | [AsnType]$q = [RSAKeyUtils]::CreateIntegerPos($privateKey.Q) 15 | [AsnType]$dp = [RSAKeyUtils]::CreateIntegerPos($privateKey.DP) 16 | [AsnType]$dq = [RSAKeyUtils]::CreateIntegerPos($privateKey.DQ) 17 | [AsnType]$iq = [RSAKeyUtils]::CreateIntegerPos($privateKey.InverseQ) 18 | [AsnType]$version = [RSAKeyUtils]::CreateInteger(@(0)) 19 | [AsnType]$key = [RSAKeyUtils]::CreateOctetString([RSAKeyUtils]::CreateSequence(@($version,$n,$e,$d,$p,$q,$dp,$dq,$iq))) 20 | [AsnType]$algorithmID = [RSAKeyUtils]::CreateSequence(@([RSAKeyUtils]::CreateOid("1.2.840.113549.1.1.1"),[RSAKeyUtils]::CreateNull())) 21 | [AsnType]$privateKeyInfo = [RSAKeyUtils]::CreateSequence(@($version,$algorithmID,$key)) 22 | return (New-Object -TypeName AsnMessage -ArgumentList $privateKeyInfo.GetBytes(),"PKCS#8").GetBytes() 23 | } 24 | 25 | static [AsnType] CreateOctetString([AsnType]$value) 26 | { 27 | if ([RSAKeyUtils]::IsEmpty($value)) 28 | { 29 | return (New-Object -TypeName AsnType -ArgumentList ([byte]0x04,[byte[]]@(0x00))) 30 | } 31 | return (New-Object -TypeName AsnType -ArgumentList ([byte]0x04,[byte[]]@($value.GetBytes()))) 32 | } 33 | 34 | static [bool] IsEmpty([byte[]]$octets) 35 | { 36 | if ($null -eq $octets -or 0 -eq $octets.Length) 37 | { 38 | return $true 39 | } 40 | return $false 41 | } 42 | 43 | static [bool] IsEmpty([String]$s) 44 | { 45 | if ($null -eq $s -or 0 -eq $s.Length) 46 | { 47 | return $true 48 | } 49 | return $false 50 | } 51 | 52 | static [bool] IsEmpty([String[]]$strings) 53 | { 54 | if ($null -eq $strings -or 0 -eq $strings.Length) 55 | { 56 | return $true 57 | } 58 | return $false 59 | } 60 | 61 | static [bool] IsEmpty([AsnType]$value) 62 | { 63 | if ($null -eq $value) 64 | { 65 | return $true 66 | } 67 | return $false 68 | } 69 | 70 | static [bool] IsEmpty([AsnType[]]$values) 71 | { 72 | if ($null -eq $values -or 0 -eq $values.Length) 73 | { 74 | return $true 75 | } 76 | return $false 77 | } 78 | 79 | static [bool] IsEmpty([byte[][]]$arrays) 80 | { 81 | if ($null -eq $arrays -or 0 -eq $arrays.Length) 82 | { 83 | return $true 84 | } 85 | return $false 86 | } 87 | 88 | static [AsnType] CreateInteger([byte[]]$value) 89 | { 90 | if ([RSAKeyUtils]::IsEmpty($value)) 91 | { 92 | $zero = [byte[]]::CreateInstance([byte], 1) 93 | $zero[0] = 0 94 | return [RSAKeyUtils]::CreateInteger($zero) 95 | } 96 | return (New-Object -TypeName AsnType -ArgumentList ([byte]0x02,[byte[]]$value)) 97 | } 98 | 99 | static [AsnType] CreateNull() 100 | { 101 | return (New-Object -TypeName AsnType -ArgumentList ([byte]0x05,[byte[]]@(0x00))) 102 | } 103 | 104 | static [byte[]] Duplicate([byte[]]$b) 105 | { 106 | if ([RSAKeyUtils]::IsEmpty($b)) 107 | { 108 | $empty = [byte[]]::CreateInstance([byte], 0) 109 | return $empty 110 | } 111 | [byte[]]$d = [byte[]]::CreateInstance([byte], $b.Length) 112 | [Array]::Copy($b,$d,$b.Length) 113 | return $d 114 | } 115 | 116 | static [AsnType] CreateIntegerPos([byte[]]$value) 117 | { 118 | [byte[]]$i = $null 119 | $d = [RSAKeyUtils]::Duplicate($value) 120 | if ([RSAKeyUtils]::IsEmpty($d)) 121 | { 122 | $zero = [byte[]]::CreateInstance([byte], 1) 123 | $zero[0] = 0 124 | $d = $zero 125 | } 126 | if ($d.Length -gt 0 -and $d[0] -gt 0x7F) 127 | { 128 | $i = [byte[]]::CreateInstance([byte], $d.Length + 1) 129 | $i[0] = 0x00 130 | [Array]::Copy($d,0,$i,1,$value.Length) 131 | } 132 | else 133 | { 134 | $i = $d 135 | } 136 | return [RSAKeyUtils]::CreateInteger($i) 137 | } 138 | 139 | static [byte[]] Concatenate([AsnType[]]$values) 140 | { 141 | if ([RSAKeyUtils]::IsEmpty($values)) 142 | { 143 | return [byte[]]::CreateInstance([byte], 0) 144 | } 145 | [int]$length = 0 146 | foreach ($t in $values) 147 | { 148 | if ($null -ne $t) 149 | { 150 | $length += $t.GetBytes().Length 151 | } 152 | } 153 | [byte[]]$cated = [byte[]]::CreateInstance([byte], $length) 154 | [int]$current = 0 155 | foreach ($t in $values) 156 | { 157 | if ($null -ne $t) 158 | { 159 | [byte[]]$b = $t.GetBytes() 160 | [Array]::Copy($b,0,$cated,$current,$b.Length) 161 | $current += $b.Length 162 | } 163 | } 164 | return $cated 165 | } 166 | 167 | static [AsnType] CreateSequence([AsnType[]]$values) 168 | { 169 | if ([RSAKeyUtils]::IsEmpty($values)) 170 | { 171 | throw (New-Object -TypeName ArgumentException -ArgumentList "A sequence requires at least one value.") 172 | } 173 | [byte[]]$octets = [byte[]][RSAKeyUtils]::Concatenate($values) 174 | return (New-Object -TypeName AsnType -ArgumentList ([byte](0x10 -bor 0x20)), ([byte[]]@($octets))) 175 | } 176 | 177 | static [AsnType] CreateOid([String]$value) 178 | { 179 | if ([RSAKeyUtils]::IsEmpty($value)) 180 | { 181 | return $null 182 | } 183 | [String[]]$tokens = $value.Split(@(' ','.')) 184 | if ([RSAKeyUtils]::IsEmpty($tokens)) 185 | { 186 | return $null 187 | } 188 | [UInt64]$a = 0 189 | [System.Collections.Generic.List[UInt64]]$arcs = (New-Object -TypeName System.Collections.Generic.List[UInt64]) 190 | foreach ($t in $tokens) 191 | { 192 | if ($t.Length -eq 0) 193 | { 194 | break 195 | } 196 | try 197 | { 198 | $a = [Convert]::ToUInt64($t,[CultureInfo]::InvariantCulture) 199 | } 200 | catch [FormatException] 201 | { 202 | break 203 | } 204 | catch [OverflowException] 205 | { 206 | break 207 | } 208 | $arcs.Add($a) > $null 209 | } 210 | if (0 -eq $arcs.Count) 211 | { 212 | return $null 213 | } 214 | [System.Collections.Generic.List[byte]]$octets = (New-Object -TypeName System.Collections.Generic.List[byte]) 215 | if ($arcs.Count -ge 1) 216 | { 217 | $a = $arcs[0] * 40 218 | } 219 | if ($arcs.Count -ge 2) 220 | { 221 | $a += $arcs[1] 222 | } 223 | $octets.Add([byte]($a)) 224 | for([int]$i = 2; $i -lt $arcs.Count; $i++) 225 | { 226 | [System.Collections.Generic.List[byte]]$temp = (New-Object -TypeName System.Collections.Generic.List[byte]) 227 | [UInt64]$arc = $arcs[$i] 228 | do { 229 | $temp.Add(([byte]0x80 -bor ($arc -band 0x7F))) > $null 230 | $arc = $arc -shr 7 231 | } while (0 -ne $arc) 232 | [byte[]]$t = $temp.ToArray() 233 | $t[0] = [byte](0x7F -band $t[0]) 234 | [Array]::Reverse($t) 235 | foreach ($b in $t) 236 | { 237 | $octets.Add($b) > $null 238 | } 239 | } 240 | return [RSAKeyUtils]::CreateOid($octets.ToArray()) 241 | } 242 | 243 | static [AsnType] CreateOid([byte[]]$value) 244 | { 245 | if ([RSAKeyUtils]::IsEmpty($value)) 246 | { 247 | return $null 248 | } 249 | return (New-Object -TypeName AsnType -ArgumentList 0x06,$value) 250 | } 251 | } 252 | 253 | class AsnMessage 254 | { 255 | [byte[]] hidden $Octets 256 | [String] hidden $m_format 257 | [int] $Length 258 | 259 | AsnMessage ([byte[]]$octets,[String]$format) 260 | { 261 | $this.Octets = $octets 262 | $this.m_format = $format 263 | } 264 | 265 | [byte[]] GetBytes() 266 | { 267 | if ($null -eq $this.Octets) 268 | { 269 | return [byte[]]::CreateInstance([byte], 0) 270 | } 271 | return $this.Octets 272 | } 273 | 274 | [String] GetFormat() 275 | { 276 | return $this.m_format 277 | } 278 | } 279 | 280 | class AsnType 281 | { 282 | AsnType ([byte]$tag,[byte[]]$octets) 283 | { 284 | $this.Tag = @($tag) 285 | $this.Octets = $octets 286 | $this.Length = [byte[]]::CreateInstance([byte], 0) 287 | } 288 | 289 | [byte[]] $Tag 290 | [byte[]] $Length 291 | [byte[]] $Octets 292 | 293 | [byte[]] GetBytes() 294 | { 295 | $this.SetLength() 296 | if (0x05 -eq $this.Tag[0]) 297 | { 298 | return $this.Concatenate([byte[][]]@($this.Tag,$this.Octets)) 299 | } 300 | $val = [byte[][]]@($this.Tag,$this.Length,$this.Octets) 301 | return $this.Concatenate($val) 302 | } 303 | 304 | [void] SetLength() 305 | { 306 | if ($null -eq $this.Octets) 307 | { 308 | $zero = [byte[]]::CreateInstance([byte], 1) 309 | $zero[0] = 0 310 | $this.Length = $zero 311 | return 312 | } 313 | if (0x05 -eq $this.Tag[0]) 314 | { 315 | $empty = [byte[]]::CreateInstance([byte], 0) 316 | $this.Length = $empty 317 | return 318 | } 319 | [byte[]]$len = $null 320 | if ($this.Octets.Length -lt 0x80) 321 | { 322 | $len= [byte[]]::CreateInstance([byte], 1) 323 | $len[0] = [byte]$this.Octets.Length 324 | } 325 | elseif ($this.Octets.Length -le 0xFF) 326 | { 327 | $len = [byte[]]::CreateInstance([byte], 2) 328 | $len[0] = 0x81 329 | $len[1] = [byte](($this.Octets.Length -band 0xFF)) 330 | } 331 | elseif ($this.Octets.Length -le 0xFFFF) 332 | { 333 | $len = [byte[]]::CreateInstance([byte], 3) 334 | $len[0] = 0x82 335 | $len[1] = [byte](($this.Octets.Length -band 0xFF00) -shr 8) 336 | $len[2] = [byte](($this.Octets.Length -band 0xFF)) 337 | } 338 | elseif ($this.Octets.Length -le 0xFFFFFF) 339 | { 340 | $len = [byte[]]::CreateInstance([byte], 4) 341 | $len[0] = 0x83 342 | $len[1] = [byte](($this.Octets.Length -band 0xFF0000) -shr 16) 343 | $len[2] = [byte](($this.Octets.Length -band 0xFF00) -shr 8) 344 | $len[3] = [byte](($this.Octets.Length -band 0xFF)) 345 | } 346 | else 347 | { 348 | $len = [byte[]]::CreateInstance([byte], 5) 349 | $len[0] = 0x84 350 | $len[1] = [byte](($this.Octets.Length -band 0xFF000000) -shr 24) 351 | $len[2] = [byte](($this.Octets.Length -band 0xFF0000) -shr 16) 352 | $len[3] = [byte](($this.Octets.Length -band 0xFF00) -shr 8) 353 | $len[4] = [byte](($this.Octets.Length -band 0xFF)) 354 | } 355 | $this.Length = $len 356 | } 357 | 358 | [byte[]] Concatenate([byte[][]]$values) 359 | { 360 | if ([RSAKeyUtils]::IsEmpty($values)) 361 | { 362 | return [byte[]] 363 | } 364 | [int]$len = 0 365 | foreach ($b in $values) 366 | { 367 | if ($null -ne $b) 368 | { 369 | $len += $b.Length 370 | } 371 | } 372 | [byte[]]$cated = [byte[]]::CreateInstance([byte], $len) 373 | [int]$current = 0 374 | foreach ($b in $values) 375 | { 376 | if ($null -ne $b) 377 | { 378 | [Array]::Copy($b,0,$cated,$current,$b.Length) 379 | $current += $b.Length 380 | } 381 | } 382 | return $cated 383 | } 384 | } -------------------------------------------------------------------------------- /powershell/test/Private/RSAKeyTool.Tests.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot\..\TestRunner.ps1 { 2 | . $PSScriptRoot\..\TestUtils.ps1 3 | 4 | Describe 'Private key to PKCS8' { 5 | It 'Throws empty parameters' { 6 | { PrivateKeyToPKCS8($null) } | Should -Throw 7 | } 8 | 9 | It 'Returns non empty byte array' { 10 | $key = Create-RSAKey -KeyLength 4096 11 | $parameters = $key.ExportParameters($true) 12 | $rawKey = [RSAKeyUtils]::PrivateKeyToPKCS8($parameters) 13 | 14 | $rawKey.Length | Should -BeGreaterThan 0 15 | } 16 | } 17 | 18 | Describe 'CreateOctetString' { 19 | It 'Non empty asn value returns sequence' { 20 | $param = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 21 | $result = [RSAKeyUtils]::CreateOctetString($param) 22 | 23 | $result.GetBytes() | Should -Be @(4, 4, 1, 2, 4, 5) 24 | } 25 | 26 | It 'Non empty asn value returns proper length' { 27 | $param = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 28 | $result = [RSAKeyUtils]::CreateOctetString($param) 29 | 30 | $result.GetBytes().Length | Should -Be 6 31 | } 32 | } 33 | 34 | Describe 'IsEmpty byte' { 35 | It 'Returns true if null byte array' { 36 | { [RSAKeyUtils]::IsEmpty([byte[]]($null)) } | Should -BeTrue 37 | } 38 | 39 | It 'Returns true if empty byte array' { 40 | { [RSAKeyUtils]::IsEmpty([byte[]](@())) } | Should -BeTrue 41 | } 42 | 43 | It 'Returns false if non empty byte array' { 44 | [RSAKeyUtils]::IsEmpty([byte[]](@(0x04, 0x05))) | Should -BeFalse 45 | } 46 | } 47 | 48 | Describe 'IsEmpty string array' { 49 | It 'Returns true if null string array' { 50 | { [RSAKeyUtils]::IsEmpty([string[]]($null)) } | Should -BeTrue 51 | } 52 | 53 | It 'Returns true if empty string array' { 54 | { [RSAKeyUtils]::IsEmpty([string[]](@())) } | Should -BeTrue 55 | } 56 | 57 | It 'Returns false if non empty string array' { 58 | [RSAKeyUtils]::IsEmpty([string](@("hello", "test"))) | Should -BeFalse 59 | } 60 | } 61 | 62 | Describe 'IsEmpty asn type' { 63 | It 'Returns true if null asn type' { 64 | { [RSAKeyUtils]::IsEmpty([asnType]($null)) } | Should -BeTrue 65 | } 66 | 67 | It 'Returns false if non empty asn type' { 68 | $param = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 69 | [RSAKeyUtils]::IsEmpty($param) | Should -BeFalse 70 | } 71 | } 72 | 73 | Describe 'IsEmpty asn type array' { 74 | It 'Returns true if null asn type array' { 75 | { [RSAKeyUtils]::IsEmpty([AsnType[]]($null)) } | Should -BeTrue 76 | } 77 | 78 | It 'Returns true if empty asn type array' { 79 | { [RSAKeyUtils]::IsEmpty([AsnType[]](@())) } | Should -BeTrue 80 | } 81 | 82 | It 'Returns false if non empty asn type array' { 83 | $param1 = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 84 | $param2 = [AsnType]::new([byte]0x012, [byte[]](@(0x06, 0x07))) 85 | [RSAKeyUtils]::IsEmpty([AsnType[]](@($param1, $param2))) | Should -BeFalse 86 | } 87 | } 88 | 89 | Describe 'CreateInteger' { 90 | It 'Normal parameters returns non empty value' { 91 | [RSAKeyUtils]::CreateInteger([byte[]](@(0x04, 0x05))) | Should -Not -BeNullOrEmpty 92 | } 93 | 94 | It 'Pass null returns non empty value' { 95 | [RSAKeyUtils]::CreateInteger([byte[]]@()) | Should -Not -BeNullOrEmpty 96 | } 97 | 98 | It 'First value is 2' { 99 | $result = [RSAKeyUtils]::CreateInteger([byte[]]@()) 100 | 101 | $result.GetBytes()[0] | Should -Be 2 102 | } 103 | } 104 | 105 | Describe 'CreateNull' { 106 | It 'First byte is 5' { 107 | $result = [RSAKeyUtils]::CreateNull() 108 | 109 | $result.GetBytes()[0] | Should -Be 5 110 | } 111 | 112 | It 'Second byte is 0' { 113 | $result = [RSAKeyUtils]::CreateNull() 114 | 115 | $result.GetBytes()[1] | Should -Be 0 116 | } 117 | } 118 | 119 | Describe 'Duplicate' { 120 | It 'Duplicates array' { 121 | [byte[]]$array = @(0x04, 0x05) 122 | $result = [RSAKeyUtils]::Duplicate($array) 123 | 124 | $result | Should -Be $array 125 | } 126 | 127 | It 'Empty array should not throw' { 128 | { [RSAKeyUtils]::Duplicate(@()) } | Should -Not -Throw 129 | } 130 | 131 | It 'Duplicates empty array' { 132 | $result = [RSAKeyUtils]::Duplicate(@()) 133 | 134 | $result | Should -BeNullOrEmpty 135 | } 136 | } 137 | 138 | Describe 'CreateIntegerPos' { 139 | It 'Empty array should not throw' { 140 | { [RSAKeyUtils]::CreateIntegerPos([byte[]]@()) } | Should -Not -Throw 141 | } 142 | 143 | It 'Empty array returns sequence' { 144 | $result = [RSAKeyUtils]::CreateIntegerPos([byte[]]@()) 145 | 146 | $result.GetBytes() | Should -Be @(2, 1, 0) 147 | } 148 | 149 | It 'Empty array returns non empty' { 150 | $result = [RSAKeyUtils]::CreateIntegerPos([byte[]]@()) 151 | 152 | $result.GetBytes().Length | Should -Be 3 153 | } 154 | 155 | It 'Non empty array returns sequence' { 156 | [byte[]]$array = @(0x04, 0x05) 157 | 158 | $result = [RSAKeyUtils]::CreateIntegerPos($array) 159 | 160 | $result.GetBytes() | Should -Be @(2, 2, 4, 5) 161 | } 162 | 163 | It 'Non empty array returns fixed size' { 164 | [byte[]]$array = @(0x04, 0x05) 165 | 166 | $result = [RSAKeyUtils]::CreateIntegerPos($array) 167 | 168 | $result.GetBytes().Length | Should -Be 4 169 | } 170 | } 171 | 172 | Describe 'Concatenate' { 173 | It 'Empty asn type array should not throw' { 174 | { [RSAKeyUtils]::Concatenate([AsnType[]]@()) } | Should -Not -Throw 175 | } 176 | 177 | It 'Empty asn type array returns null' { 178 | $result = [RSAKeyUtils]::Concatenate([AsnType[]]@()) 179 | 180 | $result| Should -Be $null 181 | } 182 | 183 | It 'Empty asn type array returns empty' { 184 | $result = [RSAKeyUtils]::Concatenate([AsnType[]]@()) 185 | 186 | $result.Length | Should -Be 0 187 | } 188 | 189 | It 'Non empty asn type array returns concatenated' { 190 | $param1 = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 191 | $param2 = [AsnType]::new([byte]0x012, [byte[]](@(0x06, 0x07))) 192 | 193 | $result = [RSAKeyUtils]::Concatenate([AsnType[]]@($param1, $param2)) 194 | 195 | $result.Length | Should -Be 8 196 | } 197 | 198 | It 'Non empty asn type array returns sequence' { 199 | $param1 = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 200 | $param2 = [AsnType]::new([byte]0x03, [byte[]](@(0x06, 0x07))) 201 | 202 | $result = [RSAKeyUtils]::Concatenate([AsnType[]]@($param1, $param2)) 203 | 204 | $result | Should -Be @(1, 2, 4, 5, 3, 2, 6, 7) 205 | } 206 | } 207 | 208 | Describe 'CreateSequence' { 209 | It 'Empty asn type array should throw' { 210 | { [RSAKeyUtils]::CreateSequence([AsnType[]]@()) } | Should -Throw 211 | } 212 | 213 | It 'Non empty asn type array returns fixed size' { 214 | $param1 = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 215 | $param2 = [AsnType]::new([byte]0x012, [byte[]](@(0x06, 0x07))) 216 | 217 | $result = [RSAKeyUtils]::CreateSequence([AsnType[]]@($param1, $param2)) 218 | 219 | $result.GetBytes().Length | Should -Be 10 220 | } 221 | 222 | It 'Non empty asn type array returns sequence' { 223 | $param1 = [AsnType]::new([byte]0x01, [byte[]](@(0x04, 0x05))) 224 | $param2 = [AsnType]::new([byte]0x03, [byte[]](@(0x06, 0x07))) 225 | 226 | $result = [RSAKeyUtils]::CreateSequence([AsnType[]]@($param1, $param2)) 227 | 228 | $result.GetBytes() | Should -Be @(48, 8, 1, 2, 4, 5, 3, 2, 6, 7) 229 | } 230 | } 231 | 232 | Describe 'CreateOid' { 233 | It 'Returns null if empty string' { 234 | $result = [RSAKeyUtils]::CreateOid("") 235 | $result | Should -BeNullOrEmpty 236 | } 237 | 238 | It 'Returns null if only value' { 239 | $result = [RSAKeyUtils]::CreateOid("hello") 240 | $result | Should -BeNullOrEmpty 241 | } 242 | 243 | It 'Returns calculated sequence for specific string' { 244 | $result = [RSAKeyUtils]::CreateOid("1 2 3") 245 | $result.GetBytes() | Should -Be @(6, 2, 42, 3) 246 | } 247 | 248 | It 'Returns proper length for specific string' { 249 | $result = [RSAKeyUtils]::CreateOid("1 2 3") 250 | $result.GetBytes().Length | Should -Be 4 251 | } 252 | } 253 | 254 | Describe 'CreateOid byte array' { 255 | It 'Empty byte array should not throw' { 256 | { [RSAKeyUtils]::CreateOid([byte[]]@()) } | Should -Not -Throw 257 | } 258 | 259 | It 'Returns null if empty byte array' { 260 | $result = [RSAKeyUtils]::CreateOid([byte[]]@()) 261 | $result | Should -BeNullOrEmpty 262 | } 263 | 264 | It 'Byte array returns sequence' { 265 | [byte[]]$array = @(0x04, 0x05) 266 | $result = [RSAKeyUtils]::CreateOid($array) 267 | $result.GetBytes() | Should -Be @(6, 2, 4, 5) 268 | } 269 | 270 | It 'Byte array returns proper length' { 271 | [byte[]]$array = @(0x04, 0x05) 272 | $result = [RSAKeyUtils]::CreateOid($array) 273 | 274 | $result.GetBytes().Length | Should -Be 4 275 | } 276 | } 277 | 278 | Describe 'GetBytes' { 279 | It 'Null octets' { 280 | $asn = [AsnMessage]::new($null, "hello") 281 | 282 | $asn.GetBytes() | Should -Be $null 283 | } 284 | 285 | It 'Not empty octets' { 286 | [byte[]]$array = @(0x04, 0x05) 287 | $asn = [AsnMessage]::new($array, "hello") 288 | 289 | $asn.GetBytes() | Should -Be $array 290 | } 291 | } 292 | 293 | Describe 'GetFormat' { 294 | It 'Empty string' { 295 | [byte[]]$array = @(0x04, 0x05) 296 | $asn = [AsnMessage]::new($array, "") 297 | 298 | $asn.GetFormat() | Should -Be "" 299 | } 300 | 301 | It 'Not empty string' { 302 | [byte[]]$array = @(0x04, 0x05) 303 | $asn = [AsnMessage]::new($array, "hello") 304 | 305 | $asn.GetFormat() | Should -Be "hello" 306 | } 307 | } 308 | } --------------------------------------------------------------------------------