├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── scripts │ ├── Install-PSSA.ps1 │ ├── Run-Formatter.ps1 │ ├── Run-PSSA.ps1 │ ├── pssa-settings.psd1 │ └── update-newtonsoft-libs.ps1 └── workflows │ ├── pester-report.yml │ ├── pester.yml │ ├── release.yml │ ├── script-analyzer.yml │ └── script-formatter.yml ├── .gitignore ├── .vscode ├── Run-Formatter.ps1 ├── launch.json ├── powershell.code-snippets ├── pssa-formatter.ps1 └── settings.json ├── AlertSender.ps1 ├── Bootstrap.ps1 ├── Installer.ps1 ├── LICENSE ├── README.md ├── THIRD_PARTY_LICENSES ├── Updater.ps1 ├── asset ├── agent-types.png ├── discord-embed-preview.png ├── discord.png ├── installer.png ├── slack.png ├── teams.png └── thumb01.png ├── config ├── conf.example.json └── schema.json ├── log └── .gitkeep ├── renovate.json ├── resources ├── DeployVeeamConfiguration.ps1 ├── Format-Bytes.psm1 ├── JsonValidator.psm1 ├── Logger.psm1 ├── New-OrderedDictionary.psm1 ├── NotificationBuilder.psm1 ├── NotificationSender.psm1 ├── Test-FileLock.psm1 ├── UpdateInfo.psm1 ├── VBRSessionInfo.psm1 ├── lib │ ├── Newtonsoft.Json.Schema.dll │ └── Newtonsoft.Json.dll └── version.txt └── tests ├── Installer.Tests.ps1 ├── JsonValidator.Tests.ps1 └── PesterPreference.ps1 /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | id: what-happened 12 | attributes: 13 | label: What happened? 14 | description: Also tell us, what did you expect to happen? 15 | placeholder: Tell us what you see! 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Steps To Reproduce 21 | description: Steps to reproduce the behavior. 22 | placeholder: | 23 | 1. In this environment... 24 | 2. With this config... 25 | 3. Run '...' 26 | 4. See error... 27 | - type: input 28 | id: project_version 29 | attributes: 30 | label: VeeamNotify version 31 | description: This can be found in the footer of any notification or in `C:\VeeamScripts\VeeamNotify\resources\version.txt`. 32 | placeholder: e.g. v1.1.1 33 | validations: 34 | required: true 35 | - type: dropdown 36 | id: pwsh_version 37 | attributes: 38 | label: PowerShell version 39 | description: This can be found by running `$PSVersionTable` in PowerShell. If you don't see your version here, it's not supported. 40 | options: 41 | - '5.1' 42 | - '6.x' 43 | - '7.x' 44 | validations: 45 | required: true 46 | - type: input 47 | id: veeam_version 48 | attributes: 49 | label: Veeam Backup & Replication build number 50 | description: This can be found Veeam B&R console (Menu > Help > About > Build). 51 | placeholder: e.g. 11.0.1.1261 52 | - type: textarea 53 | id: logs 54 | attributes: 55 | label: Relevant log output 56 | description: Please copy and paste any relevant log output, AFTER removing any sensitive information such as webhooks or user information. Logging can be enabled in `C:\VeeamScripts\VeeamNotify\config\conf.json` and logs can be found in `C:\VeeamScripts\VeeamNotify\logs\`. 57 | render: PowerShell 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[FEAT] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to help us out! 10 | - type: textarea 11 | id: is-problem 12 | attributes: 13 | label: Is your feature request related to a problem? 14 | description: If so, please describe it 15 | placeholder: Tell us what bugs you! 16 | - type: textarea 17 | id: suggestion 18 | attributes: 19 | label: Describe your ideal solution 20 | description: How would you like to see this feature implemented? 21 | value: | 22 | ## Definition of done 23 | 24 | 25 | 26 | 27 | ## Stretch 28 | 29 | 30 | 31 | ## Further comments 32 | 33 | 34 | validations: 35 | required: true 36 | -------------------------------------------------------------------------------- /.github/scripts/Install-PSSA.ps1: -------------------------------------------------------------------------------- 1 | # Install PSSA module 2 | Set-PSRepository PSGallery -InstallationPolicy Trusted 3 | Install-Module PSScriptAnalyzer -ErrorAction Stop -Force -Confirm:$false 4 | -------------------------------------------------------------------------------- /.github/scripts/Run-Formatter.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | $Path 3 | ) 4 | 5 | $ErrorActionPreference = 'Stop' 6 | 7 | # Run against all files 8 | # foreach ($file in (Get-Item *.ps1)) { 9 | # try { 10 | # $script = Get-Content $file -Raw 11 | 12 | # If ($script.Length -eq 0) { 13 | # Write-Output "Skipping empty file: $($file.Name)" 14 | # continue 15 | # } 16 | 17 | # $scriptFormatted = Invoke-Formatter -ScriptDefinition $script -Settings ./.github/scripts/pssa-settings.psd1 18 | # Set-Content -Path $file -Value $scriptFormatted -NoNewline 19 | # } 20 | # catch { 21 | # Write-Output "Error formatting $($file.Name):" 22 | # $_ 23 | # } 24 | # } 25 | 26 | try { 27 | 28 | $script = Get-Content -Path $Path -Raw 29 | 30 | If ($script.Length -eq 0) { 31 | Write-Output "Skipping empty file: $($Path)" 32 | } 33 | 34 | $scriptFormatted = Invoke-Formatter -ScriptDefinition $script -Settings ./.github/scripts/pssa-settings.psd1 35 | 36 | If ($scriptFormatted -ne $script) { 37 | Set-Content -Path $Path -Value $scriptFormatted -NoNewline 38 | Write-Output "$($Path) has been formatted." 39 | } 40 | Else { 41 | Write-Output "$($Path) has not been formatted (no changes required or changes could not be made non-interactively)." 42 | } 43 | 44 | } 45 | catch { 46 | throw "Error formatting $($Path): $($_)" 47 | } 48 | -------------------------------------------------------------------------------- /.github/scripts/Run-PSSA.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [array]$Files 3 | ) 4 | 5 | # init variables 6 | $errors = $warnings = $infos = $unknowns = 0 7 | 8 | # Define function to handle GitHub Actions output 9 | function Out-Severity { 10 | param( 11 | $InputObject 12 | ) 13 | 14 | foreach ($i in $InputObject) { 15 | Switch ($i.Severity) { 16 | {$_ -eq 'Error' -or $_ -eq 'ParseError'} { 17 | Write-Output "::error file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 18 | $script:errors++ 19 | } 20 | {$_ -eq 'Warning'} { 21 | Write-Output "::error file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 22 | $script:warnings++ 23 | } 24 | {$_ -eq 'Information'} { 25 | Write-Output "::warning file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 26 | $script:infos++ 27 | } 28 | Default { 29 | Write-Output "::debug file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 30 | $script:unknowns++ 31 | } 32 | } 33 | } 34 | } 35 | 36 | $ErrorActionPreference = 'Stop' 37 | 38 | # Run PSSA 39 | foreach ($file in $Files) { 40 | try { 41 | Write-Output "$($file) is being analysed..." 42 | Invoke-ScriptAnalyzer -Path $file -Settings ./.github/scripts/pssa-settings.psd1 -OutVariable analysis | Out-Null 43 | Format-List -InputObject $analysis 44 | 45 | If ($analysis) { 46 | Write-Output 'Determining and reporting severity of analysis results...' 47 | Out-Severity -InputObject $analysis 48 | } 49 | 50 | Write-Output "$($file) was analysed; it has $($analysis.Count) issues." 51 | } 52 | catch { 53 | Write-Output "Error analysing $($file): $($_.Exception.Message)" 54 | } 55 | } 56 | 57 | Write-Output "`nNOTE: In an effort to better enforce good practices, this script is configured to report analysis results as follows:" 58 | Write-Output 'Error and warning messages as errors and informational messages as warnings.' 59 | 60 | # Report true results 61 | If ($unknowns -gt 0) { 62 | Write-Output "`nTrue result: $errors errors, $warnings warnings, $infos infos, and $unknowns unknowns in total." 63 | } 64 | Else { 65 | Write-Output `n"True result: $errors errors, $warnings warnings, and $infos infos in total." 66 | } 67 | 68 | # Exit with error if any PSSA errors 69 | If ($errors -gt 0 -or $warnings -gt 0) { 70 | exit 1 71 | } 72 | -------------------------------------------------------------------------------- /.github/scripts/pssa-settings.psd1: -------------------------------------------------------------------------------- 1 | # Settings for PSScriptAnalyzer invocation. 2 | @{ 3 | ExcludeRules = @( 4 | 'PSAvoidLongLines', 5 | 'PSUseSingularNouns', 6 | 'PSUseShouldProcessForStateChangingFunctions', 7 | 'PSUseDeclaredVarsMoreThanAssignments', # Buggy - https://github.com/PowerShell/PSScriptAnalyzer/issues/1641 8 | 'PSAvoidUsingWriteHost' 9 | ) 10 | Rules = @{ 11 | PSAvoidUsingDoubleQuotesForConstantString = @{ 12 | Enable = $true 13 | } 14 | PSAvoidUsingPositionalParameters = @{ 15 | Enable = $true 16 | } 17 | PSUseCompatibleCommands = @{ 18 | Enable = $true 19 | # PowerShell platforms we want to check compatibility with 20 | TargetProfiles = @( 21 | 'win-8_x64_10.0.14393.0_5.1.14393.2791_x64_4.0.30319.42000_framework', # PowerShell 5.1 on Windows Server 2016 22 | 'win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework', # PowerShell 5.1 on Windows Server 2019 23 | 'win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework'#, # PowerShell 5.1 on Windows 10 24 | #'win-8_x64_10.0.14393.0_6.2.4_x64_4.0.30319.42000_core', # PowerShell 6.2 on Windows Server 2016 25 | #'win-8_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core', # PowerShell 6.2 on Windows Server 2019 26 | #'win-4_x64_10.0.18362.0_6.2.4_x64_4.0.30319.42000_core', # PowerShell 6.2 on Windows 10 27 | #'win-8_x64_10.0.14393.0_7.0.0_x64_3.1.2_core', # PowerShell 7.0 on Windows Server 2016 28 | #'win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core', # PowerShell 7.0 on Server 2019 29 | #'win-4_x64_10.0.18362.0_7.0.0_x64_3.1.2_core' # PowerShell 7.0 on Windows 10 30 | ) 31 | } 32 | PSUseCompatibleSyntax = @{ 33 | Enable = $true 34 | # PowerShell versions we want to check compatibility with 35 | TargetVersions = @( 36 | '5.1'#, 37 | #'6.2', 38 | #'7.1' 39 | ) 40 | } 41 | PSPlaceCloseBrace = @{ 42 | Enable = $true 43 | NoEmptyLineBefore = $false 44 | IgnoreOneLineBlock = $true 45 | NewLineAfter = $true 46 | } 47 | PSPlaceOpenBrace = @{ 48 | Enable = $true 49 | OnSameLine = $true 50 | NewLineAfter = $true 51 | IgnoreOneLineBlock = $true 52 | } 53 | PSUseConsistentIndentation = @{ 54 | Enable = $true 55 | IndentationSize = 4 56 | PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' 57 | Kind = 'tab' 58 | } 59 | PSAvoidLongLines = @{ 60 | Enable = $true 61 | MaximumLineLength = 155 62 | } 63 | PSAlignAssignmentStatement = @{ 64 | Enable = $true 65 | CheckHashtable = $true 66 | } 67 | PSUseCorrectCasing = @{ 68 | Enable = $true 69 | } 70 | PSAvoidSemicolonsAsLineTerminators = @{ 71 | Enable = $true 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/scripts/update-newtonsoft-libs.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Updates Newtonsoft.Json and Newtonsoft.Json.Schema binaries from their GitHub releases. 4 | 5 | .DESCRIPTION 6 | This script downloads the latest release versions of Newtonsoft.Json and Newtonsoft.Json.Schema from GitHub, 7 | extracts the required DLL files (net45 versions), and places them in the destination directory. 8 | It also updates the version numbers in THIRD_PARTY_LICENSES file. 9 | 10 | .NOTES 11 | File Name: update-newtonsoft.ps1 12 | Author: GitHub Copilot 13 | #> 14 | 15 | # Parameters 16 | param( 17 | [Parameter(Mandatory = $false)] 18 | [string]$DestinationPath = "$PSScriptRoot\..\..\resources\lib", 19 | 20 | [Parameter(Mandatory = $false)] 21 | [string]$TempDirectory = (Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'NewtonsoftUpdate'), 22 | 23 | [Parameter(Mandatory = $false)] 24 | [string]$LicenseFilePath = "$PSScriptRoot\..\..\THIRD_PARTY_LICENSES" 25 | ) 26 | 27 | # Create the necessary directories if they don't exist 28 | if (-not (Test-Path -Path $DestinationPath)) { 29 | New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null 30 | Write-Host "Created destination directory: $DestinationPath" 31 | } 32 | 33 | if (Test-Path -Path $TempDirectory) { 34 | Remove-Item -Path $TempDirectory -Recurse -Force 35 | } 36 | New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null 37 | Write-Host "Created temporary directory: $TempDirectory" 38 | 39 | # GitHub API URLs for latest releases 40 | $JsonRepoUrl = 'https://api.github.com/repos/JamesNK/Newtonsoft.Json/releases/latest' 41 | $SchemaRepoUrl = 'https://api.github.com/repos/JamesNK/Newtonsoft.Json.Schema/releases/latest' 42 | 43 | # Function to download and extract a release 44 | function Get-LatestRelease { 45 | param ( 46 | [string]$ApiUrl, 47 | [string]$OutPath, 48 | [string]$ProductName 49 | ) 50 | 51 | try { 52 | Write-Host "Fetching latest $ProductName release information..." 53 | $releaseInfo = Invoke-RestMethod -Uri $ApiUrl -Method Get -Headers @{ 54 | 'Accept' = 'application/vnd.github.v3+json' 55 | } 56 | 57 | $zipAsset = $releaseInfo.assets | Where-Object { $_.name -like '*.zip' } | Select-Object -First 1 58 | 59 | if ($null -eq $zipAsset) { 60 | throw 'No ZIP asset found in the latest release' 61 | } 62 | 63 | $zipUrl = $zipAsset.browser_download_url 64 | $zipPath = Join-Path -Path $OutPath -ChildPath $zipAsset.name 65 | 66 | Write-Host "Downloading $ProductName v$($releaseInfo.tag_name) from $zipUrl" 67 | Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath 68 | 69 | $extractPath = Join-Path -Path $OutPath -ChildPath $ProductName 70 | if (Test-Path -Path $extractPath) { 71 | Remove-Item -Path $extractPath -Recurse -Force 72 | } 73 | New-Item -ItemType Directory -Path $extractPath -Force | Out-Null 74 | 75 | Write-Host "Extracting $ProductName ZIP archive..." 76 | Unblock-File -Path $zipPath 77 | Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force 78 | 79 | return @{ 80 | Version = $releaseInfo.tag_name 81 | ExtractPath = $extractPath 82 | } 83 | } 84 | catch { 85 | Write-Error "Failed to download and extract $ProductName release: $_" 86 | throw 87 | } 88 | } 89 | 90 | # Function to find and copy a DLL file 91 | function Copy-DllFile { 92 | param ( 93 | [string]$ExtractPath, 94 | [string]$DllName, 95 | [string]$Destination, 96 | [string]$Version 97 | ) 98 | 99 | try { 100 | $dllPath = Get-ChildItem -Path $ExtractPath -Recurse -Filter $DllName | 101 | Where-Object { $_.FullName -match '(/|\\)net45(/|\\)' } | 102 | Select-Object -First 1 -ExpandProperty FullName 103 | 104 | if ($dllPath) { 105 | Copy-Item -Path $dllPath -Destination $Destination -Force 106 | # Get actual assembly version from the DLL 107 | Write-Host "Successfully updated $DllName to version $Version" 108 | return @{ 109 | Success = $true 110 | Version = $Version 111 | } 112 | } 113 | else { 114 | throw "Could not find $DllName in the net45 directory" 115 | } 116 | } 117 | catch { 118 | Write-Error "Error processing ${DllName}: $_" 119 | throw 120 | } 121 | } 122 | 123 | # Function to update version numbers in THIRD_PARTY_LICENSES file 124 | function Update-LicenseFile { 125 | param ( 126 | [string]$FilePath, 127 | [string]$JsonVersion, 128 | [string]$SchemaVersion 129 | ) 130 | 131 | try { 132 | if (-not (Test-Path -Path $FilePath)) { 133 | Write-Warning "License file not found at $FilePath - skipping update" 134 | return 135 | } 136 | 137 | $content = Get-Content -Path $FilePath -Raw 138 | 139 | # Update Newtonsoft.Json version 140 | $content = $content -replace '(?<=\*\* Newtonsoft\.Json; version )[0-9.]+(?= -- https://github\.com/JamesNK/Newtonsoft\.Json)', $JsonVersion 141 | 142 | # Update Newtonsoft.Json.Schema version 143 | $content = $content -replace '(?<=\*\* Newtonsoft\.Json\.Schema; version )[0-9.]+(?= -- https://github\.com/JamesNK/Newtonsoft\.Json\.Schema)', $SchemaVersion 144 | 145 | # Write updated content back to file 146 | Set-Content -Path $FilePath -Value $content -NoNewline 147 | Write-Host "Successfully updated version numbers in $FilePath" 148 | } 149 | catch { 150 | Write-Error "Error updating license file: $_" 151 | throw 152 | } 153 | } 154 | 155 | # Get the latest releases 156 | try { 157 | $jsonRelease = Get-LatestRelease -ApiUrl $JsonRepoUrl -OutPath $TempDirectory -ProductName 'Newtonsoft.Json' 158 | $schemaRelease = Get-LatestRelease -ApiUrl $SchemaRepoUrl -OutPath $TempDirectory -ProductName 'Newtonsoft.Json.Schema' 159 | 160 | # Copy the DLL files to the destination 161 | Copy-DllFile -ExtractPath $jsonRelease.ExtractPath -DllName 'Newtonsoft.Json.dll' -Destination $DestinationPath -Version $jsonRelease.Version | Out-Null 162 | Copy-DllFile -ExtractPath $schemaRelease.ExtractPath -DllName 'Newtonsoft.Json.Schema.dll' -Destination $DestinationPath -Version $schemaRelease.Version | Out-Null 163 | 164 | # Update the license file with new version numbers 165 | Update-LicenseFile -FilePath $LicenseFilePath -JsonVersion $jsonRelease.Version -SchemaVersion $schemaRelease.Version 166 | 167 | Write-Host 'Update completed successfully!' -ForegroundColor Green 168 | } 169 | catch { 170 | Write-Error "Error updating Newtonsoft binaries: $_" 171 | exit 1 172 | } 173 | finally { 174 | # Clean up temporary files 175 | if (Test-Path -Path $TempDirectory) { 176 | Write-Host 'Cleaning up temporary files...' 177 | Remove-Item -Path $TempDirectory -Recurse -Force 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /.github/workflows/pester-report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pester Report 3 | 4 | on: 5 | workflow_run: 6 | workflows: [Pester] 7 | types: [completed] 8 | 9 | jobs: 10 | report: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: dorny/test-reporter@v2 14 | with: 15 | artifact: Pester-Results 16 | name: Pester Tests 17 | path: '*.xml' 18 | reporter: jest-junit 19 | -------------------------------------------------------------------------------- /.github/workflows/pester.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pester 3 | 4 | on: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: windows-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Detect PR or branch 17 | id: detect 18 | shell: bash 19 | run: | 20 | if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then 21 | # For PRs, extract PR number 22 | if [[ "$GITHUB_REF" =~ refs/pull/([0-9]+)/merge ]]; then 23 | PR_ID=${BASH_REMATCH[1]} 24 | echo "IS_PR=true" >> $GITHUB_OUTPUT 25 | echo "PR_ID=$PR_ID" >> $GITHUB_OUTPUT 26 | echo "Current PR ID: $PR_ID" 27 | fi 28 | else 29 | # For pushes, extract the branch name from GITHUB_REF 30 | BRANCH=${GITHUB_REF#refs/heads/} 31 | echo "IS_PR=false" >> $GITHUB_OUTPUT 32 | echo "BRANCH=$BRANCH" >> $GITHUB_OUTPUT 33 | echo "Current branch: $BRANCH" 34 | fi 35 | 36 | - name: Run Pester 37 | shell: powershell 38 | run: | 39 | Get-Module -Name 'Pester' -ListAvailable | Sort-Object -Property 'Version' -Descending | Select-Object -First 1 | Import-Module 40 | $pesterConfig = Get-Content .\tests\PesterPreference.ps1 -Raw | Invoke-Expression 41 | 42 | # Create container data based on event type 43 | $containerData = @{} 44 | 45 | if ('${{ steps.detect.outputs.IS_PR }}' -eq 'true') { 46 | # PR event - pass PR data only 47 | $containerData.IsPr = $true 48 | $containerData.PrId = ${{ steps.detect.outputs.PR_ID }} 49 | Write-Host "Running tests for PR #$($containerData.PrId)" 50 | } else { 51 | # Push event - pass branch data only 52 | $containerData.Branch = '${{ steps.detect.outputs.BRANCH }}' 53 | Write-Host "Running tests for branch: $($containerData.Branch)" 54 | } 55 | 56 | # Create and run the container 57 | $container = New-PesterContainer -Path '.\tests' -Data $containerData 58 | Invoke-Pester -CI -Output Detailed -Container $container 59 | 60 | - name: Upload test report 61 | uses: actions/upload-artifact@v4 62 | if: success() || failure() 63 | with: 64 | name: Pester-Results 65 | path: testResults.xml 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | name: Create release from tag 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Ensure version matches tag 16 | run: | 17 | version=$(cat ./resources/version.txt) 18 | if [ "$version" != "$GITHUB_REF_NAME" ]; then 19 | echo "Version in version.txt ($version) does not match tag ($GITHUB_REF_NAME)." 20 | exit 1 21 | fi 22 | 23 | - name: Ensure config passes schema validation 24 | if: success() || failure() 25 | shell: pwsh 26 | run: | 27 | Import-Module ./resources/JsonValidator.psm1 28 | $schemaPath = "./config/schema.json" 29 | $configPath = "./config/conf.example.json" 30 | 31 | Write-Host "Testing config file: $configPath" 32 | Write-Host "Against schema file: $schemaPath" 33 | 34 | $result = Test-JsonValid -JsonPath "$configPath" -SchemaPath "$schemaPath" 35 | 36 | Write-Host 'Validation result:' 37 | $result | Format-Table -AutoSize 38 | 39 | if ($result.IsValid -ne $true) { 40 | Write-Host "Config file does not pass schema validation: $($result.Message)" 41 | exit 1 42 | } 43 | 44 | - name: Create release asset 45 | run: | 46 | shopt -s extglob 47 | name="VeeamNotify-$GITHUB_REF_NAME" 48 | mkdir "$name" 49 | cp -rv !($name) $name/ 50 | zip -r "$name.zip" "./$name" -x "./$name/.*/*" "./$name/.*" "./$name/*/.gitkeep" "./$name/asset/*" "./$name/tests/*" "./$name/README.md" 51 | rm -r "$name" 52 | 53 | - name: Publish release 54 | uses: softprops/action-gh-release@v2 55 | with: 56 | generate_release_notes: true 57 | files: VeeamNotify-${{ github.ref_name }}.zip 58 | name: ${{ github.ref_name }} 59 | -------------------------------------------------------------------------------- /.github/workflows/script-analyzer.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PSScriptAnalyzer 3 | 4 | on: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Update PSScriptAnalyzer 18 | shell: pwsh 19 | run: | 20 | $module = Get-Module -Name PSScriptAnalyzer 21 | $module | Remove-Module -Force 22 | $module | Update-Module -Force 23 | Import-Module -Name PSScriptAnalyzer -Force 24 | 25 | - name: Run PSScriptAnalyzer 26 | shell: pwsh 27 | run: | 28 | $files = Get-ChildItem . -Include *.ps1,*.psm1,*.psd1 -Recurse | Where-Object {$_.Directory.Name -ne 'tests'} 29 | ./.github/scripts/Run-PSSA.ps1 -Files $files 30 | -------------------------------------------------------------------------------- /.github/workflows/script-formatter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PS Script Formatter & Analyser 3 | 4 | on: pull_request 5 | 6 | jobs: 7 | format: 8 | 9 | # Ensure the PR is not from a fork 10 | if: github.event.pull_request.head.repo.full_name == github.repository 11 | 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Get changed PowerShell files 20 | id: changed-files 21 | uses: tj-actions/changed-files@v46 22 | with: 23 | files: | 24 | **/*.ps1 25 | **/*.psm1 26 | **/*.psd1 27 | !tests/* 28 | 29 | - name: List changed files 30 | if: steps.changed-files.outputs.any_changed == 'true' 31 | run: | 32 | for file in ${{ steps.changed-files.outputs.all_changed_files }}; do 33 | echo "$file was changed" 34 | done 35 | 36 | - name: Run script formatter 37 | id: files-formatted 38 | if: steps.changed-files.outputs.any_changed == 'true' 39 | shell: pwsh 40 | run: | 41 | git checkout ${{ github.head_ref }} 42 | $changedFiles = "${{ steps.changed-files.outputs.all_changed_files }}".Split(' ') 43 | foreach ($file in $changedFiles) { ./.github/scripts/Run-Formatter.ps1 -Path $file } 44 | If ($(git diff --name-only).Length -gt 0) { 45 | Write-Output "::set-output name=formatted::true" 46 | } 47 | Else { Write-Output "::set-output name=formatted::false" } 48 | 49 | - name: Push changes 50 | if: steps.files-formatted.outputs.formatted == 'true' 51 | run: | 52 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 53 | git config user.name "github-actions" 54 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} 55 | git commit -am "[CI] Run script formatter" 56 | git push 57 | 58 | - name: Run PSScriptAnalyzer 59 | if: steps.changed-files.outputs.any_changed == 'true' 60 | shell: pwsh 61 | run: | 62 | $changedFiles = "${{ steps.changed-files.outputs.all_changed_files }}".Split(' ') 63 | ./.github/scripts/Run-PSSA.ps1 -Files $changedFiles 64 | 65 | lint-fork: 66 | 67 | # Ensure the PR is from a fork 68 | if: github.event.pull_request.head.repo.full_name != github.repository 69 | 70 | runs-on: ubuntu-latest 71 | steps: 72 | 73 | - uses: actions/checkout@v4 74 | with: 75 | fetch-depth: 0 76 | 77 | - name: Get changed PowerShell files 78 | id: changed-files 79 | uses: tj-actions/changed-files@v46 80 | with: 81 | files: | 82 | *.ps1 83 | *.psm1 84 | 85 | - name: List changed files 86 | if: steps.changed-files.outputs.any_changed == 'true' 87 | run: | 88 | for file in ${{ steps.changed-files.outputs.all_changed_files }}; do 89 | echo "$file was changed" 90 | done 91 | 92 | - name: Run PSScriptAnalyzer 93 | if: steps.changed-files.outputs.any_changed == 'true' 94 | shell: pwsh 95 | run: | 96 | $changedFiles = "${{ steps.changed-files.outputs.all_changed_files }}".Split(' ') 97 | ./.github/scripts/Run-PSSA.ps1 -Files $changedFiles 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | notes.md 2 | ExampleData 3 | /Test-* 4 | log/*.log 5 | config/conf.json 6 | update-notification.marker 7 | -------------------------------------------------------------------------------- /.vscode/Run-Formatter.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | 3 | # Get all relevant PowerShell files 4 | $psFiles = Get-ChildItem -Path ./* -Include *.ps1,*.psm1 -Recurse 5 | 6 | # Run formatter 7 | foreach ($file in $psFiles) { 8 | try { 9 | $script = Get-Content $file -Raw 10 | 11 | If ($script.Length -eq 0) { 12 | Write-Output "Skipping empty file: $($file.Name)" 13 | continue 14 | } 15 | 16 | $scriptFormatted = Invoke-Formatter -ScriptDefinition $script -Settings .\.github\scripts\pssa-settings.psd1 17 | Set-Content -Path $file -Value $scriptFormatted -NoNewline 18 | } 19 | catch { 20 | Write-Output "Error formatting $($file.Name):" 21 | $_ 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.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: Launch Current File", 9 | "type": "PowerShell", 10 | "request": "launch", 11 | "script": "${file}", 12 | "cwd": "${file}", 13 | "args": [], 14 | } 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/powershell.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Write Log Message": { 3 | "prefix": "log", 4 | "body": [ 5 | "Write-LogMessage -Tag '$1' -Message '$2'", 6 | ], 7 | "description": "Write Log Message" 8 | }, 9 | "TryCatch": { 10 | "prefix": "try", 11 | "body": [ 12 | "try {", 13 | "\t$1", 14 | "} catch {", 15 | "\tWrite-LogMessage -Tag '$2' -Message '$3'", 16 | "}", 17 | ], 18 | "description": "TryCatch" 19 | }, 20 | "User Query": { 21 | "prefix": "query", 22 | "body": [ 23 | "$$1_$2 = New-Object System.Management.Automation.Host.ChoiceDescription '&$2', '$4'", 24 | "$$1_$3 = New-Object System.Management.Automation.Host.ChoiceDescription '&$3', '$5'", 25 | "$$1_opts = [System.Management.Automation.Host.ChoiceDescription[]]($$1_$2, $$1_$3)", 26 | "$$1_result = $$host.UI.PromptForChoice('$6', '$7', $$1_opts, 0)" 27 | ], 28 | "description": "User Query" 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/pssa-formatter.ps1: -------------------------------------------------------------------------------- 1 | # Install PSSA module 2 | Set-PSRepository PSGallery -InstallationPolicy Trusted 3 | Install-Module PSScriptAnalyzer -ErrorAction Stop 4 | 5 | # Get all relevant PowerShell files 6 | $psFiles = Get-ChildItem -Path ./* -Include *.ps1,*.psm1 -Recurse 7 | 8 | # Run PSSA 9 | $issues = $null 10 | foreach ($file in $psFiles.FullName) { 11 | $issues += Invoke-ScriptAnalyzer -Path $file -Recurse -Settings ./.github/scripts/pssa-settings.psd1 12 | } 13 | 14 | ## Get results, types and report to GitHub Actions 15 | $errors = 0 16 | $warnings = 0 17 | $infos = 0 18 | $unknowns = 0 19 | 20 | foreach ($issue in $issues) { 21 | switch ($issue.Severity) { 22 | {$_ -eq 'Error' -or $_ -eq 'ParseError'} { 23 | Write-Output "::error file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 24 | $errors++ 25 | } 26 | {$_ -eq 'Warning'} { 27 | Write-Output "::warning file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 28 | $warnings++ 29 | } 30 | {$_ -eq 'Information'} { 31 | Write-Output "::warning file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 32 | $infos++ 33 | } 34 | Default { 35 | Write-Output "::debug file=$($i.ScriptName),line=$($i.Line),col=$($i.Column)::$($i.RuleName) - $($i.Message)" 36 | $unknowns++ 37 | } 38 | } 39 | } 40 | 41 | ## Report summary to GitHub Actions 42 | If ($unknowns -gt 0) { 43 | Write-Output "There were $erorrs errors, $warnings warnings, $infos infos, and $unknowns unknowns in total." 44 | } 45 | Else { 46 | Write-Output "There were $erorrs errors, $warnings warnings, and $infos infos in total." 47 | } 48 | 49 | # Exit with error if any PSSA errors 50 | If ($errors) { 51 | exit 1 52 | } 53 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "powershell.codeFormatting.autoCorrectAliases": true, 3 | "powershell.codeFormatting.avoidSemicolonsAsLineTerminators": true, 4 | "powershell.codeFormatting.pipelineIndentationStyle": "IncreaseIndentationAfterEveryPipeline", 5 | "powershell.codeFormatting.preset": "Stroustrup", 6 | "powershell.codeFormatting.trimWhitespaceAroundPipe": true, 7 | "powershell.codeFormatting.useConstantStrings": true, 8 | "powershell.codeFormatting.useCorrectCasing": true, 9 | "powershell.codeFormatting.whitespaceBetweenParameters": true, 10 | "powershell.codeFormatting.whitespaceInsideBrace": false, 11 | "powershell.scriptAnalysis.settingsPath": ".github\\scripts\\pssa-settings.psd1", 12 | "editor.tabSize": 4, 13 | "editor.insertSpaces": false, 14 | "editor.detectIndentation": true, 15 | } 16 | -------------------------------------------------------------------------------- /AlertSender.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [String]$SessionId, 3 | [String]$JobType, 4 | $Config, 5 | $Logfile 6 | ) 7 | 8 | # Function to get a session's bottleneck from the session logs 9 | # See for more details: 10 | # https://github.com/tigattack/VeeamNotify/issues/19 11 | # https://forums.veeam.com/powershell-f26/accessing-bottleneck-info-via-veeam-backup-model-cbottleneckinfo-bottleneck-t80127.html 12 | function Get-Bottleneck { 13 | param( 14 | $Logger 15 | ) 16 | 17 | $bottleneck = ($Logger.GetLog() | 18 | Select-Object -ExpandProperty UpdatedRecords | 19 | Where-Object {$_.Title -match 'Primary bottleneck:.*'} | 20 | Select-Object -ExpandProperty Title) -replace 'Primary bottleneck:', '' 21 | 22 | if ($bottleneck.Length -eq 0) { 23 | $bottleneck = 'Undetermined' 24 | } 25 | else { 26 | $bottleneck = $bottleneck.Trim() 27 | } 28 | 29 | return $bottleneck 30 | } 31 | 32 | # Convert config from JSON 33 | $Config = $Config | ConvertFrom-Json 34 | 35 | # Import modules. 36 | Import-Module Veeam.Backup.PowerShell -DisableNameChecking 37 | Get-Item "$PSScriptRoot\resources\*.psm1" | Import-Module 38 | Add-Type -AssemblyName System.Web 39 | 40 | 41 | # Start logging if logging is enabled in config 42 | if ($Config.logging.enabled) { 43 | ## Wait until log file is closed by Bootstrap.ps1 44 | try { 45 | $count = 1 46 | do { 47 | $logExist = Test-Path -Path $Logfile 48 | $count++ 49 | Start-Sleep -Seconds 1 50 | } 51 | until ($logExist -eq $true -or $count -ge 10) 52 | do { 53 | $logLocked = $(Test-FileLock -Path "$Logfile" -ErrorAction Stop).IsLocked 54 | Start-Sleep -Seconds 1 55 | } 56 | until (-not $logLocked) 57 | } 58 | catch [System.Management.Automation.ItemNotFoundException] { 59 | Write-LogMessage -Tag 'INFO' -Message 'Log file not found. Starting logging to new file.' 60 | } 61 | 62 | ## Start logging to file 63 | Start-Logging -Path $Logfile 64 | } 65 | 66 | 67 | # Determine if an update is required 68 | $updateStatus = Get-UpdateStatus 69 | 70 | 71 | try { 72 | # Job info preparation 73 | 74 | ## Get the backup session. 75 | $session = (Get-VBRSessionInfo -SessionId $SessionId -JobType $JobType).Session 76 | 77 | ## Initiate logger variable 78 | $vbrSessionLogger = $session.Logger 79 | 80 | ## Wait for the backup session to finish. 81 | if ($false -eq $session.Info.IsCompleted) { 82 | $timeout = New-TimeSpan -Minutes 5 83 | $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() 84 | do { 85 | Write-LogMessage -Tag 'INFO' -Message 'Session not completed. Sleeping...' 86 | Start-Sleep -Seconds 10 87 | $session = (Get-VBRSessionInfo -SessionId $SessionId -JobType $JobType).Session 88 | } 89 | while ($false -eq $session.Info.IsCompleted -and $stopwatch.Elapsed -lt $timeout) 90 | $stopwatch.Stop() 91 | } 92 | 93 | ## Quit if still not stopped 94 | if ($false -eq $session.Info.IsCompleted) { 95 | Write-LogMessage -Tag 'ERROR' -Message 'Session still not completed after timeout. Aborting.' 96 | exit 1 97 | } 98 | 99 | # Add Veeam session log entry. 100 | $logId_start = $vbrSessionLogger.AddLog('[VeeamNotify] Gathering session details...') 101 | 102 | ## Gather generic session info. 103 | [String]$status = $session.Result 104 | 105 | # Decide whether to continue 106 | # Default to sending notification if unconfigured 107 | if ( 108 | ($status -eq 'Failed' -and -not $config.notifications.on_failure) -or 109 | ($status -eq 'Warning' -and -not $config.notifications.on_warning) -or 110 | ($status -eq 'Success' -and -not $config.notifications.on_success) 111 | ) { 112 | Write-LogMessage -Tag 'info' -Message "Job $($status.ToLower()); per configured options, no notification will be sent." 113 | $vbrSessionLogger.UpdateSuccess($logId_start, "[VeeamNotify] Not configured to send notifications for $($status.ToLower()) status.") | Out-Null 114 | exit 115 | } 116 | 117 | 118 | # Define session statistics for the report. 119 | 120 | ## If VM backup/replica, gather and include session info. 121 | if ($JobType -in 'Backup', 'Replica') { 122 | # Gather session data sizes and timing. 123 | [Float]$dataSize = $session.BackupStats.DataSize 124 | [Float]$transferSize = $session.BackupStats.BackupSize 125 | [Float]$speed = $session.Info.Progress.AvgSpeed 126 | [DateTime]$endTime = $session.Info.EndTime 127 | [DateTime]$startTime = $session.Info.CreationTime 128 | [string]$dedupRatio = $session.BackupStats.DedupRatio 129 | [string]$compressRatio = $session.BackupStats.CompressRatio 130 | [string]$bottleneck = Get-Bottleneck -Logger $vbrSessionLogger 131 | 132 | # Convert bytes to closest unit. 133 | $dataSizeRound = Format-Bytes -Data $dataSize 134 | $transferSizeRound = Format-Bytes -Data $transferSize 135 | $speedRound = (Format-Bytes -Data $speed) + '/s' 136 | 137 | # Set processing speed "Unknown" if 0B/s to avoid confusion. 138 | if ($speedRound -eq '0 B/s') { 139 | $speedRound = 'Unknown' 140 | } 141 | 142 | <# TODO: utilise this - shows warnings and errors per backed up machine. 143 | # Get objects in session. 144 | $sessionObjects = $session.GetTaskSessions() 145 | 146 | ## Count total 147 | $sessionObjectsCount = $sessionObjects.Count 148 | 149 | ## Count warns and fails 150 | $sessionObjectWarns = 0 151 | $sessionObjectFails = 0 152 | 153 | foreach ($object in $sessionObjects) { 154 | if ($object.Status -eq 'Warning') { 155 | $sessionObjectWarns++ 156 | } 157 | # TODO: check if 'Failed' is a valid state. 158 | if ($object.Status -eq 'Failed') { 159 | $sessionObjectFails++ 160 | } 161 | } 162 | 163 | # Add object warns/fails to fieldArray if any. 164 | if ($sessionObjectWarns -gt 0) { 165 | $fieldArray += @( 166 | [PSCustomObject]@{ 167 | name = 'Warnings' 168 | value = "$sessionObjectWarns/$sessionobjectsCount" 169 | inline = 'true' 170 | } 171 | ) 172 | } 173 | if ($sessionObjectFails -gt 0) { 174 | $fieldArray += @( 175 | [PSCustomObject]@{ 176 | name = 'Fails' 177 | value = "$sessionObjectFails/$sessionobjectsCount" 178 | inline = 'true' 179 | } 180 | ) 181 | } 182 | #> 183 | } 184 | 185 | # If agent backup, gather and include session info. 186 | if ($JobType -in 'EpAgentBackup', 'BackupToTape', 'FileToTape') { 187 | # Gather session data sizes and timings. 188 | [Float]$processedSize = $session.Info.Progress.ProcessedSize 189 | [Float]$transferSize = $session.Info.Progress.TransferedSize 190 | [Float]$speed = $session.Info.Progress.AvgSpeed 191 | [DateTime]$endTime = $session.EndTime 192 | [DateTime]$startTime = $session.CreationTime 193 | [string]$bottleneck = Get-Bottleneck -Logger $vbrSessionLogger 194 | 195 | # Convert bytes to closest unit. 196 | $processedSizeRound = Format-Bytes -Data $processedSize 197 | $transferSizeRound = Format-Bytes -Data $transferSize 198 | $speedRound = (Format-Bytes -Data $speed) + '/s' 199 | 200 | # Set processing speed "Unknown" if 0B/s to avoid confusion. 201 | if ($speedRound -eq '0 B/s') { 202 | $speedRound = 'Unknown' 203 | } 204 | } 205 | 206 | # Update Veeam session log. 207 | $vbrSessionLogger.UpdateSuccess($logId_start, '[VeeamNotify] Successfully discovered session details.') | Out-Null 208 | $logId_notification = $vbrSessionLogger.AddLog('[VeeamNotify] Preparing to send notification(s)...') 209 | 210 | 211 | # Job timings 212 | 213 | ## Calculate difference between job start and end time. 214 | $duration = $session.Info.Progress.Duration 215 | 216 | ## Switch for job duration; define pretty output. 217 | switch ($duration) { 218 | { $_.Days -ge '1' } { 219 | $durationFormatted = '{0}d {1}h {2}m {3}s' -f $_.Days, $_.Hours, $_.Minutes, $_.Seconds 220 | break 221 | } 222 | { $_.Hours -ge '1' } { 223 | $durationFormatted = '{0}h {1}m {2}s' -f $_.Hours, $_.Minutes, $_.Seconds 224 | break 225 | } 226 | { $_.Minutes -ge '1' } { 227 | $durationFormatted = '{0}m {1}s' -f $_.Minutes, $_.Seconds 228 | break 229 | } 230 | { $_.Seconds -ge '1' } { 231 | $durationFormatted = '{0}s' -f $_.Seconds 232 | break 233 | } 234 | default { 235 | $durationFormatted = '{0}d {1}h {2}m {3}s' -f $_.Days, $_.Hours, $_.Minutes, $_.Seconds 236 | } 237 | } 238 | 239 | # Define nice job type name 240 | switch ($JobType) { 241 | Backup { $jobTypeNice = 'VM Backup' } 242 | Replica { $jobTypeNice = 'VM Replication' } 243 | EpAgentBackup { 244 | switch ($session.Platform) { 245 | 'ELinuxPhysical' { $jobTypeNice = 'Linux Agent Backup' } 246 | 'EEndPoint' { $jobTypeNice = 'Windows Agent Backup' } 247 | } 248 | } 249 | FileToTape { $jobTypeNice = 'File Tape Backup' } 250 | BackupToTape { $jobTypeNice = 'Repo Tape Backup' } 251 | } 252 | 253 | # Decide whether to mention user 254 | $mention = $false 255 | ## On fail 256 | try { 257 | if ($Config.mentions.on_failure -and $status -eq 'Failed') { 258 | $mention = $true 259 | } 260 | } 261 | catch { 262 | Write-LogMessage -Tag 'WARN' -Message "Unable to determine 'mention on fail' configuration. User will not be mentioned." 263 | } 264 | 265 | ## On warning 266 | try { 267 | if ($Config.mentions.on_warning -and $status -eq 'Warning') { 268 | $mention = $true 269 | } 270 | } 271 | catch { 272 | Write-LogMessage -Tag 'WARN' -Message "Unable to determine 'mention on warning' configuration. User will not be mentioned." 273 | } 274 | 275 | ## On success 276 | try { 277 | if ($Config.mentions.on_success -and $status -eq 'Success') { 278 | $mention = $true 279 | } 280 | } 281 | catch { 282 | Write-LogMessage -Tag 'WARN' -Message "Unable to determine 'mention on success' configuration. User will not be mentioned." 283 | } 284 | 285 | 286 | # Define footer message. 287 | $footerMessage = "tigattack's VeeamNotify $($updateStatus.CurrentVersion)" 288 | switch ($updateStatus.Status) { 289 | Current {$footerMessage += ' - Up to date.'} 290 | Behind {$footerMessage += " - Update to $($updateStatus.LatestStable) is available!"} 291 | Ahead {$footerMessage += ' - Pre-release.'} 292 | } 293 | 294 | 295 | # Build embed parameters 296 | $payloadParams = [ordered]@{ 297 | JobName = $session.Name 298 | JobType = $jobTypeNice 299 | Status = $status 300 | Speed = $speedRound 301 | Bottleneck = $bottleneck 302 | Duration = $durationFormatted 303 | StartTime = $startTime 304 | EndTime = $endTime 305 | Mention = $mention 306 | ThumbnailUrl = $Config.thumbnail 307 | FooterMessage = $footerMessage 308 | } 309 | 310 | if ($JobType -in 'EpAgentBackup', 'BackupToTape', 'FileToTape') { 311 | $payloadParams.Insert('3', 'ProcessedSize', $processedSizeRound) 312 | $payloadParams.Insert('4', 'TransferSize', $transferSizeRound) 313 | } 314 | else { 315 | $payloadParams.Insert('3', 'DataSize', $dataSizeRound) 316 | $payloadParams.Insert('4', 'TransferSize', $transferSizeRound) 317 | $payloadParams.Insert('5', 'DedupRatio', $dedupRatio) 318 | $payloadParams.Insert('6', 'CompressRatio', $compressRatio) 319 | } 320 | 321 | # Add update message if relevant. 322 | if ($config.update | Get-Member -Name 'notify') { 323 | $config.update.notify = $true 324 | } 325 | 326 | # Check if we should send an update notification based on the 24 hour rule 327 | $updateNotifyCheck = Get-UpdateShouldNotify -UpdateStatus $updateStatus 328 | Write-LogMessage -Tag 'INFO' -Message $updateNotifyCheck.Message 329 | 330 | # Add update status 331 | $payloadParams.NotifyUpdate = $config.update.notify -and $updateNotifyCheck.ShouldNotify 332 | $payloadParams.UpdateAvailable = $updateStatus.Status -eq 'Behind' 333 | 334 | # Add latest version if update is available 335 | if ($payloadParams.UpdateAvailable) { 336 | $payloadParams.LatestVersion = $updateStatus.LatestStable 337 | } 338 | 339 | 340 | # Build embed and send iiiit. 341 | try { 342 | $Config.services.PSObject.Properties | ForEach-Object { 343 | $service = $_ 344 | 345 | # Make service name TitleCase 346 | $serviceName = (Get-Culture).TextInfo.ToTitleCase($service.Name) 347 | 348 | # Skip if service is not enabled 349 | if (-not $service.Value.enabled) { 350 | Write-LogMessage -Tag 'DEBUG' -Message "Skipping $($serviceName) notification as it is not enabled." 351 | return 352 | } 353 | 354 | # Log that we're attempting to send notification 355 | $logId_service = $vbrSessionLogger.AddLog("[VeeamNotify] Sending $($serviceName) notification...") 356 | 357 | # Call the appropriate notification sender function based on service name 358 | switch ($serviceName.ToLower()) { 359 | 'discord' { 360 | $result = Send-WebhookNotification -Service 'Discord' -Parameters $payloadParams -ServiceConfig $service.Value 361 | } 362 | 'slack' { 363 | $result = Send-WebhookNotification -Service 'Slack' -Parameters $payloadParams -ServiceConfig $service.Value 364 | } 365 | 'teams' { 366 | $result = Send-WebhookNotification -Service 'Teams' -Parameters $payloadParams -ServiceConfig $service.Value 367 | } 368 | 'telegram' { 369 | $result = Send-TelegramNotification -Parameters $payloadParams -ServiceConfig $service.Value 370 | } 371 | 'http' { 372 | $result = Send-HttpNotification -Parameters $payloadParams -ServiceConfig $service.Value 373 | } 374 | default { 375 | Write-LogMessage -Tag 'WARN' -Message "Skipping unknown service: $serviceName" 376 | } 377 | } 378 | 379 | # Update the Veeam session log based on the result 380 | if ($result.Success) { 381 | $vbrSessionLogger.UpdateSuccess($logId_service, "[VeeamNotify] Sent notification to $($serviceName).") | Out-Null 382 | Write-LogMessage -Tag 'INFO' -Message "$serviceName notification sent successfully." 383 | if ($result.Message) { 384 | Write-LogMessage -Tag 'DEBUG' -Message "$serviceName notification response: $($result.Message)" 385 | } 386 | else { 387 | Write-LogMessage -Tag 'DEBUG' -Message "No response received from $serviceName notification." 388 | } 389 | } 390 | else { 391 | $vbrSessionLogger.UpdateErr($logId_service, "[VeeamNotify] $serviceName notification could not be sent.", "Please check the log: $Logfile") | Out-Null 392 | 393 | [System.Collections.ArrayList]$errors = @() 394 | $result.Detail.GetEnumerator().ForEach({ $errors.Add("$($_.Name)=$($_.Value)") | Out-Null }) 395 | Write-LogMessage -Tag 'ERROR' -Message "$serviceName notification could not be sent: $($errors -Join '; ')" 396 | } 397 | } 398 | 399 | # Update Veeam session log. 400 | $vbrSessionLogger.UpdateSuccess($logId_notification, '[VeeamNotify] Notification(s) sent successfully.') | Out-Null 401 | } 402 | catch { 403 | Write-LogMessage -Tag 'WARN' -Message "Unable to send notification(s): ${_Exception.Message}" 404 | $_ 405 | $vbrSessionLogger.UpdateErr($logId_notification, '[VeeamNotify] An error occured while sending notification(s).', "Please check the log: $Logfile") | Out-Null 406 | } 407 | 408 | # Clean up old log files if configured 409 | if ($Config.logging.max_age_days -ne 0) { 410 | Write-LogMessage -Tag 'DEBUG' -Message 'Running log cleanup.' 411 | 412 | if ($config.logging.level -eq 'debug') { 413 | $debug = $true 414 | } 415 | else { 416 | $debug = $false 417 | } 418 | 419 | Remove-OldLogs -Path "$PSScriptRoot\log" -MaxAgeDays $Config.logging.max_age_days -Verbose:$debug 420 | } 421 | 422 | # If newer version available... 423 | if ($updateStatus.Status -eq 'Behind') { 424 | 425 | # Add Veeam session log entry. 426 | if ($Config.update.notify) { 427 | $vbrSessionLogger.AddWarning("[VeeamNotify] A new version is available: $($updateStatus.LatestStable). Currently running: $($updateStatus.CurrentVersion)") | Out-Null 428 | } 429 | 430 | # Trigger update if configured to do so. 431 | if ($Config.update.auto_update) { 432 | Write-LogMessage -Tag 'WARN' -Message 'An update is available and auto_update was enabled in config, but the feature is not yet implemented.' 433 | 434 | # # Copy update script out of working directory. 435 | # Copy-Item $PSScriptRoot\Updater.ps1 $PSScriptRoot\..\VDNotifs-Updater.ps1 436 | # Unblock-File $PSScriptRoot\..\VDNotifs-Updater.ps1 437 | 438 | # # Run update script. 439 | # $updateArgs = "-file $PSScriptRoot\..\VDNotifs-Updater.ps1", "-LatestVersion $($updateStatus.LatestStable)" 440 | # Start-Process -FilePath 'powershell' -Verb runAs -ArgumentList $updateArgs -WindowStyle hidden 441 | } 442 | } 443 | } 444 | catch { 445 | Write-LogMessage -Tag 'ERROR' -Message "A terminating error occured: ${_Exception.Message}" 446 | $_ 447 | # Add Veeam session log entry if logger is available 448 | if ($vbrSessionLogger) { 449 | $vbrSessionLogger.UpdateErr($logId_start, '[VeeamNotify] A terminating error occured.', "Please check the log: $Logfile") | Out-Null 450 | } 451 | } 452 | finally { 453 | # Stop logging. 454 | if ($Config.logging.enabled) { 455 | Stop-Logging 456 | } 457 | } 458 | -------------------------------------------------------------------------------- /Bootstrap.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | # Params allow for manually launching the script for debugging 3 | [Parameter(Mandatory = $false)] 4 | [string]$JobId = $null, 5 | [Parameter(Mandatory = $false)] 6 | [string]$SessionId = $null 7 | ) 8 | 9 | # Import modules 10 | Import-Module Veeam.Backup.PowerShell -DisableNameChecking 11 | Import-Module "$PSScriptRoot\resources\Logger.psm1" 12 | Import-Module "$PSScriptRoot\resources\JsonValidator.psm1" 13 | Import-Module "$PSScriptRoot\resources\VBRSessionInfo.psm1" 14 | 15 | $IsDebug = $false 16 | 17 | if ($JobId -and -not $SessionId) { 18 | Write-Output 'INFO: JobId is provided but not SessionId. Attempting to retrieve the last session ID for the job.' 19 | $SessionId = (Get-VBRBackupSession | Where-Object {$_.JobId -eq $JobId} | Sort-Object EndTimeUTC -Descending | Select-Object -First 1).Id.ToString() 20 | if ($null -eq $SessionId) { 21 | Write-Output 'ERROR: Failed to retrieve the session ID for the provided job ID. Please check the job ID.' 22 | exit 1 23 | } 24 | } 25 | elseif ($SessionId -and -not $JobId) { 26 | Write-Output 'INFO: SessionId is provided but not JobId. Attempting to retrieve the last job ID for the session.' 27 | $JobId = (Get-VBRBackupSession -Id $SessionId | Sort-Object EndTimeUTC -Descending | Select-Object -First 1).JobId.ToString() 28 | if ($null -eq $JobId) { 29 | Write-Output 'ERROR: Failed to retrieve the job ID for the provided session ID. Please check the session ID.' 30 | exit 1 31 | } 32 | } 33 | if ($JobId -and $SessionId) { 34 | Write-Output 'INFO: JobId and SessionId found. Using them to start the script.' 35 | $IsDebug = $true 36 | } 37 | 38 | # Set vars 39 | $configFile = "$PSScriptRoot\config\conf.json" 40 | $date = (Get-Date -UFormat %Y-%m-%d_%T).Replace(':', '.') 41 | $logFile = "$PSScriptRoot\log\$($date)_Bootstrap.log" 42 | $idRegex = '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}' 43 | $supportedTypes = 'Backup', 'EpAgentBackup', 'Replica', 'BackupToTape', 'FileToTape' 44 | 45 | # Start logging to file 46 | Start-Logging -Path $logFile 47 | 48 | # Log version 49 | Write-LogMessage -Tag 'INFO' -Message "Version: $(Get-Content "$PSScriptRoot\resources\version.txt" -Raw)" 50 | 51 | # Retrieve configuration. 52 | ## Pull config to PSCustomObject 53 | $config = Get-Content -Raw $configFile | ConvertFrom-Json # TODO: import config from param instead of later as file. Can then improve logging flow. 54 | 55 | # Stop logging and remove log file if logging is disable in config. 56 | if (-not $config.logging.enabled) { 57 | Stop-Logging 58 | Remove-Item $logFile -Force -ErrorAction SilentlyContinue 59 | } 60 | 61 | ## Pull raw config and format for later. 62 | ## This is necessary since $config as a PSCustomObject was not passed through correctly with Start-Process and $powershellArguments. 63 | $configRaw = (Get-Content -Raw $configFile).Replace('"', '\"').Replace("`n", '').Replace("`t", '').Replace(' ', ' ') 64 | 65 | ## Test config. 66 | $validationResult = Test-JsonValid -JsonPath $configFile -SchemaPath "$PSScriptRoot\config\schema.json" 67 | if ($validationResult.IsValid) { 68 | Write-LogMessage -Tag 'INFO' -Message 'Configuration validated successfully.' 69 | } 70 | else { 71 | Write-LogMessage -Tag 'ERROR' -Message "Failed to validate configuration: $($validationResult.Message)" 72 | } 73 | 74 | if ($IsDebug) { 75 | Write-LogMessage -Tag 'INFO' -Message "JobId: $JobId" 76 | Write-LogMessage -Tag 'INFO' -Message "SessionId: $SessionId" 77 | } 78 | else { 79 | Write-LogMessage -Tag 'INFO' -Message 'Attempting to retrieve job ID and session ID from the parent process.' 80 | 81 | # Get the command line used to start the Veeam session. 82 | $parentPid = (Get-CimInstance Win32_Process -Filter "processid='$PID'").parentprocessid.ToString() 83 | $parentCmd = (Get-CimInstance Win32_Process -Filter "processid='$parentPID'").CommandLine 84 | 85 | # Get the Veeam job and session IDs 86 | $JobId = ([regex]::Matches($parentCmd, $idRegex)).Value[0] 87 | $SessionId = ([regex]::Matches($parentCmd, $idRegex)).Value[1] 88 | } 89 | 90 | # Get the Veeam job details and hide warnings to mute the warning regarding deprecation of the use of some cmdlets to get certain job type details. 91 | # At time of writing, there is no alternative way to discover the job time. 92 | Write-LogMessage -Tag 'INFO' -Message 'Getting VBR job details' 93 | $job = Get-VBRJob -WarningAction SilentlyContinue | Where-Object {$_.Id.ToString() -eq $JobId} 94 | if (!$job) { 95 | # Can't locate non tape job so check if it's a tape job 96 | $job = Get-VBRTapeJob -WarningAction SilentlyContinue | Where-Object {$_.Id.ToString() -eq $JobId} 97 | $JobType = $job.Type 98 | } 99 | else { 100 | $JobType = $job.JobType 101 | } 102 | 103 | 104 | # Get the session information and name. 105 | Write-LogMessage -Tag 'INFO' -Message 'Getting VBR session information' 106 | $sessionInfo = Get-VBRSessionInfo -SessionId $SessionId -JobType $JobType 107 | $jobName = $sessionInfo.JobName 108 | $vbrSessionLogger = $sessionInfo.Session.Logger 109 | 110 | $vbrLogEntry = $vbrSessionLogger.AddLog('[VeeamNotify] Parsing job & session information...') 111 | 112 | # Quit if job type is not supported. 113 | if ($JobType -notin $supportedTypes) { 114 | Write-LogMessage -Tag 'ERROR' -Message "Job type '$($JobType)' is not supported." 115 | exit 1 116 | } 117 | 118 | Write-LogMessage -Tag 'INFO' -Message "Bootstrap script for Veeam job '$jobName' (job $JobId session $SessionId) - Session & job detection complete." 119 | 120 | # Set log file name based on job 121 | ## Replace spaces if any in the job name 122 | if ($jobName -match ' ') { 123 | $logJobName = $jobName.Replace(' ', '_') 124 | } 125 | else { 126 | $logJobName = $jobName 127 | } 128 | $newLogfile = "$PSScriptRoot\log\$($date)-$($logJobName).log" 129 | 130 | # Build argument string for the alert sender script. 131 | $powershellArguments = "-NoProfile -File $PSScriptRoot\AlertSender.ps1", ` 132 | "-SessionId `"$SessionId`"", ` 133 | "-JobType `"$JobType`"", ` 134 | "-Config `"$configRaw`"", ` 135 | "-Logfile `"$newLogfile`"" 136 | 137 | $vbrSessionLogger.UpdateSuccess($vbrLogEntry, '[VeeamNotify] Parsed job & session information.') | Out-Null 138 | 139 | # Start a new new script in a new process with some of the information gathered here. 140 | # This allows Veeam to finish the current session faster and allows us gather information from the completed job. 141 | try { 142 | $powershellExePath = (Get-Command -Name 'powershell.exe' -ErrorAction Stop).Path 143 | Write-LogMessage -Tag 'INFO' -Message 'Launching AlertSender.ps1...' 144 | $vbrLogEntry = $vbrSessionLogger.AddLog('[VeeamNotify] Launching Alert Sender...') 145 | Start-Process -FilePath "$powershellExePath" -Verb runAs -ArgumentList $powershellArguments -WindowStyle hidden -ErrorAction Stop 146 | Write-LogMessage -Tag 'INFO' -Message 'AlertSender.ps1 launched successfully.' 147 | $vbrSessionLogger.UpdateSuccess($vbrLogEntry, '[VeeamNotify] Launched Alert Sender.') | Out-Null 148 | } 149 | catch { 150 | Write-LogMessage -Tag 'ERROR' -Message "Failed to launch AlertSender.ps1: $_" 151 | $vbrSessionLogger.UpdateErr($vbrLogEntry, '[VeeamNotify] Failed to launch Alert Sender.', "Please check the log: $newLogfile") | Out-Null 152 | exit 1 153 | } 154 | 155 | # Stop logging. 156 | if ($config.logging.enabled) { 157 | Stop-Logging 158 | 159 | # Rename log file to include the job name. 160 | try { 161 | Rename-Item -Path $logFile -NewName "$(Split-Path $newLogfile -Leaf)" -ErrorAction Stop 162 | } 163 | catch { 164 | Write-Output "ERROR: Failed to rename log file: $_" | Out-File $logFile -Append 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Installer.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Installer script for VeeamNotify. 4 | .DESCRIPTION 5 | Installs VeeamNotify from one of the following: 6 | 1) Latest release; 7 | 2) Latest prerelease; 8 | 3) Specific version; 9 | 4) A named branch; 10 | 5) A pull request. 11 | This script can also optionally launch a deployment script to apply the VeeamNotify configuration to all or selected Veeam jobs. You will be prompted for this after installation. 12 | .PARAMETER Latest 13 | Choose between "Release" or "Prerelease" to install the latest release or pre-release. 14 | .PARAMETER Version 15 | Specify a version to install (e.g. 'v1.0') 16 | .PARAMETER Branch 17 | Specify a branch name to install - TESTING ONLY 18 | .PARAMETER PullRequest 19 | Specify a pull request ID to install - TESTING ONLY 20 | .PARAMETER NonInteractive 21 | Switch for noninteractive installation. No prompts to choose versions or configurations will appear when specified, and one of the above parameters must also be specified. 22 | .PARAMETER InstallParentPath 23 | Path to VeeamNotify destination directory. Default: 'C:\VeeamScripts' 24 | .INPUTS 25 | None 26 | .OUTPUTS 27 | None 28 | .EXAMPLE 29 | PS> Installer.ps1 30 | .EXAMPLE 31 | PS> Installer.ps1 -Latest release 32 | .EXAMPLE 33 | PS> Installer.ps1 -Version 'v1.0' -NonInteractive 34 | .EXAMPLE 35 | PS> Installer.ps1 -PullRequest '123' 36 | .NOTES 37 | Authors: tigattack, philenst 38 | .LINK 39 | https://github.com/tigattack/VeeamNotify/wiki 40 | #> 41 | 42 | #Requires -RunAsAdministrator 43 | 44 | [CmdletBinding(DefaultParameterSetName = 'None')] 45 | param( 46 | [Parameter(ParameterSetName = 'Version', Position = 0)] 47 | [Parameter(ParameterSetName = 'Release', Position = 0)] 48 | [Parameter(ParameterSetName = 'Branch', Position = 0)] 49 | [Parameter(ParameterSetName = 'PullRequest', Position = 0)] 50 | [String]$InstallParentPath = 'C:\VeeamScripts', 51 | 52 | [Parameter(ParameterSetName = 'Version', Position = 1, Mandatory)] 53 | # Built-in parameter validation disabled - See https://github.com/tigattack/VeeamNotify/issues/50 54 | # [ValidatePattern('^v(\d+\.)?(\d+\.)?(\*|\d+)$')] 55 | [String]$Version, 56 | 57 | [Parameter(ParameterSetName = 'Release', Position = 1, Mandatory)] 58 | # Built-in parameter validation disabled - See https://github.com/tigattack/VeeamNotify/issues/50 59 | # [ValidateSet('Release', 'Prerelease')] 60 | [String]$Latest, 61 | 62 | [Parameter(ParameterSetName = 'Branch', Position = 1, Mandatory)] 63 | [String]$Branch, 64 | 65 | [Parameter(ParameterSetName = 'PullRequest', Position = 1, Mandatory)] 66 | [String]$PullRequest, 67 | 68 | [Parameter(ParameterSetName = 'Version', Position = 2)] 69 | [Parameter(ParameterSetName = 'Release', Position = 2)] 70 | [Parameter(ParameterSetName = 'Branch', Position = 2)] 71 | [Parameter(ParameterSetName = 'PullRequest', Position = 2)] 72 | [Switch]$NonInteractive 73 | ) 74 | 75 | #region Functions 76 | 77 | function Test-InstallationPrerequisites { 78 | [CmdletBinding()] 79 | param ( 80 | [Parameter(Mandatory)] 81 | [string]$Project, 82 | 83 | [Parameter(Mandatory)] 84 | [string]$InstallPath, 85 | 86 | [Parameter()] 87 | [string]$Version, 88 | 89 | [Parameter()] 90 | [string]$Latest 91 | ) 92 | 93 | # Check if this project is already installed and if so, exit 94 | if (Test-Path "$InstallPath\$Project\resources\version.txt") { 95 | $installedVersion = (Get-Content -Raw "$InstallPath\$Project\resources\version.txt").Trim() 96 | Write-Host -ForegroundColor Yellow "`n$Project $installedVersion is already installed. This script cannot update an existing installation." 97 | Write-Host -ForegroundColor Yellow "Please manually update or delete/rename the existing installation and retry.`n`n" 98 | return $false 99 | } 100 | elseif ((Test-Path "$InstallPath\$Project") -and (Get-ChildItem "$InstallPath\$Project").Count -gt 0) { 101 | "`nThe install path ($InstallPath\$Project) already exists with children, " ` 102 | + "but an existing installation couldn't be detected (looking for $InstallPath\$Project\resources\version.txt)." | Write-Host 103 | Write-Host "Please remove the install path and retry.`n`n" 104 | return $false 105 | } 106 | 107 | # Validate Version parameter if provided 108 | if ($Version -and $Version -notmatch '^v(\d+\.)?(\d+\.)?(\*|\d+)$') { 109 | Write-Warning "Version parameter value '$Version' does not match the version naming structure." 110 | return $false 111 | } 112 | 113 | # Validate Latest parameter if provided 114 | if ($Latest -and $Latest -notin 'Release', 'Prerelease') { 115 | Write-Warning "Latest parameter value must be one of 'Release' or 'Prelease'." 116 | return $false 117 | } 118 | 119 | return $true 120 | } 121 | 122 | function Get-GitHubReleaseInfo { 123 | [CmdletBinding()] 124 | [OutputType([hashtable])] 125 | param ( 126 | [Parameter(Mandatory)] 127 | [string]$Project 128 | ) 129 | 130 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 131 | try { 132 | $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/tigattack/$Project/releases" -Method Get 133 | $branches = (Invoke-RestMethod -Uri "https://api.github.com/repos/tigattack/$Project/branches" -Method Get).name 134 | 135 | # Parse latest release and latest prerelease 136 | $latestPrerelease = $null 137 | $latestStable = $null 138 | 139 | foreach ($i in $releases) { 140 | if ($i.prerelease -and -not $latestPrerelease) { 141 | $latestPrerelease = $i.tag_name 142 | } 143 | } 144 | 145 | foreach ($i in $releases) { 146 | if (-not $i.prerelease -and -not $latestStable) { 147 | $latestStable = $i.tag_name 148 | } 149 | } 150 | 151 | return @{ 152 | Releases = $releases 153 | Branches = $branches 154 | LatestPrerelease = $latestPrerelease 155 | LatestStable = $latestStable 156 | } 157 | } 158 | catch { 159 | $versionStatusCode = $_.Exception.Response.StatusCode.value__ 160 | Write-Warning "Failed to query GitHub for $Project releases." 161 | throw "HTTP status code: $versionStatusCode" 162 | } 163 | } 164 | 165 | function Get-GitHubPRInfo { 166 | [CmdletBinding()] 167 | [OutputType([hashtable])] 168 | param ( 169 | [Parameter(Mandatory)] 170 | [string]$Project, 171 | 172 | [Parameter(Mandatory)] 173 | [string]$PullRequestId 174 | ) 175 | 176 | try { 177 | $prInfo = Invoke-RestMethod -Uri "https://api.github.com/repos/tigattack/$Project/pulls/$PullRequestId" -Method Get 178 | return $prInfo 179 | } 180 | catch { 181 | if ($_.Exception.Response.StatusCode.value__ -eq 404) { 182 | Write-Warning "Pull request $PullRequestId not found." 183 | } 184 | else { 185 | Write-Warning "Failed to query GitHub for pull request $PullRequestId." 186 | } 187 | throw 188 | } 189 | } 190 | 191 | function Get-InstallationSource { 192 | [CmdletBinding()] 193 | [OutputType([System.Collections.Hashtable])] 194 | param ( 195 | [Parameter(Mandatory)] 196 | [string]$Project, 197 | 198 | [Parameter()] 199 | [string]$Version, 200 | 201 | [Parameter()] 202 | [string]$Latest, 203 | 204 | [Parameter()] 205 | [string]$Branch, 206 | 207 | [Parameter()] 208 | [string]$PullRequest, 209 | 210 | [Parameter()] 211 | [switch]$NonInteractive, 212 | 213 | [Parameter(Mandatory)] 214 | [hashtable]$GitHubInfo 215 | ) 216 | 217 | $releases = $GitHubInfo.Releases 218 | $branches = $GitHubInfo.Branches 219 | $latestStable = $GitHubInfo.LatestStable 220 | $latestPrerelease = $GitHubInfo.LatestPrerelease 221 | 222 | # If no installation source provided and interactive mode enabled, query user 223 | if (-not $Version -and -not $Latest -and -not $Branch -and -not $PullRequest -and -not $NonInteractive) { 224 | # Query download type / release stream 225 | if ($releases) { 226 | [System.Management.Automation.Host.ChoiceDescription[]]$downloadQuery_opts = @() 227 | $downloadQuery_opts += New-Object System.Management.Automation.Host.ChoiceDescription '&Release', "Download the latest release or prerelease. You will be prompted if there's a choice between the two." 228 | $downloadQuery_opts += New-Object System.Management.Automation.Host.ChoiceDescription '&Version', 'Download a specific version.' 229 | $downloadQuery_opts += New-Object System.Management.Automation.Host.ChoiceDescription '&Branch', '[TESTING ONLY] Download a branch.' 230 | $downloadQuery_opts += New-Object System.Management.Automation.Host.ChoiceDescription '&PullRequest', '[TESTING ONLY] Download a pull request.' 231 | $downloadQuery_result = $host.UI.PromptForChoice( 232 | 'Download type', 233 | "Please select how you would like to download $Project.", 234 | $downloadQuery_opts, 235 | 0 236 | ) 237 | } 238 | else { 239 | $branchQuery_yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Install from a branch.' 240 | $branchQuery_no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Cancel installation.' 241 | $host.UI.PromptForChoice( 242 | 'Would you like to install from a branch?', 243 | "There are currently no releases or prereleases available for $Project.", 244 | @($branchQuery_yes, $branchQuery_no), 245 | 0 246 | ) | ForEach-Object { 247 | if ($_ -eq 0) { $downloadQuery_result = 2 } 248 | else { exit } 249 | } 250 | } 251 | 252 | # Set download type 253 | $releasePrompt = $false 254 | switch ($downloadQuery_result) { 255 | 0 { 256 | if ($latestStable -and $latestPrerelease) { 257 | # Query release stream 258 | $releasePrompt = $true 259 | # Query release stream 260 | $versionQuery_stable = New-Object System.Management.Automation.Host.ChoiceDescription 'Latest &stable', "Latest stable: $latestStable." 261 | $versionQuery_prerelease = New-Object System.Management.Automation.Host.ChoiceDescription 'Latest &prerelease', "Latest prelease: $latestPrerelease." 262 | $versionQuery_result = $host.UI.PromptForChoice( 263 | 'Release Selection', 264 | "Which release type would you like to install?`nEnter '?' to see versions.", 265 | @( 266 | $versionQuery_stable, 267 | $versionQuery_prerelease), 268 | 0 269 | ) 270 | 271 | switch ($versionQuery_result) { 272 | 0 { 273 | $Latest = 'Release' 274 | } 275 | 1 { 276 | $Latest = 'Prerelease' 277 | } 278 | } 279 | } 280 | elseif ($latestStable) { 281 | $Latest = 'Release' 282 | } 283 | elseif ($latestPrerelease) { 284 | $prereleaseQuery_yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Install the latest prerelease.' 285 | $prereleaseQuery_no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Cancel installation.' 286 | $host.UI.PromptForChoice( 287 | 'Do you wish to install the latest prerelease?', 288 | 'You chose release, but the only available releases are prereleases.', 289 | @($prereleaseQuery_yes, $prereleaseQuery_no), 290 | 0 291 | ) | ForEach-Object { 292 | if ($_ -eq 0) { $Latest = 'Prerelease' } 293 | else { exit } 294 | } 295 | } 296 | } 297 | 1 { 298 | do { 299 | $Version = ($host.UI.Prompt( 300 | 'Version Selection', 301 | "Please enter the version you wish to install.`nAvailable versions:`n $(foreach ($tag in $releases.tag_name) {"$tag`n"})", 302 | 'Version' 303 | )).Version 304 | if ($releases.tag_name -notcontains $Version) { Write-Host "`nInvalid version, please try again." } 305 | } until ( 306 | $releases.tag_name -contains $Version 307 | ) 308 | } 309 | 2 { 310 | do { 311 | $Branch = ($host.UI.Prompt( 312 | 'Branch Selection', 313 | "Please enter the name of the branch you wish to install.`nAvailable branches:`n $(foreach ($branch in $branches) {"$branch`n"})", 314 | 'Branch' 315 | )).Branch 316 | if ($branches -notcontains $Branch) { Write-Host "`nInvalid branch name, please try again." } 317 | } until ( 318 | $branches -contains $Branch 319 | ) 320 | } 321 | 3 { 322 | do { 323 | # Get PR source in format owner:branch 324 | $PullRequest = ($host.UI.Prompt( 325 | 'Pull Request Source', 326 | "Please enter the pull request number (e.g. '123')", 327 | 'PullRequest' 328 | )).PullRequest 329 | 330 | if ($PullRequest -notmatch '^\d+$') { 331 | Write-Host "`nPull request ID must be a number (e.g. '123'). Please try again." 332 | } 333 | } until ( 334 | $PullRequest -match '^\d+$' 335 | ) 336 | } 337 | } 338 | } 339 | 340 | # Download branch if specified 341 | if ($Branch) { 342 | # Throw if branch not found 343 | if (-not $branches.Contains($Branch)) { 344 | throw "Branch '$Branch' not found. Will not prompt for branch in non-interactive mode." 345 | } 346 | 347 | # Set $releaseName to branch name 348 | $releaseName = $Branch 349 | 350 | # Define download URL 351 | $downloadUrl = "https://api.github.com/repos/tigattack/$Project/zipball/$Branch" 352 | } 353 | # Download pull request if specified 354 | elseif ($PullRequest) { 355 | try { 356 | $prInfo = Get-GitHubPRInfo -Project $Project -PullRequestId $PullRequest 357 | $prCreator = $prInfo.user.login 358 | $prRepoName = $prInfo.head.repo.name 359 | $prSrcBranch = $prInfo.head.ref 360 | } 361 | catch { exit 1 } 362 | $downloadUrl = "https://api.github.com/repos/${prCreator}/${prRepoName}/zipball/$prSrcBranch" 363 | $releaseName = "PR #$PullRequest by $prCreator" 364 | } 365 | # Otherwise work with versions 366 | else { 367 | # Define release to use 368 | if ($Latest) { 369 | switch ($Latest) { 370 | 'Release' { 371 | $releaseName = $latestStable 372 | } 373 | 'Prerelease' { 374 | $releaseName = $latestPrerelease 375 | } 376 | } 377 | } 378 | elseif ($Version) { 379 | $releaseName = $Version 380 | } 381 | 382 | if (($Latest -or $releasePrompt) -and (-not $releaseName)) { 383 | Write-Warning 'A release of the specified type could not found.' 384 | exit 385 | } 386 | 387 | # Define download URL 388 | $releases = Invoke-RestMethod "https://api.github.com/repos/tigattack/$Project/releases" 389 | foreach ($i in $releases) { 390 | if ($i.tag_name -eq $releaseName) { 391 | $downloadUrl = $i.assets[0].browser_download_url 392 | break 393 | } 394 | } 395 | 396 | if (-not $downloadUrl) { 397 | Write-Warning "No download URL found for release '$releaseName'." 398 | exit 399 | } 400 | } 401 | 402 | # Sanitise releaseName for OutFile if installing from branch or pull request 403 | if ($Branch -or $PullRequest) { 404 | $outFile = "$Project-$($releaseName -replace '[\W]','-')" 405 | } 406 | else { 407 | $outFile = "$Project-$releaseName" 408 | } 409 | 410 | return @{ 411 | ReleaseName = $releaseName 412 | OutFile = $outFile 413 | DownloadUrl = $downloadUrl 414 | } 415 | } 416 | 417 | function Install-DownloadedProject { 418 | [CmdletBinding()] 419 | param ( 420 | [Parameter(Mandatory)] 421 | [string]$Project, 422 | 423 | [Parameter(Mandatory)] 424 | [string]$InstallParentPath, 425 | 426 | [Parameter(Mandatory)] 427 | [string]$DownloadUrl, 428 | 429 | [Parameter(Mandatory)] 430 | [string]$OutFile, 431 | 432 | [Parameter(Mandatory)] 433 | [string]$ReleaseName 434 | ) 435 | 436 | $tempDir = [System.IO.Path]::GetTempPath() 437 | $downloadPath = Join-Path -Path $tempDir -ChildPath "${OutFile}.zip" 438 | 439 | # Download parameters 440 | $DownloadParams = @{ 441 | Uri = $DownloadUrl 442 | OutFile = $downloadPath 443 | } 444 | 445 | # Download project from GitHub 446 | try { 447 | Write-Host "`nDownloading $Project $ReleaseName from GitHub..." 448 | Invoke-WebRequest @DownloadParams 449 | } 450 | catch { 451 | $downloadStatusCode = $_.Exception.Response.StatusCode.value__ 452 | Write-Warning "Failed to download $Project $ReleaseName." 453 | if ($_.Exception.Response) { 454 | throw "HTTP status code: $downloadStatusCode" 455 | } 456 | else { 457 | throw $_ 458 | } 459 | } 460 | 461 | # Unblock downloaded ZIP 462 | try { 463 | Write-Host 'Unblocking ZIP...' 464 | Unblock-File -Path $downloadPath 465 | } 466 | catch { 467 | Write-Warning 'Failed to unblock downloaded files. You will need to run the following commands manually once installation is complete:' 468 | Write-Host "Get-ChildItem -Path $InstallParentPath -Filter *.ps* -Recurse | Unblock-File" 469 | } 470 | 471 | # Extract release to destination path 472 | Write-Host "Extracting files to '$InstallParentPath'..." 473 | Expand-Archive -Path $downloadPath -DestinationPath "$InstallParentPath" -Force 474 | 475 | # Rename destination and tidy up 476 | Write-Host 'Renaming directory and removing download artefact...' 477 | 478 | $destinationPath = Join-Path -Path $InstallParentPath -ChildPath $OutFile 479 | 480 | if (Test-Path $destinationPath) { 481 | Rename-Item -Path $destinationPath -NewName "$Project" 482 | } 483 | else { 484 | # Necessary to handle branch downloads, which come as a ZIP containing a directory named similarly to "tigattack-VeeamNotify-2100906". 485 | # Look for a directory less than 5 minutes old which matches the pattern described above. 486 | (Get-ChildItem $InstallParentPath | Where-Object { 487 | $_.LastWriteTime -gt (Get-Date).AddMinutes(-5) -and 488 | $_.Name -match ".*-$Project-.*" -and 489 | $_.PsIsContainer 490 | })[0] | Rename-Item -NewName "$Project" 491 | } 492 | 493 | # Clean up temp files 494 | Remove-Item -Path $downloadPath 495 | } 496 | 497 | function Set-ProjectConfiguration { 498 | [CmdletBinding()] 499 | param ( 500 | [Parameter(Mandatory)] 501 | [string]$Project, 502 | 503 | [Parameter(Mandatory)] 504 | [string]$InstallParentPath 505 | ) 506 | 507 | # Join config path 508 | $configPath = Join-Path -Path $InstallParentPath -ChildPath $Project | Join-Path -ChildPath 'config\conf.json' 509 | 510 | # Create config from example if it doesn't exist 511 | if (-not (Test-Path $configPath)) { 512 | Write-Host "`nCreating configuration file..." 513 | $exampleConfig = Join-Path -Path $InstallParentPath -ChildPath $Project | Join-Path -ChildPath 'config\conf.example.json' 514 | Copy-Item -Path $exampleConfig -Destination $configPath 515 | } 516 | 517 | # Get config 518 | $config = Get-Content "$configPath" -Raw | ConvertFrom-Json 519 | 520 | # Configure service 521 | $config, $serviceType = Set-NotificationService -Config $config 522 | 523 | # Configure mentions 524 | if ($serviceType -ne 4) { 525 | $config = Set-MentionPreference -Config $config -ServiceType $serviceType 526 | } 527 | else { 528 | Write-Verbose 'Skipping mention preference configuration as mentions are not supported for the chosen service.' 529 | } 530 | 531 | # Write config 532 | try { 533 | ConvertTo-Json $config -Depth 10 | Set-Content "$configPath" 534 | Write-Host "`nConfiguration set successfully." 535 | Write-Host "Further options than those available in this script can be found in the config file at:`n${configPath}." 536 | } 537 | catch { 538 | Write-Warning "Failed to write configuration file at `"$configPath`". Please open the file and complete configuration manually." 539 | } 540 | 541 | # Run configuration deployment tool 542 | Invoke-DeploymentTool -InstallParentPath $InstallParentPath -Project $Project 543 | 544 | return $configPath 545 | } 546 | 547 | function Set-NotificationService { 548 | [CmdletBinding()] 549 | [OutputType([object[]])] 550 | param ( 551 | [Parameter(Mandatory)] 552 | [PSCustomObject]$Config 553 | ) 554 | 555 | # Prompt user with config options 556 | $servicePrompt_discord = New-Object System.Management.Automation.Host.ChoiceDescription '&Discord', 'Send notifications to Discord.' 557 | $servicePrompt_slack = New-Object System.Management.Automation.Host.ChoiceDescription '&Slack', 'Send notifications to Slack.' 558 | $servicePrompt_teams = New-Object System.Management.Automation.Host.ChoiceDescription '&Microsoft Teams', 'Send notifications to Microsoft Teams.' 559 | $servicePrompt_telegram = New-Object System.Management.Automation.Host.ChoiceDescription '&Telegram', 'Send notifications to Telegram.' 560 | $servicePrompt_http = New-Object System.Management.Automation.Host.ChoiceDescription '&HTTP', 'Send job data to HTTP endpoint.' 561 | $servicePrompt_result = $host.UI.PromptForChoice( 562 | 'Notification Service', 563 | 'Which service do you wish to send notifications to?', 564 | @( 565 | $servicePrompt_discord, 566 | $servicePrompt_slack, 567 | $servicePrompt_teams, 568 | $servicePrompt_telegram, 569 | $servicePrompt_http 570 | ), 571 | -1 572 | ) 573 | 574 | # Prompt for webhook URL based on selected service 575 | $webhookPrompt = "`nPlease enter your webhook URL" 576 | switch ($servicePrompt_result) { 577 | 0 { 578 | $Config.services.discord.enabled = $true 579 | $Config.services.discord.webhook = Read-Host -Prompt $webhookPrompt 580 | } 581 | 1 { 582 | $Config.services.slack.enabled = $true 583 | $Config.services.slack.webhook = Read-Host -Prompt $webhookPrompt 584 | } 585 | 2 { 586 | $Config.services.teams.enabled = $true 587 | $Config.services.teams.webhook = Read-Host -Prompt $webhookPrompt 588 | } 589 | 3 { 590 | $Config.services.telegram.enabled = $true 591 | $Config.services.telegram.bot_token = Read-Host -Prompt "`nPlease enter your Telegram bot token" 592 | $Config.services.telegram.chat_id = Read-Host -Prompt "`nPlease enter your Telegram chat ID" 593 | } 594 | 4 { 595 | $Config.services.http.enabled = $true 596 | $Config.services.http.url = Read-Host -Prompt "`nPlease enter your HTTP endpoint URL" 597 | Write-Host "`nNow select the HTTP method to use. Note:" 598 | Write-Host "- If POST is selected, the job data will be sent as a JSON payload.`n- If GET is selected, the job data will be sent as query parameters." 599 | $Config.services.http.method = Read-Host -Prompt "`nPOST or GET [POST]" 600 | $methodEmpty = [string]::IsNullOrWhitespace($Config.services.http.method) 601 | if ($methodEmpty -or $Config.services.http.method -notmatch '^(POST|GET)$') { 602 | if (-not $methodEmpty) { 603 | Write-Warning 'Invalid HTTP method specified. Defaulting to POST.' 604 | } 605 | $Config.services.http.method = 'POST' 606 | } 607 | } 608 | } 609 | 610 | return $Config, $servicePrompt_result 611 | } 612 | 613 | function Set-MentionPreference { 614 | [CmdletBinding()] 615 | [OutputType([PSCustomObject])] 616 | param ( 617 | [Parameter(Mandatory)] 618 | [PSCustomObject]$Config, 619 | 620 | [Parameter()] 621 | [int]$ServiceType 622 | ) 623 | 624 | $mentionPreference_no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Do not mention me.' 625 | $mentionPreference_warn = New-Object System.Management.Automation.Host.ChoiceDescription '&Warning', 'Mention me when a session finishes in a warning state.' 626 | $mentionPreference_fail = New-Object System.Management.Automation.Host.ChoiceDescription '&Failure', 'Mention me when a session finishes in a failed state.' 627 | $mentionPreference_warnfail = New-Object System.Management.Automation.Host.ChoiceDescription '&Both', 'Notify me when a session finishes in either a warning or a failed state.' 628 | $mentionPreference_result = $host.UI.PromptForChoice( 629 | 'Mention Preference', 630 | 'Do you wish to be mentioned/tagged when a session finishes in one of the following states?', 631 | @( 632 | $mentionPreference_no, 633 | $mentionPreference_warn, 634 | $mentionPreference_fail, 635 | $mentionPreference_warnfail 636 | ), 637 | 2 638 | ) 639 | 640 | if ($mentionPreference_result -ne 0) { 641 | switch ($ServiceType) { 642 | 0 { 643 | $Config.services.discord.user_id = Read-Host -Prompt "`nPlease enter your Discord user ID" 644 | } 645 | 1 { 646 | $Config.services.slack.user_id = Read-Host -Prompt "`nPlease enter your Slack member ID" 647 | } 648 | 2 { 649 | $Config.services.teams.user_id = Read-Host -Prompt "`nPlease enter your Teams email address" 650 | Write-Host "`nTeams also requires a name to be specified for mentions.`nIf you do not specify anything, your username (from your email address) will be used." 651 | $Config.services.teams.display_name = Read-Host -Prompt 'Please enter your Teams display name (e.g. John Smith)' 652 | } 653 | 3 { 654 | $Config.services.telegram.user_name = Read-Host -Prompt "`nPlease enter your Telegram username" 655 | } 656 | } 657 | } 658 | 659 | # Set config values 660 | switch ($mentionPreference_result) { 661 | 0 { 662 | $Config.mentions.on_failure = $false 663 | $Config.mentions.on_warning = $false 664 | } 665 | 1 { 666 | $Config.mentions.on_failure = $false 667 | $Config.mentions.on_warning = $true 668 | } 669 | 2 { 670 | $Config.mentions.on_failure = $true 671 | $Config.mentions.on_warning = $false 672 | } 673 | 3 { 674 | $Config.mentions.on_failure = $true 675 | $Config.mentions.on_warning = $true 676 | } 677 | } 678 | 679 | return $Config 680 | } 681 | 682 | function Invoke-DeploymentTool { 683 | [CmdletBinding()] 684 | param ( 685 | [Parameter(Mandatory)] 686 | [string]$InstallParentPath, 687 | 688 | [Parameter(Mandatory)] 689 | [string]$Project 690 | ) 691 | 692 | # Query for configuration deployment script. 693 | $configPrompt_yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Execute configuration deployment tool.' 694 | $configPrompt_no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Skip configuration deployment tool.' 695 | $host.UI.PromptForChoice( 696 | 'Configuration Deployment Tool', 697 | "Would you like to to run the VeeamNotify configuration deployment tool?`nNone of your job configurations will be modified without confirmation.", 698 | @( 699 | $configPrompt_yes, 700 | $configPrompt_no 701 | ), 702 | 0 703 | ) | ForEach-Object { 704 | if ($_ -eq 0) { 705 | Write-Host "`nRunning configuration deployment script...`n" 706 | & "$InstallParentPath\$Project\resources\DeployVeeamConfiguration.ps1" -InstallParentPath $InstallParentPath 707 | } 708 | } 709 | } 710 | 711 | #endregion Functions 712 | 713 | # Main execution block 714 | $project = 'VeeamNotify' 715 | $ErrorActionPreference = 'Stop' 716 | 717 | Write-Host -ForegroundColor Green @' 718 | ####################################### 719 | # # 720 | # VeeamNotify Installer # 721 | # # 722 | ####################################### 723 | '@ 724 | 725 | # Validate prerequisites 726 | $validPrereqs = Test-InstallationPrerequisites -Project $project -InstallPath $InstallParentPath -Version $Version -Latest $Latest 727 | if (-not $validPrereqs) { exit 1 } 728 | 729 | # Get GitHub release info 730 | $gitHubInfo = Get-GitHubReleaseInfo -Project $project 731 | 732 | # Determine what to download and install 733 | $downloadProperties = Get-InstallationSource -Project $project -Version $Version -Latest $Latest -Branch $Branch -PullRequest $PullRequest -NonInteractive:$NonInteractive -GitHubInfo $gitHubInfo 734 | 735 | # Download and install the project 736 | Install-DownloadedProject -Project $project ` 737 | -InstallParentPath $InstallParentPath ` 738 | -DownloadUrl $downloadProperties.DownloadUrl ` 739 | -OutFile $downloadProperties.OutFile ` 740 | -ReleaseName $downloadProperties.ReleaseName ` 741 | 742 | # Configure the installation if not running in non-interactive mode 743 | if (-not $NonInteractive) { 744 | $configPath = Set-ProjectConfiguration -Project $project -InstallParentPath $InstallParentPath 745 | } 746 | else { 747 | $configPath = Join-Path -Path $InstallParentPath -ChildPath $project | Join-Path -ChildPath 'config\conf.json' 748 | Write-Host "`nWill not prompt for VeeamNotify configuration, or to run Veeam configuration deployment script in non-interactive mode." 749 | Write-Host "`nConfiguration can be found in:`n$configPath." 750 | } 751 | 752 | Write-Host -ForegroundColor Green "`nInstallation complete!`n" 753 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VeeamNotify 2 | 3 | Send Veeam Backup & Replication session summary notifications to Discord, Microsoft Teams, and Slack, detailing session result and statistics and optionally alerting you via mention when a job finishes in a warning or failed state. 4 | 5 | VeeamNotify is a replacement for [VeeamDiscordNotifications](https://github.com/tigattack/VeeamDiscordNotifications) and the respective [Teams](https://github.com/tigattack/VeeamTeamsNotifications) and [Slack](https://github.com/tigattack/VeeamSlackNotifications) projects, featuring improvements across the board and support for all the aforementioned messaging services in one, easy to use project. 6 | 7 | 8 | 9 | ## Supported Notification Services 10 | 11 | * Discord 12 | * Slack 13 | * Microsoft Teams 14 | * Telegram 15 | 16 | Please create a [feature request](https://github.com/tigattack/VeeamNotify/issues/new?assignees=tigattack&labels=enhancement&template=feature_request.yml&title=[FEAT]+New+service:+) if your preferred service isn't listed here. 17 | 18 | ## Supported Veeam Job Types 19 | 20 | * VM Backup 21 | * VM Replication 22 | * Windows & Linux Agent Backup jobs* 23 | 24 | ### *Agent job support 25 | 26 | Due to limitations in Veeam, only some types of Agent jobs are supported. 27 | 28 | **Supported** jobs are known as "Agent Backup" or "Managed by backup server". **Unsupported** jobs are known as "Agent policy" or "Managed by agent". See the spoilered illustration below if this isn't clear to you. 29 | 30 |
31 | Illustrated screenshot showing an example of supported types 32 | 33 | 34 | **Note:** Linux Agent Backup jobs are also supported, this image is only an example. 35 |
36 | 37 | You can read about the difference between these two Agent job types [here](https://helpcenter.veeam.com/docs/backup/agents/agent_job_protection_mode.html?ver=110#selecting-job-mode). 38 | 39 | Unfortunately, even supported Agent jobs return a reduced amount of session information, so you won't see as much detail in the notification (see the example screenshots at the top). 40 | As much relevant information as I've been able to discover from such backup sessions is included in the notifications, but I welcome any suggestions for improvement in this area. 41 | 42 | ## Installation 43 | 44 | Requirements: 45 | * Veeam Backup & Replication 12 or higher. 46 | * PowerShell 5.1 or higher. 47 | 48 | Please see the [How to Install](https://github.com/tigattack/VeeamNotify/wiki/%F0%9F%94%A7-How-to-Install) wiki page. 49 | 50 | ## Configuration 51 | 52 | Default configuration can be found in `C:\VeeamScripts\VeeamNotify\config\conf.example.json` 53 | 54 | Details and examples can be found in the [Configuration Options](https://github.com/tigattack/VeeamNotify/wiki/%E2%9A%99%EF%B8%8F-Configuration-Options) wiki page. 55 | 56 | --- 57 | 58 | ## Credits 59 | 60 | [MelonSmasher](https://github.com/MelonSmasher)//[TheSageColleges](https://github.com/TheSageColleges) for [the project](https://github.com/TheSageColleges/VeeamSlackNotifications) which inspired me to make this. 61 | [dantho281](https://github.com/dantho281) for various things - Assistance with silly little issues, the odd bugfix here and there, and the inspiration for and first works on the `Updater.ps1` script. 62 | [Lee_Dailey](https://reddit.com/u/Lee_Dailey) for general pointers and the [first revision](https://pastebin.com/srN5CKty) of the `Format-ByteUnit` function. 63 | [philenst](https://github.com/philenst) for committing or assisting with a number of core components of the project. 64 | [s0yun](https://github.com/s0yun) for the `Installer.ps1` script. 65 | 66 | Thank you all. 67 | -------------------------------------------------------------------------------- /THIRD_PARTY_LICENSES: -------------------------------------------------------------------------------- 1 | ** Newtonsoft.Json; version 13.0.3 -- https://github.com/JamesNK/Newtonsoft.Json 2 | ** Newtonsoft.Json.Schema; version 4.0.1 -- https://github.com/JamesNK/Newtonsoft.Json.Schema 3 | 4 | The MIT License (MIT) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | * For Newtonsoft.Json and Newtonsoft.Json.Schema see also this required NOTICE: 24 | Copyright (c) 2007 James Newton-King 25 | -------------------------------------------------------------------------------- /Updater.ps1: -------------------------------------------------------------------------------- 1 | # Pull version from script trigger 2 | param ( 3 | [string]$LatestVersion 4 | ) 5 | 6 | # Set variables 7 | $projectPath = "$PSScriptRoot\VeeamNotify" 8 | 9 | # Import functions 10 | Import-Module "$projectPath\resources\Logger.psm1" 11 | 12 | # Logging 13 | ## Set log file name 14 | $date = (Get-Date -UFormat %Y-%m-%d_%T).Replace(':','.') 15 | $logFile = "$PSScriptRoot\$($date)_Update.log" 16 | ## Start logging to file 17 | Start-Logging $logFile 18 | 19 | # Set error action preference. 20 | Write-LogMessage -Tag 'INFO' -Message 'Set error action preference.' 21 | $ErrorActionPreference = 'Stop' 22 | 23 | # Notification function 24 | function Update-Notification { 25 | [CmdletBinding( 26 | SupportsShouldProcess, 27 | ConfirmImpact = 'Low' 28 | )] 29 | param () 30 | if ($PSCmdlet.ShouldProcess('Discord', 'Send update notification')) { 31 | Write-LogMessage -Tag 'INFO' -Message 'Building notification.' 32 | # Create embed and fields array 33 | [System.Collections.ArrayList]$embedArray = @() 34 | [System.Collections.ArrayList]$fieldArray = @() 35 | # Thumbnail object 36 | $thumbObject = [PSCustomObject]@{ 37 | url = $currentConfig.thumbnail 38 | } 39 | # Field objects 40 | $resultField = [PSCustomObject]@{ 41 | name = 'Update Result' 42 | value = $result 43 | inline = 'false' 44 | } 45 | $newVersionField = [PSCustomObject]@{ 46 | name = 'New version' 47 | value = $newVersion 48 | inline = 'false' 49 | } 50 | $oldVersionField = [PSCustomObject]@{ 51 | name = 'Old version' 52 | value = $oldVersion 53 | inline = 'false' 54 | } 55 | # Add field objects to the field array 56 | $fieldArray.Add($oldVersionField) | Out-Null 57 | $fieldArray.Add($newVersionField) | Out-Null 58 | $fieldArray.Add($resultField) | Out-Null 59 | # Send error if exist 60 | if ($null -ne $errorVar) { 61 | $errorField = [PSCustomObject]@{ 62 | name = 'Update Error' 63 | value = $errorVar 64 | inline = 'false' 65 | } 66 | $fieldArray.Add($errorField) | Out-Null 67 | } 68 | # Embed object including field and thumbnail vars from above 69 | $embedObject = [PSCustomObject]@{ 70 | title = 'Update' 71 | color = '1267393' 72 | thumbnail = $thumbObject 73 | fields = $fieldArray 74 | } 75 | # Add embed object to the array created above 76 | $embedArray.Add($embedObject) | Out-Null 77 | # Build payload 78 | $payload = [PSCustomObject]@{ 79 | embeds = $embedArray 80 | } 81 | Write-LogMessage -Tag 'INFO' -Message 'Sending notification.' 82 | # Send iiit 83 | try { 84 | Invoke-RestMethod -Uri $currentConfig.webhook -Body ($payload | ConvertTo-Json -Depth 4) -Method Post -ContentType 'application/json' 85 | } 86 | catch { 87 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 88 | Write-Warning 'Update notification failed to send to Discord.' 89 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 90 | } 91 | } 92 | } 93 | 94 | # Success function 95 | function Update-Success { 96 | [CmdletBinding( 97 | SupportsShouldProcess, 98 | ConfirmImpact = 'Low' 99 | )] 100 | param () 101 | if ($PSCmdlet.ShouldProcess('Updater', 'Update success process')) { 102 | # Set error action preference so that errors while ending the script don't end the script prematurely. 103 | Write-LogMessage -Tag 'INFO' -Message 'Set error action preference.' 104 | $ErrorActionPreference = 'Continue' 105 | 106 | # Set result var for notification and script output 107 | $script:result = 'Success!' 108 | 109 | # Copy logs directory from copy of previously installed version to new install 110 | Write-LogMessage -Tag 'INFO' -Message 'Copying logs from old version to new version.' 111 | Copy-Item -Path $projectPath-old\log -Destination $projectPath\ -Recurse -Force 112 | 113 | # Remove copy of previously installed version 114 | Write-LogMessage -Tag 'INFO' -Message 'Removing old version.' 115 | Remove-Item -Path $projectPath-old -Recurse -Force 116 | 117 | # Trigger the Update-Notification function and then End-Script function. 118 | Update-Notification 119 | End-Script 120 | } 121 | } 122 | 123 | # Failure function 124 | function Update-Fail { 125 | [CmdletBinding( 126 | SupportsShouldProcess, 127 | ConfirmImpact = 'Low' 128 | )] 129 | param () 130 | if ($PSCmdlet.ShouldProcess('Updater', 'Update failure process')) { 131 | # Set error action preference so that errors while ending the script don't end the script prematurely. 132 | Write-LogMessage -Tag 'INFO' -Message 'Set error action preference.' 133 | $ErrorActionPreference = 'Continue' 134 | 135 | # Set result var for notification and script output 136 | $script:result = 'Failure!' 137 | 138 | # Take action based on the stage at which the error occured 139 | switch ($fail) { 140 | download { 141 | Write-Warning 'Failed to download update.' 142 | } 143 | unzip { 144 | Write-Warning 'Failed to unzip update. Cleaning up and reverting.' 145 | Remove-Item -Path $projectPath-$LatestVersion.zip -Force 146 | } 147 | rename_old { 148 | Write-Warning 'Failed to rename old version. Cleaning up and reverting.' 149 | Remove-Item -Path $projectPath-$LatestVersion.zip -Force 150 | Remove-Item -Path $projectPath-$LatestVersion -Recurse -Force 151 | } 152 | rename_new { 153 | Write-Warning 'Failed to rename new version. Cleaning up and reverting.' 154 | Remove-Item -Path $projectPath-$LatestVersion.zip -Force 155 | Remove-Item -Path $projectPath-$LatestVersion -Recurse -Force 156 | Rename-Item $projectPath-old $projectPath 157 | } 158 | after_rename_new { 159 | Write-Warning 'Failed after renaming new version. Cleaning up and reverting.' 160 | Remove-Item -Path $projectPath-$LatestVersion.zip -Force 161 | Remove-Item -Path $projectPath -Recurse -Force 162 | Rename-Item $projectPath-old $projectPath 163 | } 164 | } 165 | 166 | # Trigger the Update-Notification function and then End-Script function. 167 | Update-Notification 168 | End-Script 169 | } 170 | } 171 | 172 | # End of script function 173 | function Stop-Script { 174 | [CmdletBinding( 175 | SupportsShouldProcess, 176 | ConfirmImpact = 'Low' 177 | )] 178 | param () 179 | if ($PSCmdlet.ShouldProcess('Updater', 'Cleanup & stop')) { 180 | # Clean up. 181 | Write-LogMessage -Tag 'INFO' -Message 'Remove downloaded ZIP.' 182 | if (Test-Path "$projectPath-$LatestVersion.zip") { 183 | Remove-Item "$projectPath-$LatestVersion.zip" 184 | } 185 | Write-LogMessage -Tag 'INFO' -Message 'Remove Updater.ps1.' 186 | Remove-Item -LiteralPath $PSCommandPath -Force 187 | 188 | # Report result 189 | Write-LogMessage -Tag 'INFO' -Message "Update result: $result" 190 | 191 | # Stop logging 192 | Write-LogMessage -Tag 'INFO' -Message 'Stop logging.' 193 | Stop-Logging $logFile 194 | 195 | # Move log file 196 | Write-Output 'Move log file to log directory in VeeamNotify.' 197 | Move-Item $logFile "$projectPath\log\" 198 | 199 | # Exit script 200 | Write-Output 'Exiting.' 201 | exit 202 | } 203 | } 204 | 205 | # Pull current config to variable 206 | try { 207 | Write-LogMessage -Tag 'INFO' -Message 'Pull current config to variable.' 208 | $currentConfig = (Get-Content "$projectPath\config\conf.json") -join "`n" | ConvertFrom-Json 209 | } 210 | catch { 211 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 212 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 213 | Update-Fail 214 | } 215 | 216 | # Get currently downloaded version 217 | try { 218 | Write-LogMessage -Tag 'INFO' -Message 'Getting currently downloaded version of the script.' 219 | [String]$oldVersion = Get-Content "$projectPath\resources\version.txt" -Raw 220 | } 221 | catch { 222 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 223 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 224 | Update-Fail 225 | } 226 | 227 | # Wait until the alert sender has finished running, or quit this if it's still running after 60s. It should never take that long. 228 | while (Get-CimInstance win32_process -Filter "name='powershell.exe' and commandline like '%AlertSender.ps1%'") { 229 | $timer++ 230 | Start-Sleep -Seconds 1 231 | if ($timer -eq '90') { 232 | Write-LogMessage -Tag 'INFO' -Message "Timeout reached. Updater quitting as AlertSender.ps1 is still running after $timer seconds." 233 | } 234 | Update-Fail 235 | } 236 | 237 | # Pull latest version of script from GitHub 238 | try { 239 | Write-LogMessage -Tag 'INFO' -Message 'Pull latest version of script from GitHub.' 240 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 241 | Invoke-WebRequest -Uri ` 242 | https://github.com/tigattack/VeeamNotify/releases/download/$LatestVersion/VeeamNotify-$LatestVersion.zip ` 243 | -OutFile $projectPath-$LatestVersion.zip 244 | } 245 | catch { 246 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 247 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 248 | $fail = 'download' 249 | Update-Fail 250 | } 251 | 252 | # Expand downloaded ZIP 253 | try { 254 | Write-LogMessage -Tag 'INFO' -Message 'Expand downloaded ZIP.' 255 | Expand-Archive $projectPath-$LatestVersion.zip -DestinationPath $PSScriptRoot 256 | } 257 | catch { 258 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 259 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 260 | $fail = 'unzip' 261 | Update-Fail 262 | } 263 | 264 | # Rename old version to keep as a backup while the update is in progress. 265 | try { 266 | Write-LogMessage -Tag 'INFO' -Message 'Rename current to avoid conflict with new version.' 267 | Rename-Item $projectPath $projectPath-old 268 | } 269 | catch { 270 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 271 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 272 | $fail = 'rename_old' 273 | Update-Fail 274 | } 275 | 276 | # Rename extracted update 277 | try { 278 | Write-LogMessage -Tag 'INFO' -Message 'Rename extracted download.' 279 | Rename-Item $projectPath-$LatestVersion $projectPath 280 | } 281 | catch { 282 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 283 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 284 | $fail = 'rename_new' 285 | Update-Fail 286 | } 287 | 288 | # Pull configuration from new conf file 289 | try { 290 | Write-LogMessage -Tag 'INFO' -Message 'Pull configuration from new conf file.' 291 | $newConfig = (Get-Content "$projectPath\config\conf.json") -join "`n" | ConvertFrom-Json 292 | } 293 | catch { 294 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 295 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 296 | $fail = 'after_rename_new' 297 | Update-Fail 298 | } 299 | 300 | # Unblock script files 301 | Write-LogMessage -Tag 'INFO' -Message 'Unblock script files.' 302 | 303 | ## Get script files 304 | $pwshFiles = Get-ChildItem $projectPath\* -Recurse | Where-Object { $_.Name -match '^.*\.ps(m)?1$' } 305 | 306 | ## Unblock them 307 | try { 308 | foreach ($i in $pwshFiles) { 309 | Unblock-File -Path $i.FullName 310 | } 311 | } 312 | catch { 313 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 314 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 315 | $fail = 'unblock_scripts' 316 | Update-Fail 317 | } 318 | 319 | # Populate conf.json with previous configuration 320 | try { 321 | Write-LogMessage -Tag 'INFO' -Message 'Populate conf.json with previous configuration.' 322 | $newConfig.webhook = $currentConfig.webhook 323 | $newConfig.userid = $currentConfig.userid 324 | if ($currentConfig.mentions.on_fail -ne $newConfig.mentions.on_fail) { 325 | $newConfig.mentions.on_fail = $currentConfig.mentions.on_fail 326 | } 327 | if ($currentConfig.logging.enabled -ne $newConfig.logging.enabled) { 328 | $newConfig.logging.enabled = $currentConfig.logging.enabled 329 | } 330 | if ($currentConfig.auto_update -ne $newConfig.auto_update) { 331 | $newConfig.auto_update = $currentConfig.auto_update 332 | } 333 | ConvertTo-Json $newConfig | Set-Content "$projectPath\config\conf.json" 334 | } 335 | catch { 336 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 337 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 338 | $fail = 'after_rename_new' 339 | Update-Fail 340 | } 341 | 342 | # Get newly downloaded version 343 | try { 344 | Write-LogMessage -Tag 'INFO' -Message 'Get newly downloaded version.' 345 | [String]$newVersion = Get-Content "$projectPath\resources\version.txt" -Raw 346 | } 347 | catch { 348 | $errorVar = $_.CategoryInfo.Activity + ' : ' + $_.ToString() 349 | Write-LogMessage -Tag 'ERROR' -Message "$errorVar" 350 | $fail = 'after_rename_new' 351 | Update-Fail 352 | } 353 | 354 | # Send notification 355 | if ($newVersion -eq $LatestVersion) { 356 | Update-Success 357 | } 358 | else { 359 | Update-Fail 360 | } 361 | -------------------------------------------------------------------------------- /asset/agent-types.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/agent-types.png -------------------------------------------------------------------------------- /asset/discord-embed-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/discord-embed-preview.png -------------------------------------------------------------------------------- /asset/discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/discord.png -------------------------------------------------------------------------------- /asset/installer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/installer.png -------------------------------------------------------------------------------- /asset/slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/slack.png -------------------------------------------------------------------------------- /asset/teams.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/teams.png -------------------------------------------------------------------------------- /asset/thumb01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/asset/thumb01.png -------------------------------------------------------------------------------- /config/conf.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "discord": { 4 | "enabled": false, 5 | "webhook": "DiscordWebhook", 6 | "user_id": "123456789" 7 | }, 8 | "slack": { 9 | "enabled": false, 10 | "webhook": "SlackWebhook", 11 | "user_id": "A1B2C3D4E5" 12 | }, 13 | "teams": { 14 | "enabled": false, 15 | "webhook": "TeamsWebhook", 16 | "user_id": "user@domain.tld", 17 | "display_name": "Your Name" 18 | }, 19 | "telegram": { 20 | "enabled": false, 21 | "bot_token": "TelegramBotToken", 22 | "chat_id": "TelegramChatID", 23 | "user_name": "yourusername" 24 | }, 25 | "http": { 26 | "enabled": false, 27 | "url": "EndpointURL", 28 | "method": "POST" 29 | } 30 | }, 31 | "mentions": { 32 | "on_failure": false, 33 | "on_warning": false, 34 | "on_success": false 35 | }, 36 | "notifications": { 37 | "on_success": true, 38 | "on_failure": true, 39 | "on_warning": true 40 | }, 41 | "logging": { 42 | "enabled": true, 43 | "level": "info", 44 | "max_age_days": 7 45 | }, 46 | "update": { 47 | "notify": true, 48 | "auto_update": false, 49 | "auto_update_comment": "auto_update is not yet implemented. This will do nothing even if 'true'." 50 | }, 51 | "thumbnail": "https://raw.githubusercontent.com/tigattack/VeeamNotify/main/asset/thumb01.png" 52 | } 53 | -------------------------------------------------------------------------------- /config/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "VeeamNotify Configuration Schema", 4 | "type": "object", 5 | "properties": { 6 | "services": { 7 | "type": "object", 8 | "properties": { 9 | "discord": { 10 | "type": "object", 11 | "properties": { 12 | "enabled": { 13 | "type": "boolean" 14 | }, 15 | "webhook": { 16 | "type": "string" 17 | }, 18 | "user_id": { 19 | "type": "string" 20 | } 21 | }, 22 | "required": [ 23 | "enabled", 24 | "webhook" 25 | ] 26 | }, 27 | "slack": { 28 | "type": "object", 29 | "properties": { 30 | "enabled": { 31 | "type": "boolean" 32 | }, 33 | "webhook": { 34 | "type": "string" 35 | }, 36 | "user_id": { 37 | "type": "string" 38 | } 39 | }, 40 | "required": [ 41 | "enabled", 42 | "webhook" 43 | ] 44 | }, 45 | "teams": { 46 | "type": "object", 47 | "properties": { 48 | "enabled": { 49 | "type": "boolean" 50 | }, 51 | "webhook": { 52 | "type": "string" 53 | }, 54 | "user_id": { 55 | "type": "string" 56 | }, 57 | "display_name": { 58 | "type": "string" 59 | } 60 | }, 61 | "required": [ 62 | "enabled", 63 | "webhook" 64 | ], 65 | "not": { 66 | "required": [ 67 | "user_name", 68 | "display_name" 69 | ] 70 | } 71 | }, 72 | "telegram": { 73 | "type": "object", 74 | "properties": { 75 | "enabled": { 76 | "type": "boolean" 77 | }, 78 | "bot_token": { 79 | "type": "string" 80 | }, 81 | "chat_id": { 82 | "type": "string" 83 | }, 84 | "user_name": { 85 | "type": "string" 86 | } 87 | }, 88 | "required": [ 89 | "enabled", 90 | "bot_token", 91 | "chat_id" 92 | ] 93 | }, 94 | "http": { 95 | "type": "object", 96 | "properties": { 97 | "enabled": { 98 | "type": "boolean" 99 | }, 100 | "url": { 101 | "type": "string" 102 | }, 103 | "method": { 104 | "type": "string", 105 | "enum": [ 106 | "GET", 107 | "POST" 108 | ] 109 | } 110 | }, 111 | "required": [ 112 | "enabled", 113 | "url", 114 | "method" 115 | ] 116 | } 117 | }, 118 | "required": [ 119 | "discord", 120 | "slack", 121 | "teams", 122 | "telegram", 123 | "http" 124 | ] 125 | }, 126 | "mentions": { 127 | "type": "object", 128 | "properties": { 129 | "on_failure": { 130 | "type": "boolean" 131 | }, 132 | "on_warning": { 133 | "type": "boolean" 134 | }, 135 | "on_success": { 136 | "type": "boolean" 137 | } 138 | } 139 | }, 140 | "notifications": { 141 | "type": "object", 142 | "properties": { 143 | "on_success": { 144 | "type": "boolean" 145 | }, 146 | "on_failure": { 147 | "type": "boolean" 148 | }, 149 | "on_warning": { 150 | "type": "boolean" 151 | } 152 | } 153 | }, 154 | "logging": { 155 | "type": "object", 156 | "properties": { 157 | "enabled": { 158 | "type": "boolean" 159 | }, 160 | "level": { 161 | "type": "string" 162 | }, 163 | "max_age_days": { 164 | "type": "integer" 165 | } 166 | }, 167 | "required": [ 168 | "enabled", 169 | "level", 170 | "max_age_days" 171 | ] 172 | }, 173 | "update": { 174 | "type": "object", 175 | "properties": { 176 | "notify": { 177 | "type": "boolean" 178 | }, 179 | "auto_update": { 180 | "type": "boolean" 181 | }, 182 | "auto_update_comment": { 183 | "type": "string" 184 | } 185 | } 186 | }, 187 | "thumbnail": { 188 | "type": "string" 189 | } 190 | }, 191 | "required": [ 192 | "services", 193 | "logging", 194 | "thumbnail" 195 | ] 196 | } 197 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /resources/DeployVeeamConfiguration.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [String]$InstallParentPath = 'C:\VeeamScripts' 3 | ) 4 | 5 | function Write-StatusMessage { 6 | param( 7 | [string]$Message, 8 | [string]$Status = 'Info', 9 | [string]$JobName = '' 10 | ) 11 | 12 | $prefix = if ($JobName) { "[$JobName] " } else { '' } 13 | 14 | switch ($Status) { 15 | 'Success' { Write-Host -ForegroundColor Green "`n$prefix$Message" } 16 | 'Warning' { Write-Host -ForegroundColor Yellow "`n$prefix$Message" } 17 | 'Error' { Write-Host -ForegroundColor Red "`n$prefix$Message" } 18 | default { Write-Host "$prefix$Message" } 19 | } 20 | } 21 | 22 | # Function to be used when an error is encountered 23 | function DeploymentError { 24 | $issues = 'https://github.com/tigattack/VeeamNotify/issues' 25 | 26 | Write-StatusMessage -Status 'Error' -Message "An error occured $($_.ScriptStackTrace.Split("`n")[0]): $($_.Exception.Message)" 27 | Write-StatusMessage -Message "`nPlease raise an issue at $issues" 28 | 29 | $launchIssuesPrompt_yes = New-Object System.Management.Automation.Host.ChoiceDescription '&Yes', 'Open a new issue' 30 | $launchIssuesPrompt_no = New-Object System.Management.Automation.Host.ChoiceDescription '&No', 'Do nothing' 31 | $launchIssuesPrompt_opts = [System.Management.Automation.Host.ChoiceDescription[]]($launchIssuesPrompt_yes, $launchIssuesPrompt_no) 32 | $launchIssuesPrompt_result = $host.UI.PromptForChoice('Open a new issue', 'Do you wish to open the new issue page in your browser?', $launchIssuesPrompt_opts, -1) 33 | 34 | if ($launchIssuesPrompt_result -eq 0) { 35 | Start-Process "$issues/new?assignees=tigattack&labels=bug&template=bug_report.yml&title=[BUG]+Veeam%20configuration%20deployment%20error" 36 | } 37 | exit 1 38 | } 39 | 40 | function Set-VeeamJobOptions { 41 | param( 42 | [Parameter(Mandatory)]$Job, 43 | [Parameter(Mandatory)]$Options 44 | ) 45 | 46 | try { 47 | # Agent jobs require their own cmdlet 48 | if ($Job.JobType -eq 'EpAgentBackup') { 49 | Set-VBRComputerBackupJob -Job $Job -ScriptOptions $Options.JobScriptCommand | Out-Null 50 | } 51 | else { 52 | # For 'regular' (e.g. backup, replica) jobs 53 | Set-VBRJobOptions -Job $Job -Options $Options | Out-Null 54 | } 55 | } 56 | catch { 57 | DeploymentError 58 | } 59 | } 60 | 61 | function New-PromptChoice { 62 | param( 63 | [string]$Label, 64 | [string]$HelpMessage 65 | ) 66 | 67 | return New-Object System.Management.Automation.Host.ChoiceDescription $Label, $HelpMessage 68 | } 69 | 70 | function Show-Prompt { 71 | param( 72 | [string]$Title, 73 | [string]$Message, 74 | [System.Management.Automation.Host.ChoiceDescription[]]$Choices, 75 | [int]$DefaultOption = -1 76 | ) 77 | 78 | return $host.UI.PromptForChoice($Title, $Message, $Choices, $DefaultOption) 79 | } 80 | 81 | function Update-JobWithFullPowershellPath { 82 | param( 83 | [Parameter(Mandatory)]$Job, 84 | [string]$PowershellPath, 85 | [string]$PostScriptCmd 86 | ) 87 | 88 | Write-StatusMessage -Status 'Warning' -JobName $Job.Name -Message 'Job is already configured for VeeamNotify, but does not have a full path to PowerShell. Updating...' 89 | 90 | try { 91 | $jobOptions = $Job.GetOptions() 92 | # Replace Powershell.exe with full path in a new variable for update. 93 | $PostScriptFullPSPath = $PostScriptCmd -replace 'Powershell.exe', "$PowershellPath" 94 | # Set job to use modified post script path 95 | $jobOptions.JobScriptCommand.PostScriptCommandLine = $PostScriptFullPSPath 96 | $null = Set-VeeamJobOptions -Job $Job -Options $jobOptions 97 | 98 | Write-StatusMessage -Status 'Success' -JobName $Job.Name -Message 'Job updated with the full PowerShell path.' 99 | } 100 | catch { 101 | DeploymentError 102 | } 103 | } 104 | 105 | function Update-ExistingPostScript { 106 | param( 107 | [Parameter(Mandatory)]$Job, 108 | [string]$NewPostScriptCmd, 109 | [string]$CurrentPostScriptCmd, 110 | [bool]$AskBeforeOverwriting = $true 111 | ) 112 | 113 | Write-StatusMessage -Status 'Warning' -JobName $Job.Name -Message 'Job has an existing post-job script:' 114 | Write-StatusMessage -Message $CurrentPostScriptCmd 115 | 116 | if ($AskBeforeOverwriting) { 117 | Write-StatusMessage -Message "`nIf you wish to receive notifications for this job, you must overwrite the existing post-job script." 118 | 119 | $overwriteYes = New-PromptChoice -Label '&Yes' -HelpMessage 'Overwrite the current post-job script.' 120 | $overwriteNo = New-PromptChoice -Label '&No' -HelpMessage 'Skip configuration of this job, leaving it as-is.' 121 | $overwriteOptions = @($overwriteYes, $overwriteNo) 122 | 123 | $overwriteResult = Show-Prompt -Title 'Overwrite Job Configuration' -Message 'Do you wish to overwrite the existing post-job script?' -Choices $overwriteOptions -DefaultOption 1 124 | 125 | switch ($overwriteResult) { 126 | 0 { $shouldUpdate = $true } 127 | 1 { 128 | $shouldUpdate = $false 129 | Write-StatusMessage -JobName $Job.Name -Message 'Skipping job' 130 | } 131 | } 132 | } 133 | else { 134 | $shouldUpdate = $true 135 | } 136 | 137 | if ($shouldUpdate) { 138 | try { 139 | # Check to see if the script has even changed 140 | if ($CurrentPostScriptCmd -ne $NewPostScriptCmd) { 141 | # Script is not the same. Update the script command line. 142 | $jobOptions = $Job.GetOptions() 143 | $jobOptions.JobScriptCommand.PostScriptCommandLine = $NewPostScriptCmd 144 | Set-VeeamJobOptions -Job $Job -Options $jobOptions 145 | 146 | Write-StatusMessage -JobName $Job.Name -Message 'Updated post-job script:' 147 | Write-StatusMessage -Message "Old: $CurrentPostScriptCmd" 148 | Write-StatusMessage -Message "New: $NewPostScriptCmd" 149 | Write-StatusMessage -Status 'Success' -JobName $Job.Name -Message 'Job is now configured for VeeamNotify.' 150 | } 151 | else { 152 | # Script hasn't changed. Notify user of this and continue. 153 | Write-StatusMessage -Status 'Warning' -JobName $Job.Name -Message 'Job is already configured for VeeamNotify; Skipping.' 154 | } 155 | } 156 | catch { 157 | DeploymentError 158 | } 159 | } 160 | } 161 | 162 | function Enable-PostScript { 163 | param( 164 | [Parameter(Mandatory)]$Job, 165 | [string]$NewPostScriptCmd, 166 | [bool]$AskBeforeEnabling = $true 167 | ) 168 | 169 | if ($AskBeforeEnabling) { 170 | $setNewYes = New-PromptChoice -Label '&Yes' -HelpMessage 'Configure this job to send notifications.' 171 | $setNewNo = New-PromptChoice -Label '&No' -HelpMessage 'Skip configuration of this job, leaving it as-is.' 172 | $setNewOptions = @($setNewYes, $setNewNo) 173 | 174 | $setNewMessage = 'Do you wish to receive notifications for this job?' 175 | $setNewResult = Show-Prompt -Title "Configure Job: $($Job.Name.ToString()) ($($Job.TypeToString))" -Message $setNewMessage -Choices $setNewOptions 176 | 177 | switch ($setNewResult) { 178 | 0 { $shouldEnable = $true } 179 | 1 { 180 | $shouldEnable = $false 181 | Write-StatusMessage -JobName $Job.Name -Message 'Skipping job' 182 | } 183 | } 184 | } 185 | else { 186 | $shouldEnable = $true 187 | } 188 | 189 | if ($shouldEnable) { 190 | try { 191 | # Sets post-job script to Enabled and sets the command line to full command including path. 192 | $jobOptions = $Job.GetOptions() 193 | $jobOptions.JobScriptCommand.PostScriptEnabled = $true 194 | $jobOptions.JobScriptCommand.PostScriptCommandLine = $NewPostScriptCmd 195 | Set-VeeamJobOptions -Job $Job -Options $jobOptions 196 | 197 | Write-StatusMessage -Status 'Success' -JobName $Job.Name -Message 'Job is now configured for VeeamNotify.' 198 | } 199 | catch { 200 | DeploymentError 201 | } 202 | } 203 | } 204 | 205 | function Set-BackupJobPostScript { 206 | param( 207 | [Parameter(Mandatory)]$Job, 208 | [string]$NewPostScriptCmd, 209 | [string]$PowershellPath, 210 | [bool]$AskBeforeConfiguring = $true 211 | ) 212 | 213 | # Get post-job script options for job 214 | $jobOptions = $Job.GetOptions() 215 | $postScriptEnabled = $jobOptions.JobScriptCommand.PostScriptEnabled 216 | $postScriptCmd = $jobOptions.JobScriptCommand.PostScriptCommandLine 217 | 218 | # Check if job is already configured for VeeamNotify 219 | if ($postScriptCmd -eq $NewPostScriptCmd) { 220 | # Check if job has full PowerShell.exe path 221 | if ($postScriptCmd.StartsWith('powershell.exe', 'CurrentCultureIgnoreCase')) { 222 | return Update-JobWithFullPowershellPath -Job $Job -PowershellPath $PowershellPath -PostScriptCmd $postScriptCmd 223 | } 224 | 225 | # skip if all correct 226 | Write-StatusMessage -Status 'Warning' -JobName $Job.Name -Message 'Job is already configured for VeeamNotify; Skipping.' 227 | return 228 | } 229 | 230 | # Different actions whether post-job script is already enabled 231 | if ($postScriptEnabled) { 232 | return Update-ExistingPostScript -Job $Job -NewPostScriptCmd $NewPostScriptCmd -CurrentPostScriptCmd $postScriptCmd -AskBeforeOverwriting $AskBeforeConfiguring 233 | } 234 | else { 235 | return Enable-PostScript -Job $Job -NewPostScriptCmd $NewPostScriptCmd -AskBeforeEnabling $AskBeforeConfiguring 236 | } 237 | } 238 | 239 | function Show-JobSelectionMenu { 240 | param([array]$Jobs) 241 | 242 | $jobMenu = @{} 243 | $menuIndex = 1 244 | 245 | Write-StatusMessage -Message "`nAvailable jobs:" 246 | foreach ($job in $Jobs) { 247 | Write-StatusMessage -Message "$menuIndex. $($job.Name.ToString()) ($($job.TypeToString))" 248 | $jobMenu.Add($menuIndex, $job) 249 | $menuIndex++ 250 | } 251 | 252 | Write-StatusMessage -Message "`n0. Exit job configuration" 253 | 254 | return $jobMenu 255 | } 256 | 257 | function Get-JobSelection { 258 | param([hashtable]$JobMenu) 259 | 260 | do { 261 | $selection = Read-Host "`nEnter the job number to configure (0 to exit)" 262 | 263 | if ($selection -eq '0') { 264 | return $null 265 | } 266 | 267 | $intSelection = 0 268 | if ([int]::TryParse($selection, [ref]$intSelection) -and $JobMenu.ContainsKey($intSelection)) { 269 | return $JobMenu[$intSelection] 270 | } 271 | 272 | Write-StatusMessage -Status 'Error' -Message 'Invalid selection. Please enter a valid job number.' 273 | } while ($true) 274 | } 275 | 276 | # Consolidated configuration function 277 | function Start-JobConfiguration { 278 | param( 279 | [array]$Jobs, 280 | [string]$PostScriptCmd, 281 | [string]$PowerShellPath, 282 | [ValidateSet('All', 'Interactive', 'None')] 283 | [string]$Mode = 'Interactive' 284 | ) 285 | 286 | switch ($Mode) { 287 | 'All' { 288 | foreach ($job in $Jobs) { 289 | Set-BackupJobPostScript -Job $job -NewPostScriptCmd $PostScriptCmd -PowershellPath $PowerShellPath -AskBeforeConfiguring $false 290 | } 291 | } 292 | 'Interactive' { 293 | $jobMenu = Show-JobSelectionMenu -Jobs $Jobs 294 | do { 295 | $selectedJob = Get-JobSelection -JobMenu $jobMenu 296 | if ($null -eq $selectedJob) { break } 297 | 298 | Set-BackupJobPostScript -Job $selectedJob -NewPostScriptCmd $PostScriptCmd -PowershellPath $PowerShellPath -AskBeforeConfiguring $true 299 | } while ($true) 300 | } 301 | 'None' { 302 | Write-StatusMessage -Status 'Warning' -Message 'Skipping VeeamNotify configuration deployment for all jobs.' 303 | } 304 | } 305 | } 306 | 307 | # Main script execution starts here 308 | 309 | # Get PowerShell path 310 | try { 311 | $powershellExePath = (Get-Command -Name 'powershell.exe' -ErrorAction Stop).Path 312 | } 313 | catch { 314 | DeploymentError 315 | } 316 | 317 | $newPostScriptCmd = "$powershellExePath -NoProfile -ExecutionPolicy Bypass -File $(Join-Path -Path "$InstallParentPath" -ChildPath 'VeeamNotify\Bootstrap.ps1')" 318 | 319 | Write-StatusMessage -Message "Importing Veeam module and discovering supported jobs, please wait...`n" 320 | 321 | # Import Veeam module 322 | Import-Module Veeam.Backup.PowerShell -DisableNameChecking 323 | 324 | # Get all supported jobs 325 | $backupJobs = Get-VBRJob -WarningAction SilentlyContinue | Where-Object { 326 | $_.JobType -in 'Backup', 'Replica', 'EpAgentBackup' 327 | } | Sort-Object -Property Name, Type 328 | 329 | # Make sure we found some jobs 330 | if ($backupJobs.Count -eq 0) { 331 | Write-StatusMessage -Status 'Warning' -Message 'No supported jobs found; Exiting.' 332 | Start-Sleep 10 333 | exit 334 | } 335 | else { 336 | Write-StatusMessage -Message "Found $($backupJobs.count) supported jobs:" 337 | $backupJobs | Format-Table -Property Name, @{Name = 'Type'; Expression = { $_.TypeToString } } -AutoSize 338 | } 339 | 340 | # Query config backup 341 | $backupYes = New-PromptChoice -Label '&Yes' -HelpMessage 'Create a Veeam configuration backup.' 342 | $backupNo = New-PromptChoice -Label '&No' -HelpMessage 'Do not create a Veeam configuration backup.' 343 | $backupOptions = @($backupYes, $backupNo) 344 | $backupMessage = "This script can create a Veeam configuration backup for you before making any changes.`nDo you want to create a backup now?" 345 | $backupResult = Show-Prompt -Title 'Veeam Configuration Backup' -Message $backupMessage -Choices $backupOptions -DefaultOption 0 346 | 347 | if ($backupResult -eq 0) { 348 | # Run backup 349 | Write-StatusMessage -Message "`nCreating backup, please wait..." 350 | $backupExecution = Start-VBRConfigurationBackupJob 351 | if ($backupExecution.Result -ne 'Failed') { 352 | Write-StatusMessage -Status 'Success' -Message 'Backup completed successfully.' 353 | } 354 | else { 355 | $continueYes = New-PromptChoice -Label '&Yes' -HelpMessage 'Continue anyway.' 356 | $continueNo = New-PromptChoice -Label '&No' -HelpMessage 'Exit now.' 357 | $continueOptions = @($continueYes, $continueNo) 358 | $continueResult = Show-Prompt -Title 'Backup Failed' -Message 'Do you want to continue anyway?' -Choices $continueOptions 359 | 360 | if ($continueResult -eq 1) { 361 | Write-StatusMessage -Message 'Exiting.' 362 | Start-Sleep 10 363 | exit 364 | } 365 | else { 366 | Write-StatusMessage -Status 'Warning' -Message 'Continuing anyway.' 367 | } 368 | } 369 | } 370 | 371 | # Query configure all or selected jobs 372 | $modeInteractive = New-PromptChoice -Label '&Interactive' -HelpMessage 'Choose jobs to configure one by one.' 373 | $modeAll = New-PromptChoice -Label '&All' -HelpMessage 'Configure all supported jobs automatically.' 374 | $modeNone = New-PromptChoice -Label '&None' -HelpMessage 'Do not configure any jobs.' 375 | $modeOptions = @($modeInteractive, $modeAll, $modeNone) 376 | $modeMessage = 'How would you like to configure your jobs?' 377 | $modeResult = Show-Prompt -Title 'Configuration Mode' -Message $modeMessage -Choices $modeOptions -DefaultOption 0 378 | 379 | switch ($modeResult) { 380 | 0 { Start-JobConfiguration -Jobs $backupJobs -PostScriptCmd $newPostScriptCmd -PowerShellPath $powershellExePath -Mode 'Interactive' } 381 | 1 { Start-JobConfiguration -Jobs $backupJobs -PostScriptCmd $newPostScriptCmd -PowerShellPath $powershellExePath -Mode 'All' } 382 | 2 { Start-JobConfiguration -Jobs $backupJobs -PostScriptCmd $newPostScriptCmd -PowerShellPath $powershellExePath -Mode 'None' } 383 | } 384 | 385 | if ($MyInvocation.ScriptName -notlike '*Installer.ps1') { 386 | Write-StatusMessage -Status 'Success' -Message "`nFinished. Exiting." 387 | Start-Sleep 10 388 | } 389 | 390 | exit 391 | -------------------------------------------------------------------------------- /resources/Format-Bytes.psm1: -------------------------------------------------------------------------------- 1 | function Format-Bytes { 2 | <# 3 | .Synopsis 4 | Humanises data size strings. 5 | .DESCRIPTION 6 | Formats data size strings for human readability by converting the closest unit (e.g. B, KB, MB, etc.). 7 | .EXAMPLE 8 | Format-Bytes -Data 1024 9 | .EXAMPLE 10 | Format-Bytes -Data ((Get-ChildItem -Path ./ -Recurse | Measure-Object -Property Length -Sum).Sum) 11 | .EXAMPLE 12 | Get-ChildItem -Path ./ -File | Select-Object -ExpandProperty Length | Format-Bytes 13 | #> 14 | [CmdletBinding()] 15 | [OutputType([System.String])] 16 | param ( 17 | [Parameter (Mandatory, ValueFromPipeline)]$Data 18 | ) 19 | 20 | process { 21 | switch ($Data) { 22 | {$_ -ge 1TB } { 23 | $value = [math]::Round(($Data / 1TB), 2) 24 | return "$value TB" 25 | } 26 | {$_ -ge 1GB } { 27 | $value = [math]::Round(($Data / 1GB), 2) 28 | return "$value GB" 29 | } 30 | {$_ -ge 1MB } { 31 | $value = [math]::Round(($Data / 1MB), 2) 32 | return "$value MB" 33 | } 34 | {$_ -ge 1KB } { 35 | $value = [math]::Round(($Data / 1KB), 2) 36 | return "$value KB" 37 | } 38 | {$_ -lt 1KB} { 39 | $value = [math]::Round($Data, 2) 40 | return "$value B" 41 | } 42 | default { 43 | return $Data 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /resources/JsonValidator.psm1: -------------------------------------------------------------------------------- 1 | class JsonValidationResult { 2 | [Parameter(Mandatory)] 3 | [bool]$IsValid 4 | [string]$Message 5 | } 6 | 7 | # Thanks to Mathias R. Jessen for the foundations of this function 8 | # https://stackoverflow.com/a/75759006/5209106 9 | function Test-JsonValid { 10 | [CmdletBinding()] 11 | param ( 12 | [Parameter(Mandatory)] 13 | [string]$JsonPath, 14 | [Parameter(Mandatory)] 15 | [string]$SchemaPath 16 | ) 17 | 18 | $NewtonsoftJsonPath = Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath 'lib\Newtonsoft.Json.dll') 19 | $NewtonsoftJsonSchemaPath = Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath 'lib\Newtonsoft.Json.Schema.dll') 20 | 21 | Add-Type -Path $NewtonsoftJsonPath 22 | Add-Type -Path $NewtonsoftJsonSchemaPath 23 | 24 | if (-not (Test-Path -Path $JsonPath)) { 25 | throw "JSON file not found: $JsonPath" 26 | } 27 | if (-not (Test-Path -Path $SchemaPath)) { 28 | throw "Schema file not found: $SchemaPath" 29 | } 30 | 31 | $jsonStr = Get-Content -Path $JsonPath -Raw 32 | $schemaStr = Get-Content -Path $SchemaPath -Raw 33 | 34 | try { 35 | # Create a reader for the JSON string 36 | $stringReader = New-Object System.IO.StringReader($jsonStr) 37 | $jsonReader = New-Object Newtonsoft.Json.JsonTextReader($stringReader) 38 | 39 | $schemaObj = [Newtonsoft.Json.Schema.JSchema]::Parse($schemaStr) 40 | 41 | $validator = New-Object Newtonsoft.Json.Schema.JSchemaValidatingReader($jsonReader) 42 | $validator.Schema = $schemaObj 43 | 44 | $serializer = New-Object Newtonsoft.Json.JsonSerializer 45 | 46 | # Attempt to deserialize input JSON via validator 47 | $null = $serializer.Deserialize($validator) 48 | 49 | # Schema validation succeeded if we get this far 50 | return [JsonValidationResult]@{ 51 | IsValid = $true 52 | Message = 'JSON validation succeeded.' 53 | } 54 | } 55 | catch [Newtonsoft.Json.Schema.JSchemaException] { 56 | # Schema validation failed 57 | return [JsonValidationResult]@{ 58 | IsValid = $false 59 | Message = $_.Exception.Message 60 | } 61 | } 62 | catch { 63 | # Other error occurred 64 | return [JsonValidationResult]@{ 65 | IsValid = $false 66 | Message = "Unexpected error: $($_.Exception.Message)" 67 | } 68 | } 69 | finally { 70 | # Clean up resources 71 | if ($null -ne $stringReader) { $stringReader.Dispose() } 72 | if ($null -ne $jsonReader) { $jsonReader.Dispose() } 73 | if ($null -ne $validator) { $validator.Dispose() } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /resources/Logger.psm1: -------------------------------------------------------------------------------- 1 | # This function log messages with a type tag 2 | function Write-LogMessage { 3 | [CmdletBinding( 4 | SupportsShouldProcess, 5 | ConfirmImpact = 'Low' 6 | )] 7 | param ( 8 | [ValidateSet('Debug', 'Info', 'Warn', 'Error')] 9 | [Parameter(Mandatory)] 10 | [String]$Tag, 11 | [Parameter(Mandatory)] 12 | $Message, 13 | [switch]$FirstLog 14 | ) 15 | 16 | # Get current timestamp 17 | $time = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffK') 18 | 19 | # Creates hash table with severities 20 | $Severities = @{ 21 | Error = 1 22 | Warn = 2 23 | Info = 3 24 | Debug = 4 25 | } 26 | 27 | # Pull config if necessary to correlate logging level 28 | if (-not (Get-Variable -Name 'config' -ErrorAction SilentlyContinue)) { 29 | $configPath = Split-Path $PSScriptRoot -Parent | Join-Path -ChildPath 'config\conf.json' 30 | $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json 31 | } 32 | 33 | # If config is not found, default to info 34 | if ($config.logging.level -notin $Severities.Keys) { 35 | # Set if property exists 36 | if ($config.logging | Get-Member -Name level) { 37 | $config.logging.level = 'Info' 38 | } 39 | # Otherwise add property 40 | else { 41 | $config.logging | Add-Member -MemberType NoteProperty -Name level -Value 'Info' 42 | } 43 | 44 | # Warn if this is the first log entry 45 | if ($FirstLog) { 46 | Write-Output "$time [WARNING] Logging level unset or set incorrectly in config.json. Defaulting to info level." 47 | } 48 | } 49 | 50 | # Gets correct severity integer dependant on Tag. 51 | $Severity = $Severities[$Tag] 52 | 53 | # Gets correct severity integer dependant on severity in config. 54 | $ConfigSeverity = $Severities[$config.logging.level] 55 | 56 | if (($PSCmdlet.ShouldProcess('Output stream', 'Write log message')) -and ($ConfigSeverity -ge $Severity)) { 57 | Write-Output "$time [$($Tag.ToUpper())] $Message" 58 | } 59 | } 60 | 61 | # These functions handle the initiation and termination of transcript logging. 62 | function Start-Logging { 63 | [CmdletBinding( 64 | SupportsShouldProcess, 65 | ConfirmImpact = 'Low' 66 | )] 67 | param( 68 | [Parameter(Mandatory)] 69 | [String]$Path 70 | ) 71 | if ($PSCmdlet.ShouldProcess($Path, 'Start-Transcript')) { 72 | try { 73 | Start-Transcript -Path $Path -Force -Append | Out-Null 74 | Write-LogMessage -Tag 'INFO' -Message "Transcript is being logged to '$Path'." -FirstLog 75 | } 76 | catch [System.IO.IOException] { 77 | Write-LogMessage -Tag 'INFO' -Message "Transcript start attemped but transcript is already being logged to '$Path'." 78 | } 79 | } 80 | } 81 | 82 | function Stop-Logging { 83 | [CmdletBinding( 84 | SupportsShouldProcess, 85 | ConfirmImpact = 'Low' 86 | )] 87 | param() 88 | if ($PSCmdlet.ShouldProcess('log file', 'Stop-Transcript')) { 89 | Write-LogMessage -Tag 'INFO' -Message 'Stopping transcript logging.' 90 | try { 91 | Stop-Transcript 92 | } 93 | catch { 94 | Write-LogMessage -Tag 'ERROR' -Message 'Failed to stop transcript logging.' 95 | } 96 | } 97 | } 98 | 99 | function Remove-OldLogs { 100 | [CmdletBinding( 101 | SupportsShouldProcess, 102 | ConfirmImpact = 'Low' 103 | )] 104 | param( 105 | [Parameter(Mandatory)] 106 | [String]$Path, 107 | [Parameter(Mandatory)] 108 | [int]$MaxAgeDays 109 | ) 110 | 111 | if ($PSCmdlet.ShouldProcess($Path, 'Remove expired log files')) { 112 | 113 | Write-LogMessage -Tag 'DEBUG' -Message "Searching for log files older than $MaxAgeDays days." 114 | 115 | $oldLogs = (Get-ChildItem $Path | Where-Object { $_.CreationTime -lt (Get-Date).AddDays(-$MaxAgeDays)}) 116 | 117 | if ($($oldLogs.Count) -ne 0) { 118 | Write-LogMessage -Tag 'DEBUG' -Message "Found $($oldLogs.Count) log files to remove." 119 | try { 120 | $oldLogs | Remove-Item -Force -Verbose:$VerbosePreference 121 | Write-LogMessage -Tag 'INFO' -Message "Removed $($oldLogs.Count) expired log files." 122 | } 123 | catch { 124 | Write-LogMessage -Tag 'ERROR' -Message 'Failed to remove some/all log files.' 125 | } 126 | } 127 | else { 128 | Write-LogMessage -Tag 'DEBUG' -Message 'Found 0 logs files exceeding retention period.' 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /resources/New-OrderedDictionary.psm1: -------------------------------------------------------------------------------- 1 | function New-OrderedDictionary { 2 | [CmdletBinding()] 3 | [OutputType([System.Collections.Specialized.OrderedDictionary])] 4 | param ( 5 | [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] 6 | [System.Collections.Specialized.OrderedDictionary]$InputDictionary 7 | ) 8 | 9 | process { 10 | $clone = [ordered]@{} 11 | $InputDictionary.GetEnumerator() | ForEach-Object { 12 | $clone.Add($_.Key, $_.Value) 13 | } 14 | return [System.Collections.Specialized.OrderedDictionary]$clone 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /resources/NotificationBuilder.psm1: -------------------------------------------------------------------------------- 1 | function New-Payload { 2 | [CmdletBinding()] 3 | [OutputType([PSCustomObject])] 4 | param ( 5 | [Parameter(Mandatory)] 6 | [ValidateSet('Discord', 'Slack', 'Teams', 'Telegram', 'HTTP')] 7 | [string]$Service, 8 | 9 | [Parameter(Mandatory)] 10 | [System.Collections.Specialized.OrderedDictionary]$Parameters 11 | ) 12 | 13 | switch ($Service) { 14 | 'Discord' { 15 | New-DiscordPayload @Parameters 16 | } 17 | 'Slack' { 18 | New-SlackPayload @Parameters 19 | } 20 | 'Teams' { 21 | New-TeamsPayload @Parameters 22 | } 23 | 'Telegram' { 24 | New-TelegramPayload @Parameters 25 | } 26 | 'HTTP' { 27 | New-HttpPayload -Parameters $Parameters 28 | } 29 | default { 30 | Write-LogMessage -Tag 'ERROR' -Message "Unknown service: $Service" 31 | } 32 | } 33 | } 34 | 35 | function New-DiscordPayload { 36 | [CmdletBinding()] 37 | [OutputType([PSCustomObject])] 38 | param ( 39 | [string]$JobName, 40 | [string]$JobType, 41 | [string]$Status, 42 | [string]$DataSize, 43 | [string]$TransferSize, 44 | [string]$ProcessedSize, 45 | [int]$DedupRatio, 46 | [int]$CompressRatio, 47 | [string]$Speed, 48 | [string]$Bottleneck, 49 | [string]$Duration, 50 | [DateTime]$StartTime, 51 | [DateTime]$EndTime, 52 | [boolean]$Mention, 53 | [string]$UserId, 54 | [string]$ThumbnailUrl, 55 | [string]$FooterMessage, 56 | [boolean]$NotifyUpdate, 57 | [boolean]$UpdateAvailable, 58 | [string]$LatestVersion 59 | ) 60 | 61 | # Set Discord timestamps 62 | $timestampStart = "" 63 | $timestampEnd = "" 64 | 65 | # Switch for the session status to decide the embed colour. 66 | switch ($Status) { 67 | None { $colour = '16777215' } 68 | Warning { $colour = '16776960' } 69 | Success { $colour = '65280' } 70 | Failed { $colour = '16711680' } 71 | default { $colour = '16777215' } 72 | } 73 | 74 | # Build footer object. 75 | $footerObject = [PSCustomObject]@{ 76 | text = $FooterMessage 77 | icon_url = 'https://avatars0.githubusercontent.com/u/10629864' 78 | } 79 | 80 | ## Build thumbnail object. 81 | $thumbObject = [PSCustomObject]@{ 82 | url = $ThumbnailUrl 83 | } 84 | 85 | # Build field object. 86 | 87 | # TODO look furhter into what detail can be pulled out that is tape specefic, eg tapes used etc, requires splitting out payload creation 88 | if ($JobType.EndsWith('Agent Backup') -or $JobType.EndsWith('Tape Backup')) { 89 | $fieldArray = @( 90 | [PSCustomObject]@{ 91 | name = 'Processed Size' 92 | value = $ProcessedSize 93 | inline = 'true' 94 | } 95 | [PSCustomObject]@{ 96 | name = 'Transferred Data' 97 | value = $TransferSize 98 | inline = 'true' 99 | } 100 | [PSCustomObject]@{ 101 | name = 'Processing Rate' 102 | value = $Speed 103 | inline = 'true' 104 | } 105 | [PSCustomObject]@{ 106 | name = 'Bottleneck' 107 | value = $Bottleneck 108 | inline = 'true' 109 | } 110 | [PSCustomObject]@{ 111 | name = 'Start Time' 112 | value = $timestampStart 113 | inline = 'true' 114 | } 115 | [PSCustomObject]@{ 116 | name = 'End Time' 117 | value = $timestampEnd 118 | inline = 'true' 119 | } 120 | [PSCustomObject]@{ 121 | name = 'Duration' 122 | value = $Duration 123 | inline = 'true' 124 | } 125 | ) 126 | } 127 | else { 128 | $fieldArray = @( 129 | [PSCustomObject]@{ 130 | name = 'Backup Size' 131 | value = $DataSize 132 | inline = 'true' 133 | }, 134 | [PSCustomObject]@{ 135 | name = 'Transferred Data' 136 | value = $TransferSize 137 | inline = 'true' 138 | } 139 | [PSCustomObject]@{ 140 | name = 'Dedup Ratio' 141 | value = $DedupRatio 142 | inline = 'true' 143 | } 144 | [PSCustomObject]@{ 145 | name = 'Compression Ratio' 146 | value = $CompressRatio 147 | inline = 'true' 148 | } 149 | [PSCustomObject]@{ 150 | name = 'Processing Rate' 151 | value = $Speed 152 | inline = 'true' 153 | } 154 | [PSCustomObject]@{ 155 | name = 'Bottleneck' 156 | value = $Bottleneck 157 | inline = 'true' 158 | } 159 | [PSCustomObject]@{ 160 | name = 'Start Time' 161 | value = $timestampStart 162 | inline = 'true' 163 | } 164 | [PSCustomObject]@{ 165 | name = 'End Time' 166 | value = $timestampEnd 167 | inline = 'true' 168 | } 169 | [PSCustomObject]@{ 170 | name = 'Duration' 171 | value = $Duration 172 | inline = 'true' 173 | } 174 | ) 175 | } 176 | 177 | # Build payload object. 178 | [PSCustomObject]$payload = @{ 179 | embeds = @( 180 | [PSCustomObject]@{ 181 | title = $JobName 182 | description = "**Session result:** $Status`n**Job type:** $JobType" 183 | color = $Colour 184 | thumbnail = $thumbObject 185 | fields = $fieldArray 186 | footer = $footerObject 187 | timestamp = $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffK')) 188 | } 189 | ) 190 | } 191 | 192 | # Mention user if configured to do so. 193 | if ($mention) { 194 | $payload += @{ 195 | content = "<@!$($UserId)> Job $($Status.ToLower())!" 196 | } 197 | } 198 | 199 | # Add update notice if relevant and configured to do so. 200 | if ($UpdateAvailable -and $NotifyUpdate) { 201 | # Add embed to payload. 202 | $payload.embeds += @( 203 | @{ 204 | title = 'Update Available' 205 | description = "A new version of VeeamNotify is available!`n[See release **$LatestVersion** on GitHub](https://github.com/tigattack/VeeamNotify/releases/$LatestVersion)." 206 | color = 3429867 207 | footer = $footerObject 208 | timestamp = $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffK')) 209 | } 210 | ) 211 | } 212 | 213 | # Return payload object. 214 | return $payload 215 | } 216 | 217 | function New-TeamsPayload { 218 | [CmdletBinding()] 219 | [OutputType([PSCustomObject])] 220 | param ( 221 | [string]$JobName, 222 | [string]$JobType, 223 | [string]$Status, 224 | [string]$DataSize, 225 | [string]$TransferSize, 226 | [string]$ProcessedSize, 227 | [int]$DedupRatio, 228 | [int]$CompressRatio, 229 | [string]$Speed, 230 | [string]$Bottleneck, 231 | [string]$Duration, 232 | [DateTime]$StartTime, 233 | [DateTime]$EndTime, 234 | [boolean]$Mention, 235 | [string]$UserId, 236 | [string]$UserName, 237 | [string]$ThumbnailUrl, 238 | [string]$FooterMessage, 239 | [boolean]$NotifyUpdate, 240 | [boolean]$UpdateAvailable, 241 | [string]$LatestVersion 242 | ) 243 | 244 | # Define username 245 | if (-not $UserName) { 246 | $UserName = $($UserId.Split('@')[0]) 247 | } 248 | 249 | # Mention user if configured to do so. 250 | # Must be done at early stage to ensure this section is at the top of the embed object. 251 | if ($mention) { 252 | $bodyArray = @( 253 | @{ 254 | type = 'TextBlock' 255 | text = "$UserName Job $($Status.ToLower())!" 256 | wrap = $true 257 | } 258 | ) 259 | } 260 | else { 261 | $bodyArray = @() 262 | } 263 | 264 | # Set timestamps 265 | $timestampStart = $(Get-Date $StartTime -UFormat '%d %B %Y %R').ToString() 266 | $timestampEnd = $(Get-Date $EndTime -UFormat '%d %B %Y %R').ToString() 267 | 268 | # Add embedded URL to footer message 269 | $FooterMessage = $FooterMessage.Replace( 270 | 'VeeamNotify', 271 | '[VeeamNotify](https://github.com/tigattack/VeeamNotify)' 272 | ) 273 | 274 | # Add URL to update notice if relevant and configured to do so. 275 | if ($UpdateAvailable -and $NotifyUpdate) { 276 | # Add URL to update notice. 277 | $FooterMessage += " `n[See release **$LatestVersion** on GitHub.](https://github.com/tigattack/VeeamNotify/releases/$LatestVersion)" 278 | } 279 | 280 | # Add header information to body array 281 | $bodyArray += @( 282 | @{ type = 'ColumnSet'; columns = @( 283 | @{ type = 'Column'; width = 'stretch'; items = @( 284 | @{ 285 | type = 'TextBlock' 286 | text = "**$jobName**" 287 | wrap = $true 288 | spacing = 'None' 289 | } 290 | @{ 291 | type = 'TextBlock' 292 | text = [System.Web.HttpUtility]::HtmlEncode((Get-Date -UFormat '%d %B %Y %R').ToString()) 293 | wrap = $true 294 | isSubtle = $true 295 | spacing = 'None' 296 | } 297 | @{ type = 'FactSet'; facts = @( 298 | @{ 299 | title = 'Session Result' 300 | value = "$Status" 301 | } 302 | @{ 303 | title = 'Job Type' 304 | value = "$jobType" 305 | } 306 | ) 307 | spacing = 'Small' 308 | } 309 | ) 310 | } 311 | @{ type = 'Column'; width = 'auto'; items = @( 312 | @{ 313 | type = 'Image' 314 | url = "$thumbnailUrl" 315 | height = '80px' 316 | } 317 | ) 318 | } 319 | ) 320 | } 321 | ) 322 | 323 | # Add job information to body array 324 | if (-not ($JobType.EndsWith('Agent Backup'))) { 325 | $bodyArray += @( 326 | @{ type = 'ColumnSet'; columns = @( 327 | @{ type = 'Column'; width = 'stretch'; items = @( 328 | @{ 329 | type = 'FactSet' 330 | facts = @( 331 | @{ 332 | title = 'Backup size' 333 | value = "$DataSize" 334 | } 335 | @{ 336 | title = 'Transferred data' 337 | value = "$transferSize" 338 | } 339 | @{ 340 | title = 'Dedup ratio' 341 | value = "$DedupRatio" 342 | } 343 | @{ 344 | title = 'Compress ratio' 345 | value = "$CompressRatio" 346 | } 347 | @{ 348 | title = 'Processing rate' 349 | value = "$Speed" 350 | } 351 | ) 352 | } 353 | ) 354 | } 355 | @{ type = 'Column'; width = 'stretch'; items = @( 356 | @{ type = 'FactSet'; facts = @( 357 | @{ 358 | title = 'Bottleneck' 359 | value = "$Bottleneck" 360 | } 361 | @{ 362 | title = 'Start Time' 363 | value = [System.Web.HttpUtility]::HtmlEncode($timestampStart) 364 | } 365 | @{ 366 | title = 'End Time' 367 | value = [System.Web.HttpUtility]::HtmlEncode($timestampEnd) 368 | } 369 | @{ 370 | title = 'Duration' 371 | value = "$Duration" 372 | } 373 | ) 374 | } 375 | ) 376 | } 377 | ) 378 | } 379 | ) 380 | } 381 | 382 | elseif ($JobType.EndsWith('Agent Backup') -or $JobType.EndsWith('Tape Backup')) { 383 | $bodyArray += @( 384 | @{ type = 'ColumnSet'; columns = @( 385 | @{ type = 'Column'; width = 'stretch'; items = @( 386 | @{ 387 | type = 'FactSet' 388 | facts = @( 389 | @{ 390 | title = 'Processed Size' 391 | value = "$ProcessedSize" 392 | } 393 | @{ 394 | title = 'Transferred Data' 395 | value = "$transferSize" 396 | } 397 | @{ 398 | title = 'Processing rate' 399 | value = "$Speed" 400 | } 401 | @{ 402 | title = 'Bottleneck' 403 | value = "$Bottleneck" 404 | } 405 | ) 406 | } 407 | ) 408 | } 409 | @{ type = 'Column'; width = 'stretch'; items = @( 410 | @{ type = 'FactSet'; facts = @( 411 | @{ 412 | title = 'Start Time' 413 | value = [System.Web.HttpUtility]::HtmlEncode($timestampStart) 414 | } 415 | @{ 416 | title = 'End Time' 417 | value = [System.Web.HttpUtility]::HtmlEncode($timestampEnd) 418 | } 419 | @{ 420 | title = 'Duration' 421 | value = "$Duration" 422 | } 423 | ) 424 | } 425 | ) 426 | } 427 | ) 428 | } 429 | ) 430 | } 431 | 432 | # Add footer information to the body array 433 | $bodyArray += @( 434 | @{ type = 'ColumnSet'; separator = $true ; columns = @( 435 | @{ type = 'Column'; width = 'auto'; items = @( 436 | @{ 437 | type = 'Image' 438 | url = 'https://avatars0.githubusercontent.com/u/10629864' 439 | height = '20px' 440 | } 441 | ) 442 | } 443 | @{ type = 'Column'; width = 'stretch'; items = @( 444 | @{ 445 | type = 'TextBlock' 446 | text = "$FooterMessage" 447 | wrap = $true 448 | isSubtle = $true 449 | } 450 | ) 451 | } 452 | ) 453 | } 454 | ) 455 | 456 | [PSCustomObject]$payload = @{ 457 | type = 'message' 458 | attachments = @( 459 | @{ 460 | contentType = 'application/vnd.microsoft.card.adaptive' 461 | contentUrl = $null 462 | content = @{ 463 | '$schema' = 'http://adaptivecards.io/schemas/adaptive-card.json' 464 | type = 'AdaptiveCard' 465 | version = '1.4' 466 | body = $bodyArray 467 | } 468 | } 469 | ) 470 | } 471 | 472 | # Mention user if configured to do so. 473 | # Must be done at early stage to ensure this section is at the top of the embed object. 474 | if ($mention) { 475 | $payload.attachments[0].content += @{ 476 | msteams = @{ 477 | entities = @( 478 | @{ 479 | type = 'mention' 480 | text = "$UserName" 481 | mentioned = @{ 482 | id = "$UserId" 483 | name = "$UserName" 484 | } 485 | } 486 | ) 487 | } 488 | } 489 | } 490 | 491 | return $payload 492 | } 493 | 494 | function New-SlackPayload { 495 | [CmdletBinding()] 496 | [OutputType([PSCustomObject])] 497 | param ( 498 | [string]$JobName, 499 | [string]$JobType, 500 | [string]$Status, 501 | [string]$DataSize, 502 | [string]$TransferSize, 503 | [string]$ProcessedSize, 504 | [int]$DedupRatio, 505 | [int]$CompressRatio, 506 | [string]$Speed, 507 | [string]$Bottleneck, 508 | [string]$Duration, 509 | [DateTime]$StartTime, 510 | [DateTime]$EndTime, 511 | [boolean]$Mention, 512 | [string]$UserId, 513 | [string]$ThumbnailUrl, 514 | [string]$FooterMessage, 515 | [boolean]$NotifyUpdate, 516 | [boolean]$UpdateAvailable, 517 | [string]$LatestVersion 518 | ) 519 | 520 | [PSCustomObject]$payload = @{ 521 | blocks = @() 522 | } 523 | 524 | # Mention user if configured to do so. 525 | # Must be done at early stage to ensure this section is at the top of the embed object. 526 | if ($mention) { 527 | $payload.blocks = @( 528 | @{ 529 | type = 'section' 530 | text = @{ 531 | type = 'mrkdwn' 532 | text = "<@$UserId> Job $($Status.ToLower())!" 533 | } 534 | } 535 | ) 536 | } 537 | 538 | # Set timestamps 539 | $timestampStart = $(Get-Date $StartTime -UFormat '%d %B %Y %R').ToString() 540 | $timestampEnd = $(Get-Date $EndTime -UFormat '%d %B %Y %R').ToString() 541 | 542 | # Build blocks object. 543 | if (-not ($JobType.EndsWith('Agent Backup'))) { 544 | $fieldArray = @( 545 | [PSCustomObject]@{ 546 | type = 'mrkdwn' 547 | text = "*Backup Size*`n$DataSize" 548 | }, 549 | [PSCustomObject]@{ 550 | type = 'mrkdwn' 551 | text = "*Transferred Data*`n$TransferSize" 552 | } 553 | [PSCustomObject]@{ 554 | type = 'mrkdwn' 555 | text = "*Dedup Ratio*`n$DedupRatio" 556 | } 557 | [PSCustomObject]@{ 558 | type = 'mrkdwn' 559 | text = "*Compression Ratio*`n$CompressRatio" 560 | } 561 | [PSCustomObject]@{ 562 | type = 'mrkdwn' 563 | text = "*Processing Rate*`n$Speed" 564 | } 565 | [PSCustomObject]@{ 566 | type = 'mrkdwn' 567 | text = "*Bottleneck*`n$Bottleneck" 568 | } 569 | [PSCustomObject]@{ 570 | type = 'mrkdwn' 571 | text = "*Start Time*`n$timestampStart" 572 | } 573 | [PSCustomObject]@{ 574 | type = 'mrkdwn' 575 | text = "*End Time*`n$timestampEnd" 576 | } 577 | [PSCustomObject]@{ 578 | type = 'mrkdwn' 579 | text = "*Duration*`n$Duration" 580 | } 581 | ) 582 | } 583 | 584 | elseif ($JobType.EndsWith('Agent Backup') -or $JobType.EndsWith('Tape Backup')) { 585 | $fieldArray += @( 586 | [PSCustomObject]@{ 587 | type = 'mrkdwn' 588 | text = "*Processed Size*`n$ProcessedSize" 589 | } 590 | [PSCustomObject]@{ 591 | type = 'mrkdwn' 592 | text = "*Transferred Data*`n$TransferSize" 593 | } 594 | [PSCustomObject]@{ 595 | type = 'mrkdwn' 596 | text = "*Processing Rate*`n$Speed" 597 | } 598 | [PSCustomObject]@{ 599 | type = 'mrkdwn' 600 | text = "*Bottleneck*`n$Bottleneck" 601 | } 602 | [PSCustomObject]@{ 603 | type = 'mrkdwn' 604 | text = "*Start Time*`n$timestampStart" 605 | } 606 | [PSCustomObject]@{ 607 | type = 'mrkdwn' 608 | text = "*End Time*`n$timestampEnd" 609 | } 610 | [PSCustomObject]@{ 611 | type = 'mrkdwn' 612 | text = "*Duration*`n$Duration" 613 | } 614 | ) 615 | } 616 | 617 | # Build payload object. 618 | [PSCustomObject]$payload.blocks += @( 619 | @{ 620 | type = 'section' 621 | text = @{ 622 | type = 'mrkdwn' 623 | text = "*$JobName*`n`n*Session result:* $Status`n*Job type:* $JobType" 624 | } 625 | accessory = @{ 626 | type = 'image' 627 | image_url = "$ThumbnailUrl" 628 | alt_text = 'Veeam Backup & Replication logo' 629 | } 630 | } 631 | @{ 632 | type = 'section' 633 | fields = $fieldArray 634 | } 635 | ) 636 | 637 | # Add footer to payload object. 638 | $payload.blocks += @( 639 | @{ 640 | type = 'divider' 641 | } 642 | @{ 643 | type = 'context' 644 | elements = @( 645 | @{ 646 | type = 'image' 647 | image_url = 'https://avatars0.githubusercontent.com/u/10629864' 648 | alt_text = "tigattack's avatar" 649 | } 650 | @{ 651 | type = 'plain_text' 652 | text = $FooterMessage 653 | } 654 | ) 655 | } 656 | ) 657 | 658 | # Add update notice if relevant and configured to do so. 659 | if ($UpdateAvailable -and $NotifyUpdate) { 660 | # Add block to payload. 661 | $payload.blocks += @( 662 | @{ 663 | type = 'section' 664 | text = @{ 665 | type = 'mrkdwn' 666 | text = "A new version of VeeamNotify is available! See release *$LatestVersion* on GitHub." 667 | } 668 | accessory = @{ 669 | type = 'button' 670 | text = @{ 671 | type = 'plain_text' 672 | text = 'Open on GitHub' 673 | } 674 | value = 'open_github' 675 | url = "https://github.com/tigattack/VeeamNotify/releases/$LatestVersion" 676 | action_id = 'button-action' 677 | } 678 | } 679 | ) 680 | } 681 | 682 | # Remove obsolete extended-type system properties added by Add-Member ($payload.blocks +=) 683 | # https://stackoverflow.com/a/57599481 684 | Remove-TypeData System.Array -ErrorAction Ignore 685 | 686 | return $payload 687 | } 688 | 689 | function New-TelegramPayload { 690 | [CmdletBinding()] 691 | [OutputType([PSCustomObject])] 692 | [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( 693 | 'PSReviewUnusedParameter', 694 | 'ThumbnailUrl', 695 | Justification = 'ThumbnailUrl is part of standard notification parameters' 696 | )] 697 | param ( 698 | [ValidateNotNullOrEmpty()] 699 | [string]$ChatId, 700 | [string]$JobName, 701 | [string]$JobType, 702 | [string]$Status, 703 | [string]$DataSize, 704 | [string]$TransferSize, 705 | [string]$ProcessedSize, 706 | [int]$DedupRatio, 707 | [int]$CompressRatio, 708 | [string]$Speed, 709 | [string]$Bottleneck, 710 | [string]$Duration, 711 | [DateTime]$StartTime, 712 | [DateTime]$EndTime, 713 | [boolean]$Mention, 714 | [string]$UserName, 715 | [string]$ThumbnailUrl, 716 | [string]$FooterMessage, 717 | [boolean]$NotifyUpdate, 718 | [boolean]$UpdateAvailable, 719 | [string]$LatestVersion 720 | ) 721 | 722 | function escapeTGChars { 723 | [CmdletBinding()] 724 | [OutputType([string])] 725 | param ([Parameter(Mandatory)][string]$text) 726 | # Escape characters for Telegram MarkdownV2 727 | # https://core.telegram.org/bots/api#markdownv2-style 728 | foreach ($char in '_', '[', ']', '(', ')', '~', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!') { 729 | $text = $text.Replace("$char", "\$char") 730 | } 731 | return $text 732 | } 733 | 734 | $PSBoundParameters.GetEnumerator() | ForEach-Object { 735 | # Escape characters for Telegram MarkdownV2 736 | if ($_.Value -is [string]) { 737 | Set-Variable -Name $_.Key -Value (escapeTGChars $_.Value) -Scope 0 738 | } 739 | } 740 | 741 | # Build user mention string 742 | $mentionStr = '' 743 | if ($Mention) { 744 | $mentionStr = "[$UserName](tg://user?id=$ChatId) Job $($Status.ToLower())\!`n`n" 745 | } 746 | 747 | # Set timestamps 748 | $timestampStart = $(Get-Date $StartTime -UFormat '%d %B %Y %R').ToString() 749 | $timestampEnd = $(Get-Date $EndTime -UFormat '%d %B %Y %R').ToString() 750 | 751 | # Build session info string 752 | $sessionInfo = @" 753 | *$JobName* 754 | 755 | *Session result:* $Status 756 | *Job type:* $JobType 757 | 758 | "@ 759 | 760 | if (-not ($JobType.EndsWith('Agent Backup'))) { 761 | $sessionInfo += @" 762 | *Backup Size:* $DataSize 763 | *Transferred Data:* $TransferSize 764 | *Dedup Ratio:* $DedupRatio 765 | *Compression Ratio:* $CompressRatio 766 | *Processing Rate:* $Speed 767 | *Bottleneck:* $Bottleneck 768 | *Start Time:* $timestampStart 769 | *End Time:* $timestampEnd 770 | *Duration:* $Duration 771 | "@ 772 | } 773 | 774 | elseif ($JobType.EndsWith('Agent Backup') -or $JobType.EndsWith('Tape Backup')) { 775 | $sessionInfo += @" 776 | *Processed Size:* $ProcessedSize 777 | *Transferred Data:* $TransferSize 778 | *Processing Rate:* $Speed 779 | *Bottleneck:* $Bottleneck 780 | *Start Time:* $timestampStart 781 | *End Time:* $timestampEnd 782 | *Duration:* $Duration 783 | "@ 784 | } 785 | 786 | # Add update notice if required 787 | if ($UpdateAvailable -and $NotifyUpdate) { 788 | $message += "`nA new version of VeeamNotify is available! See release [*$LatestVersion* on GitHub](https://github.com/tigattack/VeeamNotify/releases/$LatestVersion)\." 789 | } 790 | 791 | # Compile message parts 792 | $message = $mentionStr + $sessionInfo + "`n`n$FooterMessage" 793 | 794 | [PSCustomObject]$payload = @{ 795 | chat_id = $ChatId 796 | parse_mode = 'MarkdownV2' 797 | text = $message 798 | } 799 | 800 | return $payload 801 | } 802 | 803 | function New-HttpPayload { 804 | [CmdletBinding()] 805 | [OutputType([PSCustomObject])] 806 | param ( 807 | [Parameter(Mandatory)] 808 | [System.Collections.Specialized.OrderedDictionary]$Parameters 809 | ) 810 | 811 | $params = New-OrderedDictionary -InputDictionary $Parameters 812 | 813 | # Set timestamps 814 | $params.StartTime = ([System.DateTimeOffset]$(Get-Date $params.StartTime)).ToUnixTimeSeconds() 815 | $params.EndTime = ([System.DateTimeOffset]$(Get-Date $params.EndTime)).ToUnixTimeSeconds() 816 | 817 | # Drop unwanted parameters for HTTP 818 | $( 819 | 'Mention', 820 | 'NotifyUpdate', 821 | 'ThumbnailUrl', 822 | 'FooterMessage' 823 | ) | ForEach-Object { $params.Remove($_) } 824 | 825 | return $params 826 | } 827 | -------------------------------------------------------------------------------- /resources/NotificationSender.psm1: -------------------------------------------------------------------------------- 1 | # Relies on functions from NotificationBuilder.psm1 2 | 3 | class NotificationResult { 4 | [ValidateNotNullOrEmpty()][bool]$Success 5 | [string]$Message 6 | [hashtable]$Detail 7 | } 8 | 9 | function Send-Payload { 10 | [CmdletBinding()] 11 | [OutputType([NotificationResult])] 12 | param ( 13 | [Parameter(Mandatory)] 14 | [String]$Uri, 15 | [Parameter(ValueFromPipeline)] 16 | [PSCustomObject]$Payload, 17 | [String]$Method = 'Post' 18 | ) 19 | 20 | begin { 21 | # Referencing these directly in the $psVersion string didn't work, hence this. 22 | $psMajor = $PSVersionTable.PSVersion.Major 23 | $psMinor = $PSVersionTable.PSVersion.Minor 24 | $psVersion = "${psMajor}.${psMinor}" 25 | } 26 | 27 | process { 28 | 29 | $postParams = @{ 30 | Uri = $Uri 31 | Method = $Method 32 | UserAgent = "VeeamNotify; PowerShell/$psVersion" 33 | ErrorAction = 'Stop' 34 | } 35 | 36 | if ($Method -eq 'Post' -and $Payload) { 37 | $postParams.Body = $Payload | ConvertTo-Json -Depth 10 38 | $postParams.ContentType = 'application/json' 39 | } 40 | 41 | try { 42 | $request = Invoke-RestMethod @postParams 43 | return [NotificationResult]@{ 44 | Success = $true 45 | Message = $request 46 | } 47 | } 48 | catch { 49 | return [NotificationResult]@{ 50 | Success = $false 51 | Message = 'Unable to send payload' 52 | Detail = @{ 53 | StatusCode = $_.Exception.Response.StatusCode.value__ 54 | StatusDescription = $_.Exception.Response.StatusDescription 55 | Message = $_.ErrorDetails.Message 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | function Send-WebhookNotification { 63 | [CmdletBinding()] 64 | [OutputType([NotificationResult])] 65 | param ( 66 | [Parameter(Mandatory)] 67 | [ValidateSet('Discord', 'Slack', 'Teams')] 68 | [string]$Service, 69 | [Parameter(Mandatory)] 70 | [System.Collections.Specialized.OrderedDictionary]$Parameters, 71 | [Parameter(Mandatory)] 72 | [PSCustomObject]$ServiceConfig 73 | ) 74 | 75 | $params = New-OrderedDictionary -InputDictionary $Parameters 76 | 77 | # Return early if webhook is not configured or appears incorrect 78 | if (-not $ServiceConfig.webhook -or -not $ServiceConfig.webhook.StartsWith('http')) { 79 | return [NotificationResult]@{ 80 | Success = $false 81 | Message = "$Service is unconfigured (invalid URL). Skipping $Service notification." 82 | } 83 | } 84 | 85 | # Check if user should be mentioned 86 | try { 87 | if ($params.Mention) { 88 | $params.UserId = $ServiceConfig.user_id 89 | 90 | # Set username if exists (Teams specific) 91 | $teamsDisplayName = if ($ServiceConfig.display_name) { $ServiceConfig.display_name } else { $ServiceConfig.user_name } 92 | if ($Service -eq 'Teams' -and $teamsDisplayName -and $teamsDisplayName -ne 'Your Name') { 93 | $params.UserName = $teamsDisplayName 94 | } 95 | } 96 | } 97 | catch { 98 | return [NotificationResult]@{ 99 | Success = $false 100 | Message = 'Unable to add user information for mention' 101 | Detail = @{ 102 | Message = $_.Exception.Message 103 | } 104 | } 105 | } 106 | 107 | # Create payload and send notification 108 | try { 109 | $response = New-Payload -Service $Service -Parameters $params | Send-Payload -Uri $ServiceConfig.webhook 110 | return $response 111 | } 112 | catch { 113 | return [NotificationResult]@{ 114 | Success = $false 115 | Message = "Unable to send $Service notification" 116 | Detail = @{ 117 | Message = $_.Exception.Message 118 | } 119 | } 120 | } 121 | } 122 | 123 | function Send-TelegramNotification { 124 | [CmdletBinding()] 125 | [OutputType([NotificationResult])] 126 | param ( 127 | [Parameter(Mandatory)] 128 | [System.Collections.Specialized.OrderedDictionary]$Parameters, 129 | [Parameter(Mandatory)] 130 | [PSCustomObject]$ServiceConfig 131 | ) 132 | 133 | $params = New-OrderedDictionary -InputDictionary $Parameters 134 | 135 | # Return early if bot token or chat ID is not configured or appears incorrect 136 | if ($ServiceConfig.bot_token -eq 'TelegramBotToken' -or $ServiceConfig.chat_id -eq 'TelegramChatID') { 137 | return [NotificationResult]@{ 138 | Success = $false 139 | Message = 'Telegram is unconfigured (invalid bot_token or chat_id). Skipping Telegram notification.' 140 | } 141 | } 142 | 143 | # Check if user should be mentioned 144 | try { 145 | if ($params.Mention) { 146 | $params.UserName = $ServiceConfig.user_name 147 | } 148 | } 149 | catch { 150 | return [NotificationResult]@{ 151 | Success = $false 152 | Message = 'Unable to add user information for mention' 153 | Detail = @{ 154 | Message = $_.Exception.Message 155 | } 156 | } 157 | } 158 | 159 | # Create payload and send notification 160 | try { 161 | $uri = "https://api.telegram.org/bot$($ServiceConfig.bot_token)/sendMessage" 162 | $params.ChatId = $ServiceConfig.chat_id 163 | $response = New-Payload -Service 'Telegram' -Parameters $params | Send-Payload -Uri $uri 164 | return $response 165 | } 166 | catch { 167 | return [NotificationResult]@{ 168 | Success = $false 169 | Message = 'Unable to send Telegram notification' 170 | Detail = @{ 171 | Message = $_.Exception.Message 172 | } 173 | } 174 | } 175 | } 176 | 177 | function Send-HttpNotification { 178 | [CmdletBinding()] 179 | [OutputType([NotificationResult])] 180 | param ( 181 | [Parameter(Mandatory)] 182 | [System.Collections.Specialized.OrderedDictionary]$Parameters, 183 | [Parameter(Mandatory)] 184 | [PSCustomObject]$ServiceConfig 185 | ) 186 | 187 | # Return early if URL is not configured or appears incorrect 188 | if (-not $ServiceConfig.url -or -not $ServiceConfig.url.StartsWith('http')) { 189 | return [NotificationResult]@{ 190 | Success = $false 191 | Message = 'HTTP service is unconfigured (invalid URL). Skipping HTTP notification.' 192 | } 193 | } 194 | 195 | try { 196 | $payloadParams = @{ 197 | Uri = $ServiceConfig.url 198 | Method = $ServiceConfig.method 199 | } 200 | 201 | if ($ServiceConfig.method.ToLower() -eq 'post') { 202 | $payloadParams.Payload = New-Payload -Service 'HTTP' -Parameters $Parameters 203 | } 204 | 205 | $response = Send-Payload @payloadParams 206 | return $response 207 | } 208 | catch { 209 | return [NotificationResult]@{ 210 | Success = $false 211 | Message = 'Unable to send HTTP notification' 212 | Detail = @{ 213 | Message = $_.Exception.Message 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /resources/Test-FileLock.psm1: -------------------------------------------------------------------------------- 1 | class FileLockResult { 2 | [ValidateNotNullOrEmpty()][bool]$IsLocked 3 | [ValidateNotNullOrEmpty()][string]$File 4 | } 5 | 6 | # I believe the source of the original implementation of this function (since changed) was: 7 | # https://mcpmag.com/articles/2018/07/10/check-for-locked-file-using-powershell.aspx 8 | # Written by Boe Prox, Microsoft MVP. 9 | function Test-FileLock { 10 | [CmdletBinding()] 11 | [OutputType([FileLockResult])] 12 | param ( 13 | [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] 14 | [Alias('FullName', 'PSPath')] 15 | [string]$Path 16 | ) 17 | process { 18 | # Ensure this is a full path 19 | $Path = Convert-Path $Path 20 | # Verify that this is a file and not a directory 21 | if ([System.IO.File]::Exists($Path)) { 22 | try { 23 | $FileStream = [System.IO.File]::Open($Path, 'Open', 'Write') 24 | $FileStream.Close() 25 | $FileStream.Dispose() 26 | $IsLocked = $False 27 | } 28 | catch [System.UnauthorizedAccessException] { 29 | $IsLocked = 'AccessDenied' 30 | } 31 | catch { 32 | $IsLocked = $True 33 | } 34 | 35 | return [FileLockResult]@{ 36 | File = $Path 37 | IsLocked = $IsLocked 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/UpdateInfo.psm1: -------------------------------------------------------------------------------- 1 | class UpdateShouldNotifyResult { 2 | [Parameter(Mandatory)] 3 | [bool]$ShouldNotify 4 | [string]$Message 5 | } 6 | 7 | function Get-UpdateShouldNotify { 8 | [CmdletBinding()] 9 | [OutputType([UpdateShouldNotifyResult])] 10 | param ( 11 | [Parameter(Mandatory)] 12 | [PSObject]$UpdateStatus 13 | ) 14 | 15 | $result = [UpdateShouldNotifyResult]@{ 16 | ShouldNotify = $true 17 | Message = '' 18 | } 19 | 20 | # If no update is available, no need to notify 21 | if ($UpdateStatus.Status -ne 'Behind') { 22 | $result.ShouldNotify = $false 23 | $result.Message = 'No update available.' 24 | return $result 25 | } 26 | 27 | # Define marker file path 28 | $markerFilePath = "$PSScriptRoot\update-notification.marker" 29 | 30 | $currentVersion = $UpdateStatus.CurrentVersion 31 | 32 | # Check if marker file exists 33 | if (Test-Path $markerFilePath) { 34 | $versionChanged = $false 35 | $markerVersion = Get-Content -Path $markerFilePath -Raw -ErrorAction SilentlyContinue 36 | if ($null -ne $markerVersion) { 37 | # Trim version and compare with current version 38 | $markerVersion = $markerVersion.Trim() 39 | $versionChanged = $markerVersion -ne $currentVersion 40 | } 41 | 42 | $markerFile = Get-Item $markerFilePath 43 | $timeSinceLastNotification = (Get-Date) - $markerFile.LastWriteTime 44 | 45 | # If version has changed, always notify regardless of time 46 | if ($versionChanged) { 47 | $result.Message = "Version changed from $markerVersion to $currentVersion since last notification. Proceeding to notify." 48 | # Update the marker file with current version 49 | $currentVersion | Out-File -FilePath $markerFilePath -Force -NoNewline 50 | return $result 51 | } 52 | 53 | # If less than 24 hours have passed since last notification for the same version, don't notify 54 | if ($timeSinceLastNotification.TotalHours -lt 24) { 55 | $result.ShouldNotify = $false 56 | $result.Message = "Update notification suppressed. Last notification was $($timeSinceLastNotification.TotalHours.ToString('0.00')) hours ago." 57 | return $result 58 | } 59 | # If more than 24 hours have passed, proceed to notify and update the marker file contents 60 | else { 61 | $result.Message = "Update notification marker file found. Last notification was $($timeSinceLastNotification.TotalHours.ToString('0.00')) hours ago. Proceeding to notify." 62 | # Update the marker file with current version - Also updates the file's modtime as a side effect. 63 | $currentVersion | Out-File -FilePath $markerFilePath -Force -NoNewline 64 | } 65 | } 66 | else { 67 | # Create the marker file to indicate notification was sent and store current version 68 | $currentVersion | Out-File -FilePath $markerFilePath -Force -NoNewline 69 | $result.Message = "Created update notification marker file at $markerFilePath with version $currentVersion" 70 | } 71 | 72 | return $result 73 | } 74 | 75 | function Get-UpdateStatus { 76 | # Get currently downloaded version of this project. 77 | $currentVersion = (Get-Content "$PSScriptRoot\version.txt" -Raw).Trim() 78 | 79 | # Get all releases from GitHub. 80 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 81 | try { 82 | $releases = Invoke-RestMethod -Uri 'https://api.github.com/repos/tigattack/VeeamNotify/releases' -Method Get 83 | } 84 | catch { 85 | $versionStatusCode = $_.Exception.Response.StatusCode.value__ 86 | Write-LogMessage -Tag 'WARN' -Message "Failed to query GitHub for the latest version. Please check your internet connection and try again. Status code: $versionStatusCode" 87 | } 88 | 89 | if ($releases) { 90 | # Get latest stable 91 | foreach ($i in $releases) { 92 | if (-not $i.prerelease) { 93 | $latestStable = $i.tag_name 94 | break 95 | } 96 | } 97 | 98 | # Get latest prerelease 99 | foreach ($i in $releases) { 100 | if ($i.prerelease) { 101 | $latestPrerelease = $i.tag_name 102 | break 103 | } 104 | } 105 | 106 | # Determine if prerelease 107 | $prerelease = $false 108 | foreach ($i in $releases) { 109 | if ($i.tag_name -eq $currentVersion -and $i.prerelease) { 110 | $prerelease = $true 111 | break 112 | } 113 | } 114 | 115 | # Set version status 116 | if ($currentVersion -gt $latestStable) { 117 | $status = 'Ahead' 118 | } 119 | elseif ($currentVersion -lt $latestStable) { 120 | $status = 'Behind' 121 | } 122 | else { 123 | $status = 'Current' 124 | } 125 | 126 | # Create PSObject to return. 127 | $out = New-Object PSObject -Property @{ 128 | CurrentVersion = $currentVersion 129 | LatestStable = $latestStable 130 | LatestPrerelease = $latestPrerelease 131 | Prerelease = $prerelease 132 | Status = $status 133 | } 134 | } 135 | else { 136 | # Create PSObject to return. 137 | $out = New-Object PSObject -Property @{ 138 | CurrentVersion = $currentVersion 139 | } 140 | } 141 | 142 | # Return PSObject. 143 | return $out 144 | } 145 | -------------------------------------------------------------------------------- /resources/VBRSessionInfo.psm1: -------------------------------------------------------------------------------- 1 | class VBRSessionInfo { 2 | [ValidateNotNullOrEmpty()][Veeam.Backup.Core.CBackupSession]$Session 3 | [ValidateNotNullOrEmpty()][string]$JobName 4 | } 5 | 6 | function Get-VBRSessionInfo { 7 | [OutputType([VBRSessionInfo])] 8 | param ( 9 | [Parameter(Mandatory)][ValidateNotNullOrEmpty()] 10 | [string]$SessionId, 11 | [Parameter(Mandatory)][ValidateNotNullOrEmpty()] 12 | [string]$JobType 13 | ) 14 | 15 | # Import VBR module 16 | Import-Module Veeam.Backup.PowerShell -DisableNameChecking 17 | 18 | switch ($JobType) { 19 | # VM job 20 | {$_ -in 'Backup', 'Replica'} { 21 | 22 | # Get the session details. 23 | $session = Get-VBRBackupSession -Id $SessionId 24 | 25 | # Get the job's name from the session details. 26 | $jobName = $session.Name 27 | } 28 | 29 | # Agent or tape job 30 | {$_ -in 'EpAgentBackup', 'BackupToTape', 'FileToTape'} { 31 | # Fetch current session to load .NET module 32 | # It appears some of the underlying .NET items are lazy-loaded, so this is necessary 33 | # to load in whatever's required to utilise the GetByOriginalSessionId method. 34 | # See https://forums.veeam.com/powershell-f26/want-to-capture-running-jobs-by-session-type-i-e-sobr-tiering-t75583.html#p486295 35 | Get-VBRSession -Id $SessionId | Out-Null 36 | # Get the session details. 37 | $session = [Veeam.Backup.Core.CBackupSession]::GetByOriginalSessionId($SessionId) 38 | 39 | # Copy the job's name to it's own variable. 40 | if ($JobType -eq 'EpAgentBackup') { 41 | $jobName = $job.Info.Name 42 | } 43 | elseif ($JobType -in 'BackupToTape', 'FileToTape') { 44 | $jobName = $job.Name 45 | } 46 | } 47 | } 48 | 49 | # Create PSObject to return. 50 | return [VBRSessionInfo]@{ 51 | Session = $session 52 | JobName = $jobName 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /resources/lib/Newtonsoft.Json.Schema.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/resources/lib/Newtonsoft.Json.Schema.dll -------------------------------------------------------------------------------- /resources/lib/Newtonsoft.Json.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tigattack/VeeamNotify/deee5e7635bffceea137cabb5d1088d4f18923e1/resources/lib/Newtonsoft.Json.dll -------------------------------------------------------------------------------- /resources/version.txt: -------------------------------------------------------------------------------- 1 | v2.1.0 2 | -------------------------------------------------------------------------------- /tests/Installer.Tests.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [Parameter(ParameterSetName = 'Branch')] 3 | [Parameter(ParameterSetName = 'PR')] 4 | [string]$Branch, 5 | [Parameter(ParameterSetName = 'PR')] 6 | [switch]$IsPr, 7 | [Parameter(Mandatory, ParameterSetName = 'PR')] 8 | [int]$PrId 9 | ) 10 | 11 | Describe 'Installer.ps1' { 12 | BeforeAll { 13 | # Use TestDrive for installation directory 14 | $installDir = (Join-Path -Path $TestDrive -ChildPath 'VeeamNotifyInstall') 15 | New-Item -Path $installDir -ItemType Directory -Force | Out-Null 16 | 17 | # Get installer path 18 | $installerPath = (Get-ChildItem -Path (Split-Path -Path $PSScriptRoot -Parent) -Filter 'Installer.ps1').FullName 19 | 20 | # Define installer params 21 | $installerParams = @{ 22 | NonInteractive = $true 23 | InstallParentPath = $installDir 24 | } 25 | 26 | # Define critical files that should exist in any version 27 | $criticalFiles = @( 28 | '.\Bootstrap.ps1', 29 | '.\AlertSender.ps1', 30 | '.\resources\version.txt' 31 | ) 32 | 33 | # Define basic check for any installation 34 | [scriptblock]$criticalFilesCheck = { 35 | # Check that installation directory exists 36 | (Join-Path -Path "$installDir" -ChildPath 'VeeamNotify') | Should -Exist 37 | 38 | # Check for critical files that should exist in any version 39 | foreach ($file in $criticalFiles) { 40 | Join-Path -Path "$installDir" -ChildPath 'VeeamNotify' | Join-Path -ChildPath $file | Should -Exist 41 | } 42 | } 43 | 44 | # Define expected files for current repository state (for branch-based installations) 45 | $repoFiles = @( 46 | '.\Bootstrap.ps1', 47 | '.\AlertSender.ps1' 48 | ) 49 | $repoFiles += $( 50 | foreach ($dir in 'resources', 'config') { 51 | $files = Get-ChildItem -Path "$(Split-Path -Path $PSScriptRoot -Parent)\$dir" -File -Recurse 52 | foreach ($file in $files) { 53 | [string]($file | Resolve-Path -Relative) 54 | } 55 | } 56 | ) 57 | 58 | # Define required files check for branch-based installations 59 | [scriptblock]$repoFilesCheck = { 60 | foreach ($file in $repoFiles) { 61 | Join-Path -Path "$installDir" -ChildPath 'VeeamNotify' | Join-Path -ChildPath $file | Should -Exist 62 | } 63 | } 64 | } 65 | 66 | It 'Install from specific version' { 67 | $defaultVersion = 'v1.1.1' 68 | Write-Host "Installing from version: $defaultVersion" 69 | # Run installer 70 | & $installerPath -Version $defaultVersion @installerParams 71 | 72 | # Only check for critical files when installing from a specific version 73 | Invoke-Command -ScriptBlock $criticalFilesCheck 74 | } 75 | 76 | It 'Install from latest release' { 77 | Write-Host 'Installing from latest release' 78 | # Run installer 79 | & $installerPath -Latest Release @installerParams 80 | 81 | # Only check for critical files when installing from release 82 | Invoke-Command -ScriptBlock $criticalFilesCheck 83 | } 84 | 85 | It 'Install from current branch' -Skip:($IsPr -or [string]::IsNullOrWhitespace($Branch)) { 86 | Write-Host "Installing from branch: $Branch" 87 | # Call installer with branch parameter 88 | & $installerPath -Branch $Branch @installerParams 89 | 90 | # Check for expected files from current stqte 91 | Invoke-Command -ScriptBlock $repoFilesCheck 92 | } 93 | 94 | It 'Install from PR' -Skip:(-not $IsPr) { 95 | Write-Host "Installing from PR #$PrId" 96 | # Call installer with PR parameter 97 | & $installerPath -PullRequest $PrId @installerParams 98 | 99 | # Check for expected files from current stqte 100 | Invoke-Command -ScriptBlock $repoFilesCheck 101 | } 102 | 103 | AfterEach { 104 | # Remove temp install dir 105 | Remove-Item -Path $installDir -Recurse -Force 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/JsonValidator.Tests.ps1: -------------------------------------------------------------------------------- 1 | [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( 2 | 'PSUseCompatibleCommands', 3 | '', 4 | Justification = 'Pester tests are run in modern PowerShell' 5 | )] 6 | param () 7 | 8 | BeforeAll { 9 | # Import the module to test 10 | $modulePath = (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'resources\JsonValidator.psm1') 11 | Import-Module $modulePath -Force 12 | 13 | # Create a temporary directory for test files 14 | $script:tempDir = Join-Path -Path $TestDrive -ChildPath 'JsonValidatorTests' 15 | New-Item -Path $script:tempDir -ItemType Directory -Force | Out-Null 16 | } 17 | 18 | AfterAll { 19 | # Clean up temporary directory 20 | if (Test-Path -Path $script:tempDir) { 21 | Remove-Item -Path $script:tempDir -Recurse -Force -ErrorAction SilentlyContinue 22 | } 23 | } 24 | 25 | Describe 'Test-JsonValid' { 26 | Context 'Valid JSON validation' { 27 | BeforeAll { 28 | # Simple schema requiring a string name and integer age 29 | $schema = @' 30 | { 31 | "$schema": "http://json-schema.org/draft-04/schema#", 32 | "type": "object", 33 | "properties": { 34 | "name": { "type": "string" }, 35 | "age": { "type": "integer" } 36 | }, 37 | "required": ["name", "age"] 38 | } 39 | '@ 40 | 41 | # Valid JSON matching the schema 42 | $validJson = @' 43 | { 44 | "name": "John Doe", 45 | "age": 30 46 | } 47 | '@ 48 | # Create temporary files for the test 49 | $schemaPath = Join-Path -Path $script:tempDir -ChildPath 'valid-schema.json' 50 | $jsonPath = Join-Path -Path $script:tempDir -ChildPath 'valid-test.json' 51 | 52 | Set-Content -Path $schemaPath -Value $schema 53 | Set-Content -Path $jsonPath -Value $validJson 54 | 55 | $result = Test-JsonValid -JsonPath $jsonPath -SchemaPath $schemaPath 56 | } 57 | 58 | It 'Should return a JsonValidationResult object' { 59 | $result | Should -Not -Be $null 60 | # Check properties instead of type 61 | $result.PSObject.Properties.Name | Should -Contain 'IsValid' 62 | $result.PSObject.Properties.Name | Should -Contain 'Message' 63 | } 64 | 65 | It 'Should return IsValid as true for valid JSON' { 66 | $result.IsValid | Should -Be $true 67 | } 68 | 69 | It 'Should return a success message for valid JSON' { 70 | $result.Message | Should -Be 'JSON validation succeeded.' 71 | } 72 | } 73 | 74 | Context 'Invalid JSON validation' { 75 | BeforeAll { 76 | # Simple schema requiring a string name and integer age 77 | $schema = @' 78 | { 79 | "$schema": "http://json-schema.org/draft-04/schema#", 80 | "type": "object", 81 | "properties": { 82 | "name": { "type": "string" }, 83 | "age": { "type": "integer" } 84 | }, 85 | "required": ["name", "age"] 86 | } 87 | '@ 88 | 89 | # Invalid JSON - wrong type for age 90 | $invalidJson = @' 91 | { 92 | "name": "John Doe", 93 | "age": "thirty" 94 | } 95 | '@ 96 | # Create temporary files for the test 97 | $schemaPath = Join-Path -Path $script:tempDir -ChildPath 'invalid-schema.json' 98 | $jsonPath = Join-Path -Path $script:tempDir -ChildPath 'invalid-test.json' 99 | 100 | Set-Content -Path $schemaPath -Value $schema 101 | Set-Content -Path $jsonPath -Value $invalidJson 102 | 103 | $result = Test-JsonValid -JsonPath $jsonPath -SchemaPath $schemaPath 104 | } 105 | 106 | It 'Should return IsValid as false for invalid JSON' { 107 | $result.IsValid | Should -Be $false 108 | } 109 | 110 | It 'Should include error details in the Message' { 111 | $result.Message | Should -Match 'invalid' 112 | } 113 | } 114 | 115 | Context 'Malformed JSON' { 116 | BeforeAll { 117 | $schema = '{ "type": "object" }' 118 | # Malformed JSON with missing closing brace 119 | $malformedJson = '{ "name": "John Doe"' 120 | 121 | # Create temporary files for the test 122 | $schemaPath = Join-Path -Path $script:tempDir -ChildPath 'malformed-schema.json' 123 | $jsonPath = Join-Path -Path $script:tempDir -ChildPath 'malformed-test.json' 124 | 125 | Set-Content -Path $schemaPath -Value $schema 126 | Set-Content -Path $jsonPath -Value $malformedJson 127 | 128 | $result = Test-JsonValid -JsonPath $jsonPath -SchemaPath $schemaPath 129 | } 130 | 131 | It 'Should return IsValid as false for malformed JSON' { 132 | $result.IsValid | Should -Be $false 133 | } 134 | 135 | It 'Should include "Unexpected error" in the Message' { 136 | $result.Message | Should -Match 'Unexpected error' 137 | } 138 | } 139 | 140 | Context 'Invalid schema' { 141 | BeforeAll { 142 | $json = '{ "name": "John Doe" }' 143 | # Malformed schema with missing closing brace 144 | $invalidSchema = '{ "type": "object"' 145 | 146 | # Create temporary files for the test 147 | $schemaPath = Join-Path -Path $script:tempDir -ChildPath 'bad-schema.json' 148 | $jsonPath = Join-Path -Path $script:tempDir -ChildPath 'good-test.json' 149 | 150 | Set-Content -Path $schemaPath -Value $invalidSchema 151 | Set-Content -Path $jsonPath -Value $json 152 | 153 | $result = Test-JsonValid -JsonPath $jsonPath -SchemaPath $schemaPath 154 | } 155 | 156 | It 'Should return IsValid as false for invalid schema' { 157 | $result.IsValid | Should -Be $false 158 | } 159 | 160 | It 'Should return error message for invalid schema' { 161 | $result.Message | Should -Not -BeNullOrEmpty 162 | } 163 | } 164 | 165 | Context 'File not found' { 166 | It 'Should throw an exception for non-existent JSON file' { 167 | { Test-JsonValid -JsonPath 'nonexistent.json' -SchemaPath "$script:tempDir\schema.json" } | 168 | Should -Throw 'JSON file not found: nonexistent.json' 169 | } 170 | 171 | It 'Should throw an exception for non-existent schema file' { 172 | # Create a temporary JSON file 173 | $jsonPath = Join-Path -Path $script:tempDir -ChildPath 'exists.json' 174 | '{}' | Set-Content -Path $jsonPath 175 | 176 | { Test-JsonValid -JsonPath $jsonPath -SchemaPath 'nonexistent.json' } | 177 | Should -Throw 'Schema file not found: nonexistent.json' 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/PesterPreference.ps1: -------------------------------------------------------------------------------- 1 | [PesterConfiguration]@{ 2 | Run = @{ 3 | Path = @( 4 | '.' 5 | ) 6 | ExcludePath = '' 7 | ScriptBlock = '' 8 | Container = '' 9 | TestExtension = '.Tests.ps1' 10 | Exit = 'false' 11 | Throw = 'false' 12 | PassThru = 'false' 13 | SkipRun = 'false' 14 | } 15 | Filter = @{ 16 | Tag = '' 17 | ExcludeTag = '' 18 | Line = '' 19 | FullName = '' 20 | } 21 | CodeCoverage = @{ 22 | Enabled = $false 23 | OutputFormat = 'JaCoCo' 24 | OutputPath = './CodeCoverage.xml' 25 | OutputEncoding = 'UTF8' 26 | ExcludeTests = 'true' 27 | RecursePaths = 'true' 28 | CoveragePercentTarget = '75.0' 29 | SingleHitBreakpoints = 'true' 30 | } 31 | TestResult = @{ 32 | Enabled = $true 33 | OutputFormat = 'JUnitXml' 34 | OutputPath = './PesterResults.xml' 35 | OutputEncoding = 'UTF8' 36 | TestSuiteName = 'Pester' 37 | } 38 | Should = @{ 39 | ErrorAction = 'Stop' 40 | } 41 | Debug = @{ 42 | ShowFullErrors = 'false' 43 | WriteDebugMessages = 'false' 44 | WriteDebugMessagesFrom = @( 45 | 'Discovery', 46 | 'Skip', 47 | 'Filter', 48 | 'Mock', 49 | 'CodeCoverage' 50 | ) 51 | ShowNavigationMarkers = 'false' 52 | ReturnRawResultObject = 'false' 53 | } 54 | Output = @{ 55 | Verbosity = 'Detailed' 56 | } 57 | } 58 | --------------------------------------------------------------------------------