diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..518eb73 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# Windows PowerShell 5.1 needs a BOM to read non-ASCII correctly, and CRLF is the +# native line ending on the target SharePoint servers. +[*.{ps1,psm1,psd1}] +charset = utf-8-bom +end_of_line = crlf + +[*.{yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9b2615b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +# SPSUpdate line-ending and encoding policy +# +# PowerShell files run under Windows PowerShell 5.1 on SharePoint servers. Without a +# BOM, Windows PowerShell 5.1 reads .ps1/.psm1/.psd1 as ANSI (the system code page), +# which corrupts any non-ASCII character. We therefore store all PowerShell files as +# UTF-8 with BOM (the BOM is part of the committed content) and check them out with +# CRLF line endings (native on the target servers). +# +# YAML, Markdown and other text files must NOT carry a BOM (it breaks some YAML +# parsers and is unwanted on GitHub-rendered files); they use LF. + +* text=auto eol=lf + +# PowerShell -- committed with a UTF-8 BOM, checked out with CRLF +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.psd1 text eol=crlf + +# Workflows / docs / config -- no BOM, LF +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.json text eol=lf + +# Binary artifacts (release ZIP, screenshots) +*.dll binary +*.png binary +*.zip binary diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index a7ca5ce..88a8664 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -1,4 +1,4 @@ -# This is the SPSUpdate CI Pester workflow to run tests on pull requests +# SPSUpdate CI Pester workflow to run tests and code analysis on pull requests. name: SPSUpdate CI Pester Tests @@ -7,14 +7,23 @@ on: branches: - main paths: - - 'scripts/**' + - 'src/**' - 'tests/**' + - 'PSScriptAnalyzerSettings.psd1' + +# The "Publish Test Results" step creates a check run, which requires write +# access to checks. The default GITHUB_TOKEN is read-only on repositories +# created with the modern default, so grant the minimum needed here. +permissions: + contents: read + checks: write + pull-requests: write jobs: test: name: Run Pester Tests runs-on: windows-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -34,7 +43,7 @@ jobs: $config.TestResult.Enabled = $true $config.TestResult.OutputPath = './test-results.xml' $config.Output.Verbosity = 'Detailed' - + Invoke-Pester -Configuration $config - name: Upload test results @@ -47,6 +56,7 @@ jobs: - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action/composite@v2 if: always() + continue-on-error: true with: files: | test-results.xml @@ -55,10 +65,10 @@ jobs: code-quality: name: Code Quality Check runs-on: windows-latest - + steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install PSScriptAnalyzer shell: pwsh @@ -68,18 +78,16 @@ jobs: - name: Run PSScriptAnalyzer shell: pwsh run: | - # Exclude credentialmanager folder (third-party dependency) - $scriptFiles = Get-ChildItem -Path ./scripts -Include '*.ps1', '*.psm1' -Recurse | - Where-Object { $_.FullName -notlike '*credentialmanager*' } - - $results = $scriptFiles | ForEach-Object { - Invoke-ScriptAnalyzer -Path $_.FullName -Severity Error,Warning - } - + # Analyze our own code only: the entry script and the SPSUpdate.Common module. + $results = @() + $results += Invoke-ScriptAnalyzer -Path ./src/SPSUpdate.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + $results += Invoke-ScriptAnalyzer -Path ./src/Modules/SPSUpdate.Common -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 + if ($results) { $results | Format-Table -AutoSize Write-Error "PSScriptAnalyzer found issues" exit 1 - } else { + } + else { Write-Host "No issues found by PSScriptAnalyzer" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f116e09..60809bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,8 @@ -# This is the SPSUpdate CI Release workflow to create release from tag push +# This is the SPSUpdate CI Release workflow to create a release from a tag push. +# Triggered by pushing a v* tag (e.g. v4.0.0). It packages the *contents* of the +# src/ folder into a ZIP (so the archive extracts straight to Config/, Modules/ and +# SPSUpdate.ps1, with no src/ wrapper) and publishes a GitHub Release using +# RELEASE-NOTES.md as the body. name: SPSUpdate CI Release @@ -8,6 +12,9 @@ on: tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest @@ -17,10 +24,10 @@ jobs: id: checkout_code uses: actions/checkout@v4 # Create a ZIP file with project name and tag version - - name: Create ZIP file of scripts + - name: Create ZIP file of src contents run: | zip_name="SPSUpdate-${{ github.ref_name }}.zip" - zip -r $zip_name scripts/ + ( cd src && zip -r "../$zip_name" . ) shell: bash # Create Release with tag version - name: Create GitHub Release diff --git a/.gitignore b/.gitignore index 6a3e68d..afdaa2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ -**/.DS_Store \ No newline at end of file +**/.DS_Store + +# Test / analysis artifacts +test-results.xml +testResults.xml +coverage.xml + +# Local secrets (DPAPI credential store) -- NEVER commit real secrets +**/secrets.psd1 + +# Real environment configs live in src/Config; only *.example.psd1 templates are tracked +src/Config/*.psd1 +!src/Config/*.example.psd1 + +# Runtime output kept out of source control (logs and generated ContentDB inventory) +src/Logs/ +**/*-ContentDBs.json +**/*-ContentDBs_*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ba35623..c8bfe08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2026-06-29 + +### Added + +- New `SPSUpdate.Common` PowerShell module (`src/Modules/SPSUpdate.Common`) with a manifest-driven version, a dot-sourcing loader, and a one-file-per-function layout split into `Public/` and `Private/`. +- DPAPI credential store: `Get-SPSSecret` / `Set-SPSSecret` persist the `InstallAccount` as an encrypted SecureString in `Config\secrets.psd1`, replacing the Windows Credential Manager module. `-Action Install` writes the secret and `-Action Uninstall` removes it. +- Tolerant configuration loader: optional keys fall back to safe defaults (`Binaries.ProductUpdate`, `Binaries.ShutdownServices` and `UpgradeContentDatabase` default to `$true`; `MountContentDatabase` and `SideBySideToken.Enable` default to `$false`). +- Repository scaffolding aligned with the other SPS* projects: `.editorconfig`, `.gitattributes` (UTF-8 BOM + CRLF for PowerShell files), `PSScriptAnalyzerSettings.psd1`, and an expanded `.gitignore`. +- Wiki: new `Release-Process` page and `_Sidebar.md` navigation. +- Offline install guide (`src/SPSUpdate_README.md`) shipped inside the release ZIP, rewritten for the psd1 config, the DPAPI secret store and the `Modules\` layout (no DSC). + +### Changed + +- **BREAKING** — Project layout moved from `scripts/` to `src/`. The release ZIP now packages the *contents* of `src/`, so the archive extracts straight to `SPSUpdate.ps1`, `Config\` and `Modules\` (no wrapper folder). +- **BREAKING** — Environment configuration moved from JSON to a PowerShell data file (`*.psd1`), read with `Import-PowerShellDataFile`. A single documented template (`Config\CONTOSO-PROD.example.psd1`) lists every option with its possible values inline. Runtime/output files (the ContentDB inventory and logs) stay JSON by design. +- **BREAKING** — The config key `StoredCredential` is renamed to `CredentialKey` and now points at an entry in `Config\secrets.psd1` instead of a Windows Credential Manager target. +- `SPSUpdate.ps1` now imports `SPSUpdate.Common`, derives its version banner from the module manifest, and reads/writes the InstallAccount credential through `Get-SPSSecret`/`Set-SPSSecret`. +- `Add-SPSUpdateEvent` is now self-contained (version/user resolved from the module) and re-points a mis-mapped event source to the `SPSUpdate` log instead of returning silently. +- CI: `release.yml` zips the contents of `src/`; `pester.yml` triggers on `src/**`, `tests/**` and `PSScriptAnalyzerSettings.psd1`, and runs PSScriptAnalyzer against `src/SPSUpdate.ps1` and the `SPSUpdate.Common` module. +- `README.md` trimmed to a short overview with quick links; the wiki (`Home`, `Getting-Started`, `Configuration`, `Usage`) rewritten for the new layout, psd1 config and DPAPI secret store. + +### Fixed + +- `Invoke-SPSCommand` now fails fast when the CredSSP `New-PSSession` cannot be established (`-ErrorAction Stop` + a clear `throw`) instead of falling back to running the SharePoint scriptblock **locally** on the master server with no session — which previously made PSConfig and side-by-side operations silently target the wrong server. Added an `OpenTimeout` and clearer remote-failure error messages, aligned with the SPSWeather hardening. + +- **BREAKING** — The bundled third-party `credentialmanager` module (DLLs + manifest) and all `Get-/New-/Remove-StoredCredential` usage. +- The legacy `scripts/` tree, including the flat `util.psm1` / `sps.util.psm1` helpers and the per-farm JSON configs. + +### Tests + +- Replaced the scripts/JSON-oriented Pester tests with a cross-platform suite (`tests/SPSUpdate.Common.Tests.ps1`, `tests/Configuration.Tests.ps1`) covering the manifest, the exact public export set, hidden private helpers, file conventions (one function per file, UTF-8 BOM, parse), parameter contracts, the `Add-SPSUpdateEvent` self-heal, a DPAPI secret round-trip (Windows-only), and the example psd1 config/secret contract. + ## [3.2.1] - 2026-06-11 ### Changed diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..5da9711 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,14 @@ +@{ + # PSScriptAnalyzer settings for SPSUpdate. + # Run locally with: + # Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 + Severity = @('Error', 'Warning') + + # PSUseSingularNouns is disabled defensively: a couple of helpers intentionally use a + # plural noun because they act on a collection (e.g. Copy-SPSSideBySideFilesRemote + # operates on the side-by-side file set), mirroring built-in cmdlets such as + # Get-ChildItem. + ExcludeRules = @( + 'PSUseSingularNouns' + ) +} diff --git a/README.md b/README.md index d621eb7..9a0bef8 100644 --- a/README.md +++ b/README.md @@ -3,35 +3,18 @@ ![Latest release date](https://img.shields.io/github/release-date/luigilink/SPSUpdate.svg?style=flat) ![Total downloads](https://img.shields.io/github/downloads/luigilink/SPSUpdate/total.svg?style=flat) ![Issues opened](https://img.shields.io/github/issues/luigilink/SPSUpdate.svg?style=flat) -[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) -## Description +**SPSUpdate** is a PowerShell tool that installs SharePoint Server cumulative updates and runs the post-setup Configuration Wizard (PSConfig) across a farm. It installs the binaries, mounts/upgrades content databases in parallel, runs PSConfig on local and remote servers over CredSSP remoting, and configures the side-by-side token for zero-downtime patching. -SPSUpdate is a PowerShell script tool designed to install cumulative updates in your SharePoint environment. +Compatible with SharePoint Server **2016**, **2019**, and **Subscription Edition**. Requires PowerShell 5.1 or later — no DSC module needed. -[Download the latest release, Click here!](https://github.com/luigilink/SPSUpdate/releases/latest) +## Quick links -## Requirements - -### PowerShell 5.1 or later - -SPSUpdate no longer depends on a DSC module. -Use PowerShell 5.1 or later on each SharePoint server where you run the script. -The script relies on standard PowerShell remoting, scheduled tasks, Credential Manager integration, and native SharePoint update binaries. -This is discussed further on the [SPSUpdate Wiki Getting-Started](https://github.com/luigilink/SPSUpdate/wiki/Getting-Started) - -## CredSSP - -Impersonation is handled using the `Invoke-Command` cmdlet in PowerShell, together with the creation of a remote session via `New-PSSession`. In the SPSUpdate script, we authenticate as the InstallAccount and specify CredSSP as the authentication mechanism. This is explained further in the [SPSUpdate Wiki Getting-Started](https://github.com/luigilink/SPSUpdate/wiki/Getting-Started) - -## ProductUpdate - -The `ProductUpdate` action runs the SharePoint update binaries directly on the local server. You only need the update files accessible on that server. - -## Documentation - -For detailed usage, configuration, and getting started information, visit the [SPSUpdate Wiki](https://github.com/luigilink/SPSUpdate/wiki) - -## Changelog - -A full list of changes in each version can be found in the [change log](CHANGELOG.md) +- 📦 [Latest release](https://github.com/luigilink/SPSUpdate/releases/latest) +- 📖 [Documentation (Wiki)](https://github.com/luigilink/SPSUpdate/wiki) +- 🚀 [Getting Started](https://github.com/luigilink/SPSUpdate/wiki/Getting-Started) +- ⚙️ [Configuration reference](https://github.com/luigilink/SPSUpdate/wiki/Configuration) +- 📖 [Usage reference](https://github.com/luigilink/SPSUpdate/wiki/Usage) +- 📝 [Changelog](CHANGELOG.md) +- 🤝 [Contributing](.github/CONTRIBUTING.md) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 40ef187..ac52b5d 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,30 +1,44 @@ # SPSUpdate - Release Notes -## [3.2.1] - 2026-06-11 +## [4.0.0] - 2026-06-29 -### Changed +This is a major modernization release that aligns SPSUpdate with the SPSWeather and +SPSUserSync projects. It is a **breaking** release: the package layout, the configuration +format and the credential storage all change. Review the migration notes below before +upgrading an existing deployment. -scripts/Modules/sps.util.psm1: +### Added -- `Initialize-SPSContentDbJsonFile` now distributes content databases across the four sequences using a **Longest Processing Time First (LPT)** algorithm based on `DiskSizeRequired`, instead of splitting them evenly by count (`floor(count / 4)`). Sequences are now balanced by total database size so parallel upgrade workloads finish closer to the same time. -- `Initialize-SPSContentDbJsonFile` now prints a distribution report to the transcript showing the count, total size (MB) and percentage for each of the four sequences. -- `Initialize-SPSContentDbJsonFile` now also writes a timestamped snapshot of the inventory (`_yyyy-MM-dd_HH-mm-ss.json`) next to the canonical file each time it runs. The canonical file (consumed by `SPSUpdate.ps1`) is still overwritten in place, while the dated snapshots accumulate in `scripts/Config/` so previous inventories can be reviewed or restored. Snapshot failures are logged via `Write-Verbose` and never block the canonical write. -- `Start-SPSProductUpdate` now invokes the SharePoint patch setup with `/passive` instead of `/quiet`. `/passive` still runs the patch without user interaction but displays a progress UI, which gives administrators visibility on the installation progress when the script is run interactively. Behavior of the returned exit code and the post-install service-restoration logic is unchanged. +- New `SPSUpdate.Common` PowerShell module (`src/Modules/SPSUpdate.Common`) with a manifest-driven version, a dot-sourcing loader, and a one-file-per-function layout split into `Public/` and `Private/`. +- DPAPI credential store: `Get-SPSSecret` / `Set-SPSSecret` persist the `InstallAccount` as an encrypted SecureString in `Config\secrets.psd1`, replacing the Windows Credential Manager module. `-Action Install` writes the secret and `-Action Uninstall` removes it. +- Tolerant configuration loader: optional keys fall back to safe defaults (`Binaries.ProductUpdate`, `Binaries.ShutdownServices` and `UpgradeContentDatabase` default to `$true`; `MountContentDatabase` and `SideBySideToken.Enable` default to `$false`). +- Repository scaffolding aligned with the other SPS* projects: `.editorconfig`, `.gitattributes` (UTF-8 BOM + CRLF for PowerShell files), `PSScriptAnalyzerSettings.psd1`, and an expanded `.gitignore`. +- Wiki: new `Release-Process` page and `_Sidebar.md` navigation. +- Offline install guide (`src/SPSUpdate_README.md`) shipped inside the release ZIP, rewritten for the psd1 config, the DPAPI secret store and the `Modules\` layout (no DSC). -scripts/SPSUpdate.ps1: +### Changed -- Bumped `$SPSUpdateVersion` to `3.2.1`. +- **BREAKING** — Project layout moved from `scripts/` to `src/`. The release ZIP now packages the *contents* of `src/`, so the archive extracts straight to `SPSUpdate.ps1`, `Config\` and `Modules\` (no wrapper folder). +- **BREAKING** — Environment configuration moved from JSON to a PowerShell data file (`*.psd1`), read with `Import-PowerShellDataFile`. A single documented template (`Config\CONTOSO-PROD.example.psd1`) lists every option with its possible values inline. Runtime/output files (the ContentDB inventory and logs) stay JSON by design. +- **BREAKING** — The config key `StoredCredential` is renamed to `CredentialKey` and now points at an entry in `Config\secrets.psd1` instead of a Windows Credential Manager target. +- `SPSUpdate.ps1` now imports `SPSUpdate.Common`, derives its version banner from the module manifest, and reads/writes the InstallAccount credential through `Get-SPSSecret`/`Set-SPSSecret`. +- `Add-SPSUpdateEvent` is now self-contained (version/user resolved from the module) and re-points a mis-mapped event source to the `SPSUpdate` log instead of returning silently. +- CI: `release.yml` zips the contents of `src/`; `pester.yml` triggers on `src/**`, `tests/**` and `PSScriptAnalyzerSettings.psd1`, and runs PSScriptAnalyzer against `src/SPSUpdate.ps1` and the `SPSUpdate.Common` module. +- `README.md` trimmed to a short overview with quick links; the wiki (`Home`, `Getting-Started`, `Configuration`, `Usage`) rewritten for the new layout, psd1 config and DPAPI secret store. ### Fixed -scripts/Modules/sps.util.psm1: +- `Invoke-SPSCommand` now fails fast when the CredSSP `New-PSSession` cannot be established (`-ErrorAction Stop` + a clear `throw`) instead of falling back to running the SharePoint scriptblock **locally** on the master server with no session — which previously made PSConfig and side-by-side operations silently target the wrong server. Added an `OpenTimeout` and clearer remote-failure error messages, aligned with the SPSWeather hardening. -- Fixed `Initialize-SPSContentDbJsonFile` edge case where fewer than 4 content databases caused `groupSize` to evaluate to `0`, dumping every database into `SPContentDatabase4` and leaving `SPContentDatabase1..3` empty. +### Removed -### Tests +- **BREAKING** — The bundled third-party `credentialmanager` module (DLLs + manifest) and all `Get-/New-/Remove-StoredCredential` usage. +- The legacy `scripts/` tree, including the flat `util.psm1` / `sps.util.psm1` helpers and the per-farm JSON configs. -tests/Modules/sps.util.Tests.ps1: +### Migration from 3.x -- Added Pester tests for `Initialize-SPSContentDbJsonFile` covering: no file written when `Get-SPContentDatabase` returns `$null`, fair distribution of fewer than 4 databases (regression for the `floor(count / 4)` bug), LPT balancing by size (the largest database lands alone in its sequence), and the `SPContentDatabase1..4` JSON property contract consumed by `SPSUpdate.ps1`. +1. Convert each JSON config to a `*.psd1` file (see `Config\CONTOSO-PROD.example.psd1`) and rename `StoredCredential` to `CredentialKey`. +2. Re-run `.\SPSUpdate.ps1 -ConfigFile '.psd1' -Action Install -InstallAccount (Get-Credential)` **as the service account** to store the credential in `Config\secrets.psd1` (the previous Credential Manager entry is no longer used). +3. Extract the new ZIP on each server; it unpacks to `SPSUpdate.ps1`, `Config\` and `Modules\` directly. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Config/CONTOSO-PROD-CONTENT.json b/scripts/Config/CONTOSO-PROD-CONTENT.json deleted file mode 100644 index 535d7d5..0000000 --- a/scripts/Config/CONTOSO-PROD-CONTENT.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema#", - "contentVersion": "1.0.0.0", - "ConfigurationName": "PROD", - "ApplicationName": "contoso", - "FarmName": "CONTENT", - "Domain": "contoso.com", - "StoredCredential": "PROD-ADM", - "Binaries": { - "ProductUpdate": true, - "SetupFullPath": "D:\\SoftwarePackages\\SPS\\cumulativeupdates", - "SetupFileName": ["uber-subscription-kb5002651-fullfile-x64-glb.exe"], - "ShutdownServices": false - }, - "MountContentDatabase": true, - "UpgradeContentDatabase": true, - "SideBySideToken": { - "Enable": true, - "BuildVersion": "16.0.17928.20238" - } -} diff --git a/scripts/Config/CONTOSO-PROD-SEARCH.json b/scripts/Config/CONTOSO-PROD-SEARCH.json deleted file mode 100644 index 72122d3..0000000 --- a/scripts/Config/CONTOSO-PROD-SEARCH.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema#", - "contentVersion": "1.0.0.0", - "ConfigurationName": "PROD", - "ApplicationName": "contoso", - "FarmName": "SEARCH", - "Domain": "contoso.com", - "StoredCredential": "PROD-ADM", - "Binaries": { - "ProductUpdate": true, - "SetupFullPath": "D:\\SoftwarePackages\\SPS\\cumulativeupdates", - "SetupFileName": [ - "sts2019-kb5002630-fullfile-x64-glb.exe", - "wssloc2019-kb5002597-fullfile-x64-glb.exe" - ], - "ShutdownServices": false - }, - "UpgradeContentDatabase": false, - "SideBySideToken": { - "Enable": false, - "BuildVersion": "" - } -} diff --git a/scripts/Config/CONTOSO-PROD-SERVICES.json b/scripts/Config/CONTOSO-PROD-SERVICES.json deleted file mode 100644 index 52f28ad..0000000 --- a/scripts/Config/CONTOSO-PROD-SERVICES.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema#", - "contentVersion": "1.0.0.0", - "ConfigurationName": "PROD", - "ApplicationName": "contoso", - "FarmName": "SERVICES", - "Domain": "contoso.com", - "StoredCredential": "PROD-ADM", - "Binaries": { - "ProductUpdate": true, - "SetupFullPath": "D:\\SoftwarePackages\\SPS\\cumulativeupdates", - "SetupFileName": [ - "sts-subscription-kb5002191-fullfile-x64-glb.exe", - "wssloc-subscription-kb5002110-fullfile-x64-glb.exe" - ], - "ShutdownServices": false - }, - "UpgradeContentDatabase": false, - "SideBySideToken": { - "Enable": false, - "BuildVersion": "" - } -} diff --git a/scripts/Modules/credentialmanager/CredentialManager.dll b/scripts/Modules/credentialmanager/CredentialManager.dll deleted file mode 100644 index e14dba9..0000000 Binary files a/scripts/Modules/credentialmanager/CredentialManager.dll and /dev/null differ diff --git a/scripts/Modules/credentialmanager/CredentialManager.dll-Help.xml b/scripts/Modules/credentialmanager/CredentialManager.dll-Help.xml deleted file mode 100644 index 1de13ba..0000000 --- a/scripts/Modules/credentialmanager/CredentialManager.dll-Help.xml +++ /dev/null @@ -1,782 +0,0 @@ - - - - - - Get-StoredCredential - Get - StoredCredential - - Gets stored credentials from the Windows Credential Store/Vault - - - - Gets stored credentials from the Windows Credential Store/Vault and returns as either a PSCredential object or as a Credential Object - - - - - Get-StoredCredential - - - AsCredentialObject - - Switch to return the credentials as Credential objects instead of the default PSObject - - SwitchParameter - - System.Management.Automation.SwitchParameter - - - False - - - - Target - - The command will only return credentials with the specified target - - string - - System.String - - - - - - Type - - Specifies the type of credential to return, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - - - - Target - - The command will only return credentials with the specified target - - string - - System.String - - - - - - Type - - Specifies the type of credential to return, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - AsCredentialObject - - Switch to return the credentials as Credential objects instead of the default PSObject - - SwitchParameter - - System.Management.Automation.SwitchParameter - - - False - - - - - - System.String - - - - The command will only return credentials with the specified target - - - - - - - - PSCredentialManager.Common.Credential - - - - - - - System.Management.Automation.PSCredential - - - - - - - ---------- EXAMPLE 1 ---------- - PS C:\> Get-StoredCredential -Target Server01 - - Returns credentials for Server01 as a PSCredential object - UserName Password - -------- -------- - test-user System.Security.SecureString - - - - ---------- EXAMPLE 2 ---------- - PS C:\> Get-StoredCredential -Target Server01 -AsCredentialObject - - Returns credentials for Server01 as a Credential object - Flags : 0 - Type : GENERIC - TargetName : server01 - Comment : - LastWritten : 23/04/2016 10:01:37 - PaswordSize : 18 - Password : Password1 - Persist : ENTERPRISE - AttributeCount : 0 - Attributes : 0 - TargetAlias : - UserName : test-user - - - - - - Online Version - https://github.com/davotronic5000/PowerShell_Credential_Manager/wiki/Get-StoredCredential - - - - - - - New-StoredCredential - New - StoredCredential - - Create a new credential in the Windows Credential Store/Vault - - - - Create a new credential in the Windows Credential Store/Vault - - - - - New-StoredCredential - - - Comment - - Provides a comment to identify the credentials in the store - - - string - - System.String - - - Updated by: Dave on: 10/06/2016 - - - - Password - - Specifies the password in plain text, cannot be used in conjunction with SecurePassword or Credential parameters. - - string - - System.String - - - q7:7T6zBE% - - - - Persist - - sets the persistence settings of the credential, possible values are [SESSION, LOCAL_MACHINE, ENTERPRISE] - - CredPersist - - PSCredentialManager.Common.Enum.CredPersist - - - Session - - - - Target - - Specifies the target of the credentials being added. - - string - - System.String - - - DESKTOP-6O28IQJ - - - - Type - - Type of credential to store, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - UserName - - specified the username to be used for the credentials, cannot be used in conjunction with Credentials parameter. - - string - - System.String - - - Dave - - - - - New-StoredCredential - - - Comment - - Provides a comment to identify the credentials in the store - - - string - - System.String - - - Updated by: Dave on: 10/06/2016 - - - - Persist - - sets the persistence settings of the credential, possible values are [SESSION, LOCAL_MACHINE, ENTERPRISE] - - CredPersist - - PSCredentialManager.Common.Enum.CredPersist - - - Session - - - - SecurePassword - - Specifies the password as a secure string, cannot be used in conjunction with SecurePassword or Credential parameters. - - SecureString - - System.Security.SecureString - - - - - - Target - - Specifies the target of the credentials being added. - - string - - System.String - - - DESKTOP-6O28IQJ - - - - Type - - Type of credential to store, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - UserName - - specified the username to be used for the credentials, cannot be used in conjunction with Credentials parameter. - - string - - System.String - - - Dave - - - - - New-StoredCredential - - - Comment - - Provides a comment to identify the credentials in the store - - - string - - System.String - - - Updated by: Dave on: 10/06/2016 - - - - Credentials - - - - PSCredential - - System.Management.Automation.PSCredential - - - - - - Persist - - sets the persistence settings of the credential, possible values are [SESSION, LOCAL_MACHINE, ENTERPRISE] - - CredPersist - - PSCredentialManager.Common.Enum.CredPersist - - - Session - - - - Target - - Specifies the target of the credentials being added. - - string - - System.String - - - DESKTOP-6O28IQJ - - - - Type - - Type of credential to store, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - - - - Target - - Specifies the target of the credentials being added. - - string - - System.String - - - DESKTOP-6O28IQJ - - - - UserName - - specified the username to be used for the credentials, cannot be used in conjunction with Credentials parameter. - - string - - System.String - - - Dave - - - - Password - - Specifies the password in plain text, cannot be used in conjunction with SecurePassword or Credential parameters. - - string - - System.String - - - 2hxmOG=wM: - - - - SecurePassword - - Specifies the password as a secure string, cannot be used in conjunction with SecurePassword or Credential parameters. - - SecureString - - System.Security.SecureString - - - - - - Comment - - Provides a comment to identify the credentials in the store - - - string - - System.String - - - Updated by: Dave on: 10/06/2016 - - - - Type - - Type of credential to store, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - Persist - - sets the persistence settings of the credential, possible values are [SESSION, LOCAL_MACHINE, ENTERPRISE] - - CredPersist - - PSCredentialManager.Common.Enum.CredPersist - - - Session - - - - Credentials - - - - PSCredential - - System.Management.Automation.PSCredential - - - - - - - - System.Management.Automation.PSCredential - - - - - - - - - - - - PSCredentialManager.Common.Credential - - - - - - - ---------- EXAMPLE 1 ---------- - PS C:\> New-StoredCredential -Target server01 -UserName test-user -Password Password1 - - creates a credential for server01 with the username test-user and password Password1 - Flags : 0 - Type : GENERIC - TargetName : server01 - Comment : Updated by: Dave on: 23/04/2016 - LastWritten : 23/04/2016 10:48:56 - PaswordSize : 18 - Password : Password1 - Persist : SESSION - AttributeCount : 0 - Attributes : 0 - TargetAlias : - UserName : test-user - - - - ---------- EXAMPLE 2 ---------- - PS C:\> Get-Credential -UserName test-user -Message "Password please" | New-StoredCredential -Target Server01 - - Creates a credential for Server01 with the username and password provided in the PSCredential object from Get-Credential - - - - - - Online Version - https://github.com/davotronic5000/PowerShell_Credential_Manager/wiki/New-StoredCredential - - - - - - - Remove-StoredCredential - Remove - StoredCredential - - Deletes a credentials from the Windows Credential Store/Vault - - - - Deletes a credentials from the Windows Credential Store/Vault - - - - - Remove-StoredCredential - - - Target - - specifies a target to identitfy the credential to be deleted - - string - - System.String - - - - - - Type - - Specifies the type of credential to be deleted, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - - - - Target - - specifies a target to identitfy the credential to be deleted - - string - - System.String - - - - - - Type - - Specifies the type of credential to be deleted, possible values are [GENERIC, DOMAIN_PASSWORD, DOMAIN_CERTIFICATE, DOMAIN_VISIBLE_PASSWORD, GENERIC_CERTIFICATE, DOMAIN_EXTENDED, MAXIMUM, MAXIMUM_EX] - - CredType - - PSCredentialManager.Common.Enum.CredType - - - Generic - - - - - - System.String - - - - specifies a target to identitfy the credential to be deleted - - - - - - - ---------- EXAMPLE 1 ---------- - PS C:\> Remove-StoredCredential -Target Server01 -Type GENERIC - - Deletes a generic credential with the target Server01 - - - - - - Online Version - https://github.com/davotronic5000/PowerShell_Credential_Manager/wiki/Remove-StoredCredential - - - - - - - Get-StrongPassword - Get - StrongPassword - - Generates a strong password - - - - Generates a strong password based on the parameters provided - - - - - Get-StrongPassword - - - Length - - Length in Characters for the generated password to be. - - int - - System.Int32 - - - 10 - - - - NumberOfSpecialCharacters - - Number of special characters to include in the password, must be less than the length of the password - - int - - System.Int32 - - - 3 - - - - - - - Length - - Length in Characters for the generated password to be. - - int - - System.Int32 - - - 10 - - - - NumberOfSpecialCharacters - - Number of special characters to include in the password, must be less than the length of the password - - int - - System.Int32 - - - 3 - - - - - - - - System.String - - - - - - - - ---------- EXAMPLE 1 ---------- - PS C:\> Get-StrongPassword - - Generates a password 10 characters long with 3 special characters - QTJ(T?wwe) - - - - ---------- EXAMPLE 2 ---------- - PS C:\> Get-StrongPassword -Length 20 -NumberOfSpecialCharacters 5 - - Generates a password 20 characters long with 5 special characters - zPN>C%.f/(l1aGq)n3Ze - - - - - - Online Version - https://github.com/davotronic5000/PowerShell_Credential_Manager/wiki/Get-StrongPassword - - - - \ No newline at end of file diff --git a/scripts/Modules/credentialmanager/CredentialManager.psd1 b/scripts/Modules/credentialmanager/CredentialManager.psd1 deleted file mode 100644 index bf22aec..0000000 Binary files a/scripts/Modules/credentialmanager/CredentialManager.psd1 and /dev/null differ diff --git a/scripts/Modules/credentialmanager/PSCredentialManager.Api.dll b/scripts/Modules/credentialmanager/PSCredentialManager.Api.dll deleted file mode 100644 index 3c05db5..0000000 Binary files a/scripts/Modules/credentialmanager/PSCredentialManager.Api.dll and /dev/null differ diff --git a/scripts/Modules/credentialmanager/PSCredentialManager.Common.dll b/scripts/Modules/credentialmanager/PSCredentialManager.Common.dll deleted file mode 100644 index f0f8fd2..0000000 Binary files a/scripts/Modules/credentialmanager/PSCredentialManager.Common.dll and /dev/null differ diff --git a/scripts/Modules/sps.util.psm1 b/scripts/Modules/sps.util.psm1 deleted file mode 100644 index f6e763c..0000000 --- a/scripts/Modules/sps.util.psm1 +++ /dev/null @@ -1,768 +0,0 @@ -function Clear-ComObject { - [CmdletBinding()] - param - ( - [Parameter()] - [System.Object] - $ComObject - ) - - if ($null -eq $ComObject) { - return - } - - try { - if ([System.Runtime.InteropServices.Marshal]::IsComObject($ComObject)) { - [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($ComObject) - } - } - catch { - Write-Verbose -Message "Unable to release COM object: $($_.Exception.Message)" - } -} - -function Get-SPSServersPatchStatus { - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Server - ) - - $spFarm = Get-SPFarm - $productVersions = [Microsoft.SharePoint.Administration.SPProductVersions]::GetProductVersions($spFarm) - $spServer = Get-SPServer $Server - $serverProductInfo = $productVersions.GetServerProductInfo($spServer.Id) - if ($null -ne $serverProductInfo) { - $statusType = $serverProductInfo.InstallStatus - if ($statusType -ne 0) { - $statusType = $serverProductInfo.GetUpgradeStatus($spFarm, $spServer) - } - } - else { - $statusType = [Microsoft.SharePoint.Administration.SPServerProductInfo+StatusType]::NoActionRequired - } - return $statusType -} - -function Start-SPSConfigExe { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param () - - # Check which version of SharePoint is installed - $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' - $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 - $getSPInstalledProductVersion = (Get-Command $fullPath).FileVersionInfo - - if ($getSPInstalledProductVersion.FileMajorPart -eq 15) { - $wssRegKey = 'hklm:SOFTWARE\Microsoft\Shared Tools\Web Server Extensions\15.0\WSS' - $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\15\BIN" - } - else { - $wssRegKey = 'hklm:SOFTWARE\Microsoft\Shared Tools\Web Server Extensions\16.0\WSS' - $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\16\BIN" - } - $psconfigExe = Join-Path -Path $binaryDir -ChildPath "psconfig.exe" - - # Read LanguagePackInstalled and SetupType registry keys - $languagePackInstalled = Get-ItemProperty -LiteralPath $wssRegKey -Name 'LanguagePackInstalled' -ErrorAction SilentlyContinue - $setupType = Get-ItemProperty -LiteralPath $wssRegKey -Name 'SetupType' - - # Determine if LanguagePackInstalled=1 or SetupType=B2B_Upgrade. - # If so, the Config Wizard is required - if (($languagePackInstalled.LanguagePackInstalled -eq 1) -or ($setupType.SetupType -eq "B2B_UPGRADE")) { - Write-Output "Starting Configuration Wizard" - Write-Output "Starting 'Product Version Job' timer job" - $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' - $lastRunTime = $pvTimerJob.LastRunTime - - Start-SPTimerJob -Identity $pvTimerJob - - $jobRunning = $true - $maxCount = 30 - $count = 0 - Write-Output "Waiting for 'Product Version Job' timer job to complete" - while ($jobRunning -and $count -le $maxCount) { - Start-Sleep -Seconds 10 - - $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' - $jobRunning = $lastRunTime -eq $pvTimerJob.LastRunTime - - $count++ - } - - # Fix for issue with psconfig on SharePoint 2019 - if ($getSPInstalledProductVersion.FileMajorPart -eq 16) { - Upgrade-SPFarm -ServerOnly -SkipDatabaseUpgrade -SkipSiteUpgrade -Confirm:$false - } - - $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" - $psconfig = Start-Process -FilePath $psconfigExe ` - -ArgumentList "-cmd upgrade -inplace b2b -wait -cmd applicationcontent -install -cmd installfeatures -cmd secureresources -cmd services -install" ` - -RedirectStandardOutput $stdOutTempFile ` - -Wait ` - -PassThru - - $cmdOutput = Get-Content -Path $stdOutTempFile -Raw - Remove-Item -Path $stdOutTempFile - - if ($null -ne $cmdOutput) { - Write-Output $cmdOutput.Trim() - } - - Write-Output "PSConfig Exit Code: $($psconfig.ExitCode)" - return $psconfig.ExitCode - } - # Error codes: https://aka.ms/installerrorcodes - switch ($result) { - 0 { - Write-Output "SharePoint Post Setup Configuration Wizard ran successfully" - } - Default { - $message = ("SharePoint Post Setup Configuration Wizard failed, " + ` - "exit code was $result. Error codes can be found at " + ` - "https://aka.ms/installerrorcodes") - throw $message - } - } -} - -function Start-SPSConfigExeRemote { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Server, - - [Parameter()] - [System.Management.Automation.PSCredential] - $InstallAccount - ) - - # Check which version of SharePoint is installed - $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' - $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 - $getSPInstalledProductVersion = (Get-Command $fullPath).FileVersionInfo - - if ($getSPInstalledProductVersion.FileMajorPart -eq 15) { - $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\15\BIN" - } - else { - $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\16\BIN" - } - $psconfigExe = Join-Path -Path $binaryDir -ChildPath "psconfig.exe" - - # Start wizard - Write-Verbose -Message "Starting Configuration Wizard on server: $Server" - $result = Invoke-SPSCommand -Credential $InstallAccount ` - -Server $Server ` - -Arguments $psconfigExe ` - -ScriptBlock { - - $psconfigExe = $args[0] - - # Check which version of SharePoint is installed - $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' - $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 - $getSPInstalledProductVersion = (Get-Command $fullPath).FileVersionInfo - - Write-Verbose -Message "Starting 'Product Version Job' timer job" - $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' - $lastRunTime = $pvTimerJob.LastRunTime - - Start-SPTimerJob -Identity $pvTimerJob - - $jobRunning = $true - $maxCount = 30 - $count = 0 - Write-Verbose -Message "Waiting for 'Product Version Job' timer job to complete" - while ($jobRunning -and $count -le $maxCount) { - Start-Sleep -Seconds 10 - $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' - $jobRunning = $lastRunTime -eq $pvTimerJob.LastRunTime - $count++ - } - - # Fix for issue with psconfig on SharePoint 2019 - if ($getSPInstalledProductVersion.FileMajorPart -ne 15) { - Upgrade-SPFarm -ServerOnly -SkipDatabaseUpgrade -SkipSiteUpgrade -Confirm:$false - } - - $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" - $psconfig = Start-Process -FilePath $psconfigExe ` - -ArgumentList "-cmd upgrade -inplace b2b -wait -cmd applicationcontent -install -cmd installfeatures -cmd secureresources -cmd services -install" ` - -RedirectStandardOutput $stdOutTempFile ` - -Wait ` - -PassThru - - $cmdOutput = Get-Content -Path $stdOutTempFile -Raw - Remove-Item -Path $stdOutTempFile - if ($null -ne $cmdOutput) { - Write-Verbose -Message $cmdOutput.Trim() - } - Write-Verbose -Message "PSConfig Exit Code: $($psconfig.ExitCode)" - return $psconfig.ExitCode - } - # Error codes: https://aka.ms/installerrorcodes - switch ($result) { - 0 { - Write-Verbose -Message "SharePoint Post Setup Configuration Wizard ran successfully" - } - Default { - $message = ("SharePoint Post Setup Configuration Wizard failed, " + ` - "exit code was $result. Error codes can be found at " + ` - "https://aka.ms/installerrorcodes") - throw $message - } - } -} - -function Update-SPSContentDatabase { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Name - ) - - $getSPContentDb = Get-SPContentDatabase -Identity $Name -ErrorAction SilentlyContinue - if ($null -ne $getSPContentDb) { - Write-Output "Checking Upgrading status for $($Name) ..." - if ($getSPContentDb.NeedsUpgrade) { - Write-Output "Upgrading SharePoint SPContentDatabase $($Name)" - $updateStarted = Get-date - Write-Output "Started at $updateStarted - Please Wait ..." - if ($PSCmdlet.ShouldProcess($Name, 'Upgrade SharePoint content database')) { - Upgrade-SPContentDatabase $Name -Confirm:$false -Verbose - } - $updateFinished = Get-date - Write-Output "Update for SharePoint SPContentDatabase $($Name) is finished at $updateFinished" - } - else { - Write-Output "SPContentDatabase $($Name) already upgraded - No action needed" - } - } - else { - Write-Output "SPContentDatabase $($Name) does not exist - No action needed" - } - -} - -function Mount-SPSContentDatabase { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Name, - - [Parameter(Mandatory = $true)] - [System.String] - $WebAppUrl, - - [Parameter()] - [System.String] - $DatabaseServer - ) - - # Skip mount if the content database is already attached to the farm - $getSPContentDb = Get-SPContentDatabase -Identity $Name -ErrorAction SilentlyContinue - if ($null -ne $getSPContentDb) { - Write-Output "SPContentDatabase $($Name) is already mounted - No action needed" - return - } - - # Validate that the target web application exists - $getSPWebApp = Get-SPWebApplication -Identity $WebAppUrl -ErrorAction SilentlyContinue - if ($null -eq $getSPWebApp) { - $catchMessage = "SPWebApplication '$WebAppUrl' was not found - Cannot mount SPContentDatabase '$Name'" - Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' - throw $catchMessage - } - - Write-Output "Mounting SPContentDatabase $($Name) on WebApplication $($WebAppUrl)" - $mountStarted = Get-Date - Write-Output "Started at $mountStarted - Please Wait ..." - if ($PSCmdlet.ShouldProcess($Name, "Mount SharePoint content database on $WebAppUrl")) { - $mountParams = @{ - Name = $Name - WebApplication = $WebAppUrl - Confirm = $false - } - if (-not [string]::IsNullOrWhiteSpace($DatabaseServer)) { - $mountParams['DatabaseServer'] = $DatabaseServer - } - Mount-SPContentDatabase @mountParams -Verbose - } - $mountFinished = Get-Date - Write-Output "Mount for SPContentDatabase $($Name) is finished at $mountFinished" -} - -function Set-SPSSideBySideToken { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param - ( - [Parameter()] - [System.String] - $BuildVersion, - - [Parameter()] - [System.Boolean] - $EnableSideBySide - ) - - $webApps = Get-SPWebApplication -ErrorAction SilentlyContinue - if ($null -ne $webApps) { - foreach ($webApp in $webApps) { - $spWebAppName = $webApp.Name - if ($EnableSideBySide) { - if ($webApp.WebService.EnableSideBySide) { - Write-Output "EnableSideBySide is already enabled on $spWebAppName Web Application" - } - else { - Write-Output "Enabling EnableSideBySide on $spWebAppName Web Application" - if ($PSCmdlet.ShouldProcess($spWebAppName, 'Enable SharePoint side-by-side mode')) { - $webApp.WebService.EnableSideBySide = $true - $webApp.WebService.Update() - } - } - if ($webApp.WebService.SideBySideToken -eq $BuildVersion) { - Write-Output "SideBySideToken $BuildVersion is already enabled on $spWebAppName Web Application" - } - else { - Write-Output "Enabling SideBySideToken $BuildVersion on $spWebAppName Web Application" - if ($PSCmdlet.ShouldProcess($spWebAppName, "Set SharePoint SideBySideToken to $BuildVersion")) { - $webApp.WebService.SideBySideToken = $BuildVersion - $webApp.WebService.Update() - } - } - Write-Output 'Running CmdLet Copy-SPSideBySideFiles' - if ($PSCmdlet.ShouldProcess($spWebAppName, 'Copy SharePoint side-by-side files')) { - Copy-SPSideBySideFiles -Verbose - } - } - else { - if ($webApp.WebService.EnableSideBySide) { - Write-Output "Disabling EnableSideBySide on $spWebAppName Web Application" - if ($PSCmdlet.ShouldProcess($spWebAppName, 'Disable SharePoint side-by-side mode')) { - $webApp.WebService.EnableSideBySide = $false - $webApp.WebService.Update() - } - } - else { - Write-Output "EnableSideBySide is already disabled on $spWebAppName Web Application" - } - } - } - } - else { - throw 'Did not find SPWebApplication Object' - } -} -function Copy-SPSSideBySideFilesRemote { - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Server, - - [Parameter()] - [System.Management.Automation.PSCredential] - $InstallAccount - ) - - $result = Invoke-SPSCommand -Credential $InstallAccount ` - -Arguments @($PSBoundParameters, $MyInvocation.MyCommand.Source) ` - -Server $Server ` - -ScriptBlock { - $params = $args[0] - - Write-Output "Running CmdLet Copy-SPSideBySideFiles on server: $($params.Server)" - Copy-SPSideBySideFiles -Verbose - } - return $result -} - -Set-Alias -Name Copy-SPSSideBySideFilesAllServers -Value Copy-SPSSideBySideFilesRemote -function Initialize-SPSContentDbJsonFile { - [CmdletBinding()] - param - ( - [Parameter()] - [System.String] - $Path - ) - - #Initialize jSON Object, variables and class - New-Variable -Name jsonObject ` - -Description 'jSON object variable' ` - -Option AllScope ` - -Force - - $jsonObject = [PSCustomObject]@{} - $tbSPContentDb1 = New-Object -TypeName System.Collections.ArrayList - $tbSPContentDb2 = New-Object -TypeName System.Collections.ArrayList - $tbSPContentDb3 = New-Object -TypeName System.Collections.ArrayList - $tbSPContentDb4 = New-Object -TypeName System.Collections.ArrayList - class SPDbContent { - [System.String]$Name - [System.String]$Server - [System.String]$WebAppUrl - } - - #Get all content databases - $spAllDatabases = Get-SPContentDatabase -ErrorAction SilentlyContinue - - if ($null -ne $spAllDatabases) { - # --- LPT (Longest Processing Time First) scheduling --- - # Balance databases across 4 sequences by total DiskSizeRequired - # rather than by count, so parallel upgrade workloads finish closer - # to the same time. - - # 1. Sort databases by size descending - $spSortedDatabases = $spAllDatabases | - Sort-Object -Property DiskSizeRequired -Descending - - # 2. Track cumulative load (bytes) per sequence (index 0 = Seq1 .. 3 = Seq4) - $sequenceLoad = @(0.0, 0.0, 0.0, 0.0) - $sequenceLists = @($tbSPContentDb1, $tbSPContentDb2, $tbSPContentDb3, $tbSPContentDb4) - - # 3. Assign each database to the sequence with the lowest current load - foreach ($spDatabase in $spSortedDatabases) { - $minLoad = $sequenceLoad[0] - $minIndex = 0 - for ($s = 1; $s -lt 4; $s++) { - if ($sequenceLoad[$s] -lt $minLoad) { - $minLoad = $sequenceLoad[$s] - $minIndex = $s - } - } - [void]$sequenceLists[$minIndex].Add([SPDbContent]@{ - Name = $spDatabase.Name; - Server = $spDatabase.Server; - WebAppUrl = $spDatabase.WebApplication.Url; - }) - $sequenceLoad[$minIndex] += $spDatabase.DiskSizeRequired - } - - # --- Distribution report (visible in transcript) --- - $totalBytes = ($sequenceLoad | Measure-Object -Sum).Sum - $totalMB = [math]::Round($totalBytes / 1MB, 0) - $dbCount = @($spSortedDatabases).Count - Write-Output '--- ContentDatabase Distribution Report ---' - Write-Output ("Total : {0} database(s) | {1:N0} MB" -f $dbCount, $totalMB) - for ($s = 0; $s -lt 4; $s++) { - $loadMB = [math]::Round($sequenceLoad[$s] / 1MB, 0) - $pct = if ($totalBytes -gt 0) { - [math]::Round($sequenceLoad[$s] / $totalBytes * 100, 1) - } - else { 0 } - Write-Output (" Sequence {0} : {1,3} database(s) | {2,7:N0} MB | {3,5:N1}%" ` - -f ($s + 1), $sequenceLists[$s].Count, $loadMB, $pct) - } - Write-Output '-------------------------------------------' - - #Add each array to jsonObject - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name 'SPContentDatabase1' ` - -Value $tbSPContentDb1 - - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name 'SPContentDatabase2' ` - -Value $tbSPContentDb2 - - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name 'SPContentDatabase3' ` - -Value $tbSPContentDb3 - - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name 'SPContentDatabase4' ` - -Value $tbSPContentDb4 - - # Serialize once and write both the canonical file (consumed by SPSUpdate.ps1) - # and a timestamped snapshot in the same folder so previous inventories are - # retained for troubleshooting and rollback. - $jsonPayload = $jsonObject | ConvertTo-Json - $jsonPayload | Set-Content -Path $Path -Force - - try { - $snapshotDir = [System.IO.Path]::GetDirectoryName($Path) - $snapshotBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Path) - $snapshotExtension = [System.IO.Path]::GetExtension($Path) - $snapshotTimestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $snapshotFileName = '{0}_{1}{2}' -f $snapshotBaseName, $snapshotTimestamp, $snapshotExtension - $snapshotPath = if ([string]::IsNullOrEmpty($snapshotDir)) { - $snapshotFileName - } - else { - Join-Path -Path $snapshotDir -ChildPath $snapshotFileName - } - $jsonPayload | Set-Content -Path $snapshotPath -Force - Write-Output "ContentDatabase inventory snapshot saved to: $snapshotPath" - } - catch { - Write-Verbose -Message "Failed to write ContentDatabase inventory snapshot: $($_.Exception.Message)" - } - } -} - -function Get-SPSLocalVersionInfo { - [OutputType([System.Version])] - param - ( - # Parameter help description - [Parameter(Mandatory = $true)] - [ValidateSet('2016', '2019', 'SE')] - [System.String] - $ProductVersion, - - [Parameter()] - [Switch] - $IsWssPackage - ) - - if ($ProductVersion -eq 'SE') { - $spVersion = 'Subscription Edition' - } - else { - $spVersion = $ProductVersion - } - - $productNameRegEx = "Microsoft SharePoint (Foundation|Server) $($spVersion) Core" - if ($IsWssPackage) { - $productNameRegEx = "Microsoft SharePoint (Foundation|Server) $($spVersion) \d{4} (Lang|Language) Pack" - } - Write-Verbose "Product Name RegEx: $($productNameRegEx)" - $installerRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products" - $patchRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches" - $installerEntries = Get-ChildItem -Path $installerRegistryPath -ErrorAction SilentlyContinue - $nullVersion = New-Object -TypeName System.Version - $versionInfoValue = New-Object -TypeName System.Version - $officeProductKeys = $installerEntries | Where-Object -FilterScript { $_.PsPath -like "*00000000F01FEC" } - - if ($null -eq $installerEntries -or $null -eq $officeProductKeys ) { - return $nullVersion - } - - # $null - one command returns an empty value - $null = $officeProductKeys | ForEach-Object -Process { - $officeProductKey = $_ - $productInfo = Get-ItemProperty "Registry::$($officeProductKey)\InstallProperties" -ErrorAction SilentlyContinue - if ($null -eq $productInfo) { - return - } - $prodName = $productInfo.DisplayName - if ($prodName -match $productNameRegEx) { - Write-Verbose "Gathering Information for $($prodName)" - $versionInfo = $nullVersion - $patchInformationFolder = Get-ItemProperty "Registry::$($officeProductKey)\Patches" - $patchGuid = $patchInformationFolder.AllPatches - if ($null -ne $patchGuid) { - $detailedPatchInformation = Get-ItemProperty "$($patchRegistryPath)\$($patchGuid)" -ErrorAction SilentlyContinue - $localPackage = $detailedPatchInformation.LocalPackage - if ($null -ne $localPackage) { - $patchFileInformation = New-Object -TypeName System.IO.FileInfo -ArgumentList $localPackage - if ($patchFileInformation.Extension -eq ".msp") { - try { - $windowsInstaller = New-Object -ComObject WindowsInstaller.Installer - $installerDatabase = $windowsInstaller.GetType().InvokeMember("OpenDatabase", "InvokeMethod", $null, $windowsInstaller, ($localPackage , 32)) - $databaseQuery = "SELECT Value FROM MsiPatchMetadata WHERE Property = 'BuildNumber'" - $databaseView = $installerDatabase.GetType().InvokeMember("OpenView", "InvokeMethod", $null, $installerDatabase, ($databaseQuery)) - $databaseView.GetType().InvokeMember("Execute", "InvokeMethod", $null, $databaseView, $null) - $value = $databaseView.GetType().InvokeMember("Fetch", "InvokeMethod", $null, $databaseView, $null) - $versionInfo = [System.Version]$value.GetType().InvokeMember("StringData", "GetProperty", $null, $value, 1) - Clear-ComObject -ComObject $databaseView - Clear-ComObject -ComObject $value - Clear-ComObject -ComObject $installerDatabase - Clear-ComObject -ComObject $windowsInstaller - } - catch [Exception] { - $catchMessage = @" -An error occurred during the collection of data about installed products in Get-SPSLocalVersionInfo. -Exception: $($_.Exception.Message) -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-SPSLocalVersionInfo' -EntryType 'Error' - $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion - } - } - else { - $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion - } - } - else { - $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion - } - } - else { - $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion - } - # Collect Information about language packs - if ($IsWssPackage -and ( $versionInfoValue -eq $nullVersion -or $versionInfoValue -gt $versionInfo)) { - $versionInfoValue = $versionInfo - } - else { - $versionInfoValue = $versionInfo - } - Write-Verbose "Version Information for $($prodName): $($versionInfoValue)" - } - } - - if ($nullVersion -ne $versionInfoValue) { - return $versionInfoValue - } - - return $nullVersion -} - -function Start-SPSProductUpdate { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $SetupFile, - - [Parameter(Mandatory = $true)] - [System.Boolean] - $ShutdownServices - ) - - Write-Verbose -Message "Getting install status of SP binaries" - Write-Verbose -Message "Check if the setup file exists" - if (-Not (Test-Path -Path $SetupFile)) { - Throw "ERROR: Setup files could not be found: $SetupFile" - } - - Write-Verbose -Message "Checking file status of $SetupFile" - Write-Verbose -Message "Checking status now" - try { - $zone = Get-Item -Path $SetupFile -Stream "Zone.Identifier" -EA SilentlyContinue - } - catch { - Write-Verbose -Message 'Encountered error while reading file stream. Ignoring file stream.' - } - - if ($null -ne $zone) { - $catchMessage = @" -Setup file is blocked! Please use 'Unblock-File -Path $SetupFile' to unblock the file before continuing. -"@ - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' - throw $catchMessage - } - Write-Verbose -Message "File not blocked, continuing." - Write-Verbose -Message "Get file information from setup file" - $setupFileInfo = Get-ItemProperty -Path $SetupFile - $fileVersion = $setupFileInfo.VersionInfo.FileVersion - Write-Verbose -Message "Update has version $fileVersion" - $fileVersionInfo = New-Object -TypeName System.Version -ArgumentList $fileVersion - if ($fileVersionInfo.Build.ToString().Length -eq 4) { - $sharePointVersion = '2016' - } - else { - if ($fileVersionInfo.Build -lt 13000) { - $sharePointVersion = '2019' - } - else { - $sharePointVersion = 'SE' - } - } - - Write-Verbose -Message "Update is a Cumulative Update." - # For SP 2016 + 2019 Patches - $setupFileInformation = New-Object -TypeName System.IO.FileInfo -ArgumentList $SetupFile - if ($setupFileInformation.Name.StartsWith("wssloc")) { - Write-Verbose -Message "Cumulative Update is multilingual" - $versionInfo = Get-SPSLocalVersionInfo -ProductVersion $sharePointVersion -IsWssPackage - } - else { - Write-Verbose -Message "Cumulative Update is generic" - $versionInfo = Get-SPSLocalVersionInfo -ProductVersion $sharePointVersion - } - - Write-Verbose -Message "The lowest version of any SharePoint component is $($versionInfo)" - if ($versionInfo -lt $fileVersionInfo) { - # Version of SharePoint is lower than the patch version. Patch is not installed. - Write-Verbose -Message "The version of SharePoint installed is lower than the update. Starting update process." - $installedVersion = Get-SPSInstalledProductVersion - if ($ShutdownServices) { - $listOfServices = @("SPSearchHostController", "SPTimerV4", "IISADMIN") - if ($installedVersion.ProductMajorPart -eq 15) { - - $listOfServices += "OSearch15" - } - else { - $listOfServices += "OSearch16" - } - Write-Verbose -Message "Gettings services status before stopping services for installation." - $servicesStatusFilePath = Join-Path -Path $PSScriptRoot -ChildPath "ServicesStatus_$($env:COMPUTERNAME)_$(Get-Date -Format 'yyyyMMddHHmmss').json" - Get-Service -Name $listOfServices -ErrorAction SilentlyContinue | Select-Object Name, StartType, Status | ConvertTo-Json | Set-Content -Path $servicesStatusFilePath -Force - Write-Verbose -Message "Services status saved to $servicesStatusFilePath" - Write-Verbose -Message "Stopping services to speed up installation process" - foreach ($service in $listOfServices) { - Write-Verbose -Message "Stopping service: $service - Setting startup type to disabled" - Set-Service -Name $service -StartupType Disabled -ErrorAction SilentlyContinue - Stop-Service -Name $service -Force -ErrorAction SilentlyContinue - } - write-Verbose -Message "All services stopped. Starting installation process." - $null = Start-Process -FilePath "iisreset.exe" ` - -ArgumentList "-stop -noforce" ` - -Wait ` - -PassThru - - - } - - $setupInstall = Start-Process -FilePath $SetupFile -ArgumentList '/passive' -Wait -PassThru - # Error codes: https://aka.ms/installerrorcodes - switch ($setupInstall.ExitCode) { - 0 { - Write-Verbose -Message "SharePoint update binary installation complete." - } - 17022 { - Write-Verbose -Message ("SharePoint update binary installation complete, however a reboot is required.") - } - 17025 { - Write-Verbose -Message ("The SharePoint update was already installed on your system." + ` - "Please report an issue about this behavior at https://github.com/dsccommunity/SharePointDsc") - } - Default { - $catchMessage = @" -SharePoint update install failed, exit code was $($setupInstall.ExitCode). -Error codes can be found at https://aka.ms/installerrorcodes -"@ - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' - throw $catchMessage - } - } - if ($ShutdownServices) { - Write-Verbose -Message "Getting services status from json configuration." - $servicesStatusFromFile = Get-Content -Path $servicesStatusFilePath -Raw | ConvertFrom-Json - foreach ($service in $servicesStatusFromFile) { - Write-Verbose -Message "Service: $($service.Name) - Startup Type before installation: $($service.StartType) - Status before installation: $($service.Status)" - if ($service.Status -ne "Running") { - Write-Verbose -Message "Service: $($service.Name) was not running before installation. Keeping it stopped." - Set-Service -Name $service.Name -StartupType $service.StartType -ErrorAction SilentlyContinue - } - else { - Write-Verbose -Message "Service: $($service.Name) was running before installation. Restoring its startup type." - Set-Service -Name $service.Name -StartupType $service.StartType -ErrorAction SilentlyContinue - Start-Service -Name $service.Name -ErrorAction SilentlyContinue - } - } - Start-Process -FilePath "iisreset.exe" ` - -ArgumentList "-start" ` - -Wait ` - -PassThru - write-Verbose -Message "All services started. Installation process complete." - } - } - else { - # Version of SharePoint is equal or greater than the patch version. Patch is installed. - Write-Verbose -Message "The version of SharePoint installed is equal or higher than the update. No action needed." - } -} diff --git a/scripts/Modules/util.psm1 b/scripts/Modules/util.psm1 deleted file mode 100644 index cf683ff..0000000 --- a/scripts/Modules/util.psm1 +++ /dev/null @@ -1,393 +0,0 @@ -#region Import Modules -# Import the custom module 'sps.util.psm1' from the script's directory -try { - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'sps.util.psm1') -Force -} -catch { - # Handle errors during Import of helper module - Write-Error -Message @" -Failed to import sps.util.psm1 module from path: $($script:PSScriptRoot) -Exception: $_ -"@ - Exit -} -#endregion - -function Get-SPSInstalledProductVersion { - [OutputType([System.Diagnostics.FileVersionInfo])] - param () - - $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' - $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 - if ($null -eq $fullPath) { - Write-Error -Message 'SharePoint path {C:\Program Files\Common Files\microsoft shared\Web Server Extensions} does not exist' - } - else { - return [System.Diagnostics.FileVersionInfo]::GetVersionInfo($fullPath.FullName) - } -} -function Add-SPSUpdateEvent { - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Message, - - [Parameter(Mandatory = $true)] - [System.String] - $Source, - - [Parameter()] - [ValidateSet('Error', 'Information', 'FailureAudit', 'SuccessAudit', 'Warning')] - [System.String] - $EntryType = 'Information', - - [Parameter()] - [System.UInt32] - $EventID = 1 - ) - - $LogName = 'SPSUpdate' - - if ([System.Diagnostics.EventLog]::SourceExists($Source)) { - $sourceLogName = [System.Diagnostics.EventLog]::LogNameFromSourceName($Source, ".") - if ($LogName -ne $sourceLogName) { - Write-Verbose -Message "[ERROR] Specified source {$Source} already exists on log {$sourceLogName}" - return - } - } - else { - if ([System.Diagnostics.EventLog]::Exists($LogName) -eq $false) { - #Create event log - $null = New-EventLog -LogName $LogName -Source $Source - } - else { - [System.Diagnostics.EventLog]::CreateEventSource($Source, $LogName) - } - } - - try { - $headerMessage = @" -SPSUpdate Script Version: $spsUpdateVersion -User: $currentUser -ComputerName: $($env:COMPUTERNAME) --------------------------------------------------------------- -"@ - Write-EventLog -LogName $LogName -Source $Source -EventId $EventID -Message ($headerMessage + "`r`n" + $Message) -EntryType $EntryType - } - catch { - Write-Error -Message @" -SPSUpdate Script Version: $spsUpdateVersion -An error occurred while adding Event Log in Source: $Source -User: $currentUser -ComputerName: $($env:COMPUTERNAME) -Exception: $_ -"@ - } -} - -function Invoke-SPSCommand { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true)] - [System.Management.Automation.PSCredential] - $Credential, # Credential to be used for executing the command - - [Parameter()] - [Object[]] - $Arguments, # Optional arguments for the script block - - [Parameter(Mandatory = $true)] - [ScriptBlock] - $ScriptBlock, # Script block containing the commands to execute - - [Parameter(Mandatory = $true)] - [System.String] - $Server # Target server where the commands will be executed - ) - $VerbosePreference = 'Continue' - # Base script to ensure the SharePoint snap-in is loaded - $installedVersion = Get-SPSInstalledProductVersion - if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { - $baseScript = @" - if (`$null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) - { - Add-PSSnapin Microsoft.SharePoint.PowerShell - } -"@ - } - else { - $baseScript = '' - } - # Prepare the arguments for Invoke-Command - $invokeArgs = @{ - ScriptBlock = [ScriptBlock]::Create($baseScript + $ScriptBlock.ToString()) - } - # Add arguments if provided - if ($null -ne $Arguments) { - $invokeArgs.Add("ArgumentList", $Arguments) - } - # Ensure a credential is provided - if ($null -eq $Credential) { - throw 'You need to specify a Credential' - } - else { - Write-Verbose -Message ("Executing using a provided credential and local PSSession " + "as user $($Credential.UserName)") - # Running garbage collection to resolve issues related to Azure DSC extension use - [GC]::Collect() - # Create a new PowerShell session on the target server using the provided credentials - $session = New-PSSession -ComputerName $Server ` - -Credential $Credential ` - -Authentication CredSSP ` - -Name "Microsoft.SharePoint.PSSession" ` - -SessionOption (New-PSSessionOption -OperationTimeout 0 -IdleTimeout 60000) ` - -ErrorAction Continue - - # Add the session to the invocation arguments if the session is created successfully - if ($session) { - $invokeArgs.Add("Session", $session) - } - try { - # Invoke the command on the target server - return Invoke-Command @invokeArgs -Verbose - } - catch { - throw $_ # Throw any caught exceptions - } - finally { - # Remove the session to clean up - if ($session) { - Remove-PSSession -Session $session - } - } - } -} - -function Test-SPSPendingReboot { - [CmdletBinding()] - param () - - $rebootReasons = New-Object -TypeName System.Collections.Generic.List[string] - - $registryChecks = @( - @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'; Reason = 'WindowsUpdateRebootRequired' }, - @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'; Reason = 'ComponentBasedServicingRebootPending' }, - @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootInProgress'; Reason = 'ComponentBasedServicingRebootInProgress' }, - @{ Path = 'HKLM:\SOFTWARE\Microsoft\ServerManager\CurrentRebootAttempts'; Reason = 'ServerManagerCurrentRebootAttempts' } - ) - - foreach ($check in $registryChecks) { - if (Test-Path -Path $check.Path -ErrorAction SilentlyContinue) { - $rebootReasons.Add($check.Reason) - } - } - - # WindowsUpdate\Services\Pending can exist even after reboot; require at least one child entry. - $wuServicesPendingPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending' - if (Test-Path -Path $wuServicesPendingPath -ErrorAction SilentlyContinue) { - $wuPendingEntries = Get-ChildItem -Path $wuServicesPendingPath -ErrorAction SilentlyContinue - if ($null -ne $wuPendingEntries -and $wuPendingEntries.Count -gt 0) { - $rebootReasons.Add('WindowsUpdateServicesPending') - } - } - - $sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -ErrorAction SilentlyContinue - if ($null -ne $sessionManager -and $null -ne $sessionManager.PendingFileRenameOperations -and $sessionManager.PendingFileRenameOperations.Count -gt 0) { - $rebootReasons.Add('PendingFileRenameOperations') - } - - $activeComputerName = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -Name ComputerName -ErrorAction SilentlyContinue - $pendingComputerName = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -Name ComputerName -ErrorAction SilentlyContinue - if ($null -ne $activeComputerName -and $null -ne $pendingComputerName -and $activeComputerName.ComputerName -ne $pendingComputerName.ComputerName) { - $rebootReasons.Add('PendingComputerRename') - } - - if (Test-Path -Path 'HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client\Reboot Management\RebootData' -ErrorAction SilentlyContinue) { - $rebootReasons.Add('ConfigMgrRebootPending') - } - - return [PSCustomObject]@{ - IsPending = ($rebootReasons.Count -gt 0) - Reasons = $rebootReasons.ToArray() - } -} - -function Add-SPSScheduledTask { - param - ( - [Parameter(Mandatory = $true)] - [System.Management.Automation.PSCredential] - $ExecuteAsCredential, # Credentials for Task Schedule - - [Parameter(Mandatory = $true)] - [System.String] - $ActionArguments, # Arguments for the task action - - [Parameter(Mandatory = $true)] - [System.String] - $Name, # Name of the scheduled task to be added - - [Parameter()] - [System.String] - $Description, # Description of the scheduled task to be added - - [Parameter()] - [System.String] - $TaskPath = 'SharePoint' # Path of the task folder - ) - - # Initialize variables - $TaskCmd = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' # Path to PowerShell executable - $UserName = $ExecuteAsCredential.UserName - $Password = $ExecuteAsCredential.GetNetworkCredential().Password - - # Connect to the local TaskScheduler Service - $TaskSvc = New-Object -ComObject ('Schedule.service') - $TaskSvc.Connect($env:COMPUTERNAME) - - # Check if the folder exists, if not, create it - try { - $TaskFolder = $TaskSvc.GetFolder($TaskPath) # Attempt to get the task folder - } - catch { - Write-Output "Task folder '$TaskPath' does not exist. Creating folder..." - $RootFolder = $TaskSvc.GetFolder('\') # Get the root folder - $RootFolder.CreateFolder($TaskPath) # Create the missing task folder - $TaskFolder = $TaskSvc.GetFolder($TaskPath) # Get the newly created folder - Write-Output "Successfully created task folder '$TaskPath'" - } - - Write-Output '--------------------------------------------------------------' - Write-Output "Adding or updating '$Name' script in Task Scheduler Service ..." - - # Get credentials for Task Schedule - $TaskAuthor = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name # Author of the task - $TaskUser = $UserName # Username for task registration - $TaskUserPwd = $Password # Password for task registration - - # Add a new Task Schedule - $TaskSchd = $TaskSvc.NewTask(0) - $TaskSchd.RegistrationInfo.Description = "$($Description)" # Task description - $TaskSchd.RegistrationInfo.Author = $TaskAuthor # Task author - $TaskSchd.Principal.RunLevel = 1 # Task run level (1 = Highest) - - # Task Schedule - Modify Settings Section - $TaskSettings = $TaskSchd.Settings - $TaskSettings.AllowDemandStart = $true - $TaskSettings.Enabled = $true - $TaskSettings.Hidden = $false - $TaskSettings.StartWhenAvailable = $true - - # Define the task action - $TaskAction = $TaskSchd.Actions.Create(0) # 0 = Executable action - $TaskAction.Path = $TaskCmd # Path to the executable - $TaskAction.Arguments = $ActionArguments # Arguments for the executable - - try { - # Register/update the task (6 = create or update) - $TaskFolder.RegisterTaskDefinition($Name, $TaskSchd, 6, $TaskUser, $TaskUserPwd, 1) - Write-Output "Successfully added or updated '$Name' script in Task Scheduler Service" - } - catch { - $catchMessage = @" -An error occurred while adding/updating the script in scheduled task: $($Name) -ActionArguments: $($ActionArguments) -Exception: $($_.Exception.Message) -"@ - Write-Error -Message $catchMessage # Handle any errors during task registration - } -} - -function Remove-SPSScheduledTask { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param ( - [Parameter(Mandatory = $true)] - [System.String] - $Name, # Name of the scheduled task to be removed - - [Parameter()] - [System.String] - $TaskPath = 'SharePoint' # Path of the task folder - ) - - # Connect to the local TaskScheduler Service - $TaskSvc = New-Object -ComObject ('Schedule.service') - $TaskSvc.Connect($env:COMPUTERNAME) - - # Check if the folder exists - try { - $TaskFolder = $TaskSvc.GetFolder($TaskPath) # Attempt to get the task folder - } - catch { - Write-Output "Task folder '$TaskPath' does not exist." - } - - # Retrieve the scheduled task - $getScheduledTask = $TaskFolder.GetTasks(0) | Where-Object -FilterScript { - $_.Name -eq $Name - } - - if ($null -eq $getScheduledTask) { - Write-Warning -Message 'Scheduled Task already removed - skipping.' # Task not found - } - else { - Write-Output '--------------------------------------------------------------' - Write-Output "Removing $($Name) script in Task Scheduler Service ..." - try { - if ($PSCmdlet.ShouldProcess($Name, 'Remove scheduled task')) { - $TaskFolder.DeleteTask($Name, $null) # Remove the task - Write-Output "Successfully removed $($Name) script from Task Scheduler Service" - } - } - catch { - $catchMessage = @" -An error occurred while removing the script in scheduled task: $($Name) -Exception: $($_.Exception.Message) -"@ - Write-Error -Message $catchMessage # Handle any errors during task removal - } - } -} - -function Start-SPSScheduledTask { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Name, - - [Parameter()] - [System.String] - $TaskPath = 'SharePoint' - ) - - $normalizedTaskPath = if ($TaskPath.StartsWith('\')) { - $TaskPath - } - else { - "\$TaskPath" - } - if (-not $normalizedTaskPath.EndsWith('\')) { - $normalizedTaskPath = "$normalizedTaskPath\" - } - - $getScheduledTask = Get-ScheduledTask -TaskName $Name -TaskPath $normalizedTaskPath -ErrorAction SilentlyContinue - if (-not $getScheduledTask) { - throw "Scheduled Task $Name does not exist in $TaskPath Task Path" - } - - if ($PSCmdlet.ShouldProcess($Name, 'Start scheduled task')) { - Start-ScheduledTask -TaskName $Name ` - -TaskPath $normalizedTaskPath ` - -ErrorAction Stop - } - - $startedTask = Get-ScheduledTask -TaskName $Name -TaskPath $normalizedTaskPath -ErrorAction Stop - return [PSCustomObject]@{ - Name = $Name - TaskPath = $normalizedTaskPath - State = $startedTask.State - } -} diff --git a/scripts/SPSUpdate_README.md b/scripts/SPSUpdate_README.md deleted file mode 100644 index 8165635..0000000 --- a/scripts/SPSUpdate_README.md +++ /dev/null @@ -1,176 +0,0 @@ -# SPSUpdate Installation Guide - -This document provides instructions for installing and configuring the **SPSUpdate** PowerShell script in environments without internet access. It is intended for SharePoint On-Premises administrators who need to install cumulative updates in your SharePoint environment. - -## 📦 Prerequisites - -- SharePoint Server (2016 or later) -- Administrator privileges on the server -- PowerShell 5.1 or later -- Valid credentials for task scheduler setup -- StoredCredential configured (if using Install) -- CredSSP configured - -## 📁 Files Required - -Ensure the following files are available locally: - -- `SPSUpdate.ps1` (main script) -- Any dependencies or modules used by the script (if applicable) - -## 🛠 Installation Steps - -### 1. Copy Files to Server - -Place `SPSUpdate.ps1` and any dependencies or modules in a local folder on the SharePoint server, e.g., `E:\SCRIPT\`. - -### 2. Prepare your JSON configuration - -To customize the script for your environment, you need to prepare a JSON configuration file. Below is a sample structure for the file: - -```json -{ - "$schema": "http://json-schema.org/schema#", - "contentVersion": "1.0.0.0", - "ConfigurationName": "PROD", - "ApplicationName": "contoso", - "FarmName": "CONTENT", - "Domain": "contoso.com", - "StoredCredential": "PROD-ADM", - "Binaries": { - "ProductUpdate": true, - "SetupFullPath": "D:\\SoftwarePackages\\SPS\\cumulativeupdates", - "SetupFileName": ["uber-subscription-kb5002651-fullfile-x64-glb.exe"], - "ShutdownServices": false - }, - "UpgradeContentDatabase": true, - "MountContentDatabase": false, - "SideBySideToken": { - "Enable": true, - "BuildVersion": "16.0.17928.20238" - } -} -``` - -#### Configuration, Application and FarmName - -`ConfigurationName` is used to populate the content of `Environment` PowerShell Variable. -`ApplicationName` is used to populate the content of `Application` PowerShell Variable. -`FarmName` is used to populate the content of `FarmName` PowerShell Variable. - -#### Credential Manager - -`StoredCredential` is refered to the target of your credential that you used during the installation processus. - -#### Binaries settings - -Use `ProductUpdate`, `SetupFullPath`, `SetupFileName` and `ShutdownServices` parameters to configure your binaries settings in your environment - -#### UpgradeContentDatabase - -The `UpgradeContentDatabase` parameter can be used to run upgrade-SPContentDatabase in parallel. - -The authorized values are : `true`, and `false`. - -#### MountContentDatabase - -The `MountContentDatabase` parameter can be used to mount content databases on the target -farm before the upgrade. This is typically used during a farm migration scenario (for -example SharePoint Server 2019 → Subscription Edition) where databases coming from the -source farm have been restored on the SQL Server of the target farm and now need to be -attached to it. - -When set to `true`, the script loads the ContentDatabase inventory JSON file -(`---ContentDBs.json`) and runs -`Mount-SPContentDatabase` for each database that is not already attached. Mounts are -performed sequentially on the master server to avoid concurrent writes to the -configuration database. The inventory JSON file is normally generated on the source farm -with the `InitContentDB` action and then copied next to the script on the target farm. - -The authorized values are : `true`, and `false`. - -#### SideBySideToken - -Use `Enable` to enable sidebysidetoken feature. -Use `BuildVersion` to set build version used in sidebysitetoken feature. - -### 3. Run Script with Install Action parameter - -Open PowerShell as Administrator and execute: - -```powershell -E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'E\SCRIPTS\Config\contoso-PROD-CONTENT.json' -``` - -This will: - -- Validate credentials -- Add one scheduled task to run if The `UpgradeContentDatabase` parameter is set to `false` -- Add five scheduled tasks to run if The `UpgradeContentDatabase` parameter is set to `true` -- Save all content database in `contoso-PROD-CONTENT-ContentDBs.json` file - -### 4. Run Script with Install ProductUpdate parameter - -Place `SPSUpdate.ps1`, any dependencies or modules and the configuration file in a local folder on each other SharePoint server, e.g., `E:\SCRIPT\`. - -On each SharePoint Server, open PowerShell as Administrator and execute: - -```powershell -E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'E\SCRIPTS\Config\contoso-PROD-CONTENT.json' -``` - -This will: - -- Unblock cumulative update files if it is blocked -- Running Start-SPSProductUpdate function - -> Note: Starting with version 3.2.0 the ProductUpdate action no longer aborts when the -> Windows reboot markers (Component Based Servicing, PendingFileRenameOperations, etc.) -> are still present, because those markers were observed to persist on healthy production -> farms even after multiple reboots and were blocking legitimate updates. - -### 5. Run Script with InitContentDB Action parameter (source farm) - -The `InitContentDB` action (re)generates the ContentDatabase inventory JSON file -(`---ContentDBs.json`) located in the -`Config` folder next to the script. It is typically used on the source farm before a -farm upgrade (for example SharePoint Server 2019 → Subscription Edition) so that the -generated inventory can be copied to the target farm to drive the -`MountContentDatabase` step. - -On the source SharePoint Server, open PowerShell as Administrator and execute: - -```powershell -E:\SCRIPT\SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'E\SCRIPTS\Config\contoso-PROD-CONTENT.json' -``` - -This will: - -- Run `Initialize-SPSContentDbJsonFile` against the local farm -- Overwrite any existing inventory file so it always reflects the current state of the farm -- Produce a JSON file split into 4 balanced groups (`SPContentDatabase1` to `SPContentDatabase4`) that can be consumed by the Mount and parallel Upgrade flows on the target farm - -## 🔄 Uninstalling - -```powershell -E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall -``` - -## 📚 Additional Notes - -- The script automatically creates Logs folder and per-run log file (sequence-aware naming) and starts a transcript (Start-Transcript) for full output capture. -- It verifies script is running with Administrator rights before proceeding. -- It detects installed SharePoint version (Get-SPSInstalledProductVersion) and loads the appropriate SharePoint snap-in or module. -- The Full-run mode creates 4 sequence tasks (SPSUpdate-Sequence1..4) and starts them in parallel (with random short sleeps to avoid OWSTimer conflicts) -- When `MountContentDatabase` is `true`, the master server attaches the content databases listed in the inventory JSON file (skipping databases that are already mounted) before launching the parallel upgrade tasks. -- The script runs Start-SPSConfigExe locally and Start-SPSConfigExeRemote for other servers; configures SideBySide token (Set-SPSSideBySideToken) and copies side-by-side files remotely if enabled. - -## 📄 License - -MIT License - -## 👤 Authors - -- Jean-Cyril Drouhin (luigilink) - -For more details, refer to the embedded comments in `SPSUpdate.ps1`. diff --git a/src/Config/CONTOSO-PROD.example.psd1 b/src/Config/CONTOSO-PROD.example.psd1 new file mode 100644 index 0000000..c307dd9 --- /dev/null +++ b/src/Config/CONTOSO-PROD.example.psd1 @@ -0,0 +1,89 @@ +@{ + # ================================================================================= + # SPSUpdate - environment configuration (example) + # + # Copy this file to a real config (e.g. CONTOSO-PROD-CONTENT.psd1) and edit the + # values for your environment, then run: + # .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + # + # Real config files (Config\*.psd1) are gitignored so internal infrastructure + # details (server names, domains, build versions) never land in version control. + # Only this *.example.psd1 template is committed. + # + # Convention: keep one config file per farm (CONTENT, SEARCH, SERVICES, ...). The + # keys below are identical across farms; only the values change. + # ================================================================================= + + # --- Identity (all REQUIRED) ----------------------------------------------------- + + # ConfigurationName : free-form environment identifier. Used in log/result file names. + # Possible values : any string, e.g. 'PROD', 'PPRD', 'PREPROD', 'DEV', 'TEST'. + ConfigurationName = 'PROD' + + # ApplicationName : free-form application/customer code. Used in log/result file names. + # Possible values : any string, e.g. 'contoso'. + ApplicationName = 'contoso' + + # FarmName : logical name of the farm targeted by this config. Used in logs and in + # the generated ContentDB inventory file name (---ContentDBs.json). + # Possible values : any string, e.g. 'CONTENT', 'SEARCH', 'SERVICES'. + FarmName = 'CONTENT' + + # Domain : DNS suffix appended to each farm server short name when remoting (CredSSP). + # Possible values : any AD DNS domain, e.g. 'contoso.com', 'corp.contoso.local'. + Domain = 'contoso.com' + + # CredentialKey : name of the entry in Config\secrets.psd1 holding the InstallAccount + # used for CredSSP remoting and to run the scheduled tasks. Populate it by running + # -Action Install as that account, or manually (see secrets.example.psd1). + # Possible values : any key present in secrets.psd1, e.g. 'PROD-ADM'. + CredentialKey = 'PROD-ADM' + + # --- Binaries (REQUIRED block; used by -Action ProductUpdate) --------------------- + Binaries = @{ + # ProductUpdate : allow the binary installation step. + # Possible values : $true | $false. Default if omitted: $true + ProductUpdate = $true + + # SetupFullPath : folder (local to each server) that holds the update binaries. + # Possible values : any absolute Windows path. + SetupFullPath = 'D:\SoftwarePackages\SPS\cumulativeupdates' + + # SetupFileName : update executable(s), installed in the listed order. Provide a + # single uber package, or the STS + WSSLOC (language) pair. + # Possible values : array of .exe file names located under SetupFullPath. + SetupFileName = @( + 'uber-subscription-kb5002651-fullfile-x64-glb.exe' + # 'sts-subscription-kb5002191-fullfile-x64-glb.exe' + # 'wssloc-subscription-kb5002110-fullfile-x64-glb.exe' + ) + + # ShutdownServices : stop Search/Timer/IIS services during install to speed it up + # (they are restored to their prior state afterwards). + # Possible values : $true | $false. Default if omitted: $true + ShutdownServices = $false + } + + # --- Content database handling (OPTIONAL) ---------------------------------------- + + # MountContentDatabase : mount the databases listed in the generated ContentDB + # inventory (typically for a SP2019 -> Subscription Edition migration). When either + # this or UpgradeContentDatabase is $true, the inventory JSON is (re)built on run. + # Possible values : $true | $false. Default if omitted: $false + MountContentDatabase = $false + + # UpgradeContentDatabase : run Upgrade-SPContentDatabase on databases that NeedsUpgrade. + # Possible values : $true | $false. Default if omitted: $true + UpgradeContentDatabase = $true + + # --- Side-by-side patching (OPTIONAL block) -------------------------------------- + SideBySideToken = @{ + # Enable : turn EnableSideBySide on the web applications and copy side-by-side files. + # Possible values : $true | $false. Default if omitted: $false + Enable = $false + + # BuildVersion : the side-by-side token build. Leave empty to skip token config. + # Possible values : '' or a SharePoint build, e.g. '16.0.17928.20238'. + BuildVersion = '' + } +} diff --git a/src/Config/secrets.example.psd1 b/src/Config/secrets.example.psd1 new file mode 100644 index 0000000..0578735 --- /dev/null +++ b/src/Config/secrets.example.psd1 @@ -0,0 +1,26 @@ +@{ + # ================================================================================= + # SPSUpdate - secrets configuration (example) + # + # Copy this file to secrets.psd1 (same Config folder) and replace the placeholder + # with a real encrypted value. The real secrets.psd1 is gitignored and MUST NEVER + # be committed to version control. + # + # Each key (e.g. 'PROD-ADM') matches the CredentialKey of an environment config + # (CONTOSO-PROD-CONTENT.psd1). PasswordSecure must be a SecureString encrypted with + # the current user's DPAPI key. Generate it ON THE TARGET SERVER, signed in as the + # InstallAccount that will run the scheduled tasks, with: + # + # PS> Read-Host -AsSecureString -Prompt 'Password' | ConvertFrom-SecureString + # + # Paste the resulting string between the single quotes below. The encrypted value + # can only be decrypted by the same user account on the same machine. + # + # Tip: running .\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) ... + # AS that InstallAccount writes this entry for you automatically. + # ================================================================================= + 'PROD-ADM' = @{ + Username = 'CONTOSO\svc_spsupdate' + PasswordSecure = 'PASTE-ConvertFrom-SecureString-OUTPUT-HERE' + } +} diff --git a/src/Modules/SPSUpdate.Common/Private/Clear-ComObject.ps1 b/src/Modules/SPSUpdate.Common/Private/Clear-ComObject.ps1 new file mode 100644 index 0000000..bb027df --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Clear-ComObject.ps1 @@ -0,0 +1,22 @@ +function Clear-ComObject { + [CmdletBinding()] + param + ( + [Parameter()] + [System.Object] + $ComObject + ) + + if ($null -eq $ComObject) { + return + } + + try { + if ([System.Runtime.InteropServices.Marshal]::IsComObject($ComObject)) { + [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($ComObject) + } + } + catch { + Write-Verbose -Message "Unable to release COM object: $($_.Exception.Message)" + } +} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSConfigRoot.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSConfigRoot.ps1 new file mode 100644 index 0000000..1de5351 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSConfigRoot.ps1 @@ -0,0 +1,26 @@ +function Get-SPSConfigRoot { + <# + .SYNOPSIS + Resolves the default location of the Config folder. + + .DESCRIPTION + The Config folder lives in src/Config/, two levels up from the module + root (src/Modules/SPSUpdate.Common). This helper centralizes that path + resolution so the secret accessors share one default. Callers always + override it by passing -ConfigPath (the entry script passes the Config + folder next to SPSUpdate.ps1). + + .EXAMPLE + Get-SPSConfigRoot + #> + [CmdletBinding()] + [OutputType([System.String])] + param () + + if ([string]::IsNullOrEmpty($script:ModuleRoot)) { + throw "Module is not loaded correctly: `$script:ModuleRoot is not set. Re-import SPSUpdate.Common." + } + + $srcRoot = Split-Path -Parent (Split-Path -Parent $script:ModuleRoot) + return Join-Path -Path $srcRoot -ChildPath 'Config' +} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSLocalVersionInfo.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSLocalVersionInfo.ps1 new file mode 100644 index 0000000..f94f245 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSLocalVersionInfo.ps1 @@ -0,0 +1,108 @@ +function Get-SPSLocalVersionInfo { + [OutputType([System.Version])] + param + ( + # Parameter help description + [Parameter(Mandatory = $true)] + [ValidateSet('2016', '2019', 'SE')] + [System.String] + $ProductVersion, + + [Parameter()] + [Switch] + $IsWssPackage + ) + + if ($ProductVersion -eq 'SE') { + $spVersion = 'Subscription Edition' + } + else { + $spVersion = $ProductVersion + } + + $productNameRegEx = "Microsoft SharePoint (Foundation|Server) $($spVersion) Core" + if ($IsWssPackage) { + $productNameRegEx = "Microsoft SharePoint (Foundation|Server) $($spVersion) \d{4} (Lang|Language) Pack" + } + Write-Verbose "Product Name RegEx: $($productNameRegEx)" + $installerRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products" + $patchRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches" + $installerEntries = Get-ChildItem -Path $installerRegistryPath -ErrorAction SilentlyContinue + $nullVersion = New-Object -TypeName System.Version + $versionInfoValue = New-Object -TypeName System.Version + $officeProductKeys = $installerEntries | Where-Object -FilterScript { $_.PsPath -like "*00000000F01FEC" } + + if ($null -eq $installerEntries -or $null -eq $officeProductKeys ) { + return $nullVersion + } + + # $null - one command returns an empty value + $null = $officeProductKeys | ForEach-Object -Process { + $officeProductKey = $_ + $productInfo = Get-ItemProperty "Registry::$($officeProductKey)\InstallProperties" -ErrorAction SilentlyContinue + if ($null -eq $productInfo) { + return + } + $prodName = $productInfo.DisplayName + if ($prodName -match $productNameRegEx) { + Write-Verbose "Gathering Information for $($prodName)" + $versionInfo = $nullVersion + $patchInformationFolder = Get-ItemProperty "Registry::$($officeProductKey)\Patches" + $patchGuid = $patchInformationFolder.AllPatches + if ($null -ne $patchGuid) { + $detailedPatchInformation = Get-ItemProperty "$($patchRegistryPath)\$($patchGuid)" -ErrorAction SilentlyContinue + $localPackage = $detailedPatchInformation.LocalPackage + if ($null -ne $localPackage) { + $patchFileInformation = New-Object -TypeName System.IO.FileInfo -ArgumentList $localPackage + if ($patchFileInformation.Extension -eq ".msp") { + try { + $windowsInstaller = New-Object -ComObject WindowsInstaller.Installer + $installerDatabase = $windowsInstaller.GetType().InvokeMember("OpenDatabase", "InvokeMethod", $null, $windowsInstaller, ($localPackage , 32)) + $databaseQuery = "SELECT Value FROM MsiPatchMetadata WHERE Property = 'BuildNumber'" + $databaseView = $installerDatabase.GetType().InvokeMember("OpenView", "InvokeMethod", $null, $installerDatabase, ($databaseQuery)) + $databaseView.GetType().InvokeMember("Execute", "InvokeMethod", $null, $databaseView, $null) + $value = $databaseView.GetType().InvokeMember("Fetch", "InvokeMethod", $null, $databaseView, $null) + $versionInfo = [System.Version]$value.GetType().InvokeMember("StringData", "GetProperty", $null, $value, 1) + Clear-ComObject -ComObject $databaseView + Clear-ComObject -ComObject $value + Clear-ComObject -ComObject $installerDatabase + Clear-ComObject -ComObject $windowsInstaller + } + catch [Exception] { + $catchMessage = @" +An error occurred during the collection of data about installed products in Get-SPSLocalVersionInfo. +Exception: $($_.Exception.Message) +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-SPSLocalVersionInfo' -EntryType 'Error' + $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion + } + } + else { + $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion + } + } + else { + $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion + } + } + else { + $versionInfo = New-Object -TypeName System.Version -ArgumentList $productInfo.DisplayVersion + } + # Collect Information about language packs + if ($IsWssPackage -and ( $versionInfoValue -eq $nullVersion -or $versionInfoValue -gt $versionInfo)) { + $versionInfoValue = $versionInfo + } + else { + $versionInfoValue = $versionInfo + } + Write-Verbose "Version Information for $($prodName): $($versionInfoValue)" + } + } + + if ($nullVersion -ne $versionInfoValue) { + return $versionInfoValue + } + + return $nullVersion +} diff --git a/src/Modules/SPSUpdate.Common/Private/Invoke-SPSCommand.ps1 b/src/Modules/SPSUpdate.Common/Private/Invoke-SPSCommand.ps1 new file mode 100644 index 0000000..6a13db1 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Invoke-SPSCommand.ps1 @@ -0,0 +1,84 @@ +function Invoke-SPSCommand { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + $Credential, # Credential to be used for executing the command + + [Parameter()] + [Object[]] + $Arguments, # Optional arguments for the script block + + [Parameter(Mandatory = $true)] + [ScriptBlock] + $ScriptBlock, # Script block containing the commands to execute + + [Parameter(Mandatory = $true)] + [System.String] + $Server # Target server where the commands will be executed + ) + $VerbosePreference = 'Continue' + + # Base script to ensure the SharePoint snap-in is loaded. On SharePoint 2016/2019 + # the legacy PSSnapin is required; on Subscription Edition the SharePointServer + # module is auto-loaded, so no base script is prepended. + $installedVersion = Get-SPSInstalledProductVersion + if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { + $baseScript = @" + if (`$null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) + { + Add-PSSnapin Microsoft.SharePoint.PowerShell + } + +"@ + } + else { + $baseScript = '' + } + + # Prepare the arguments for Invoke-Command + $invokeArgs = @{ + ScriptBlock = [ScriptBlock]::Create($baseScript + $ScriptBlock.ToString()) + } + if ($null -ne $Arguments) { + $invokeArgs.Add("ArgumentList", $Arguments) + } + if ($null -eq $Credential) { + throw 'You need to specify a Credential' + } + + Write-Verbose -Message ("Executing on '$Server' using a CredSSP PSSession " + ` + "as user $($Credential.UserName)") + + # Running garbage collection to resolve issues related to Azure DSC extension use + [GC]::Collect() + + # Open the remote session, failing clearly instead of silently running the + # SharePoint scriptblock on the local server when the CredSSP session cannot be + # established (e.g. CredSSP not configured, or the target server is unreachable). + try { + $session = New-PSSession -ComputerName $Server ` + -Credential $Credential ` + -Authentication CredSSP ` + -Name "Microsoft.SharePoint.PSSession" ` + -SessionOption (New-PSSessionOption -OperationTimeout 0 ` + -IdleTimeout 60000 ` + -OpenTimeout 30000) ` + -ErrorAction Stop + } + catch { + throw "Failed to open a CredSSP PSSession to '$Server': $($_.Exception.Message)" + } + + $invokeArgs.Add("Session", $session) + + try { + return Invoke-Command @invokeArgs -Verbose + } + catch { + throw "Remote command on '$Server' failed: $($_.Exception.Message)" + } + finally { + Remove-PSSession -Session $session + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Add-SPSScheduledTask.ps1 b/src/Modules/SPSUpdate.Common/Public/Add-SPSScheduledTask.ps1 new file mode 100644 index 0000000..4515895 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Add-SPSScheduledTask.ps1 @@ -0,0 +1,86 @@ +function Add-SPSScheduledTask { + param + ( + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + $ExecuteAsCredential, # Credentials for Task Schedule + + [Parameter(Mandatory = $true)] + [System.String] + $ActionArguments, # Arguments for the task action + + [Parameter(Mandatory = $true)] + [System.String] + $Name, # Name of the scheduled task to be added + + [Parameter()] + [System.String] + $Description, # Description of the scheduled task to be added + + [Parameter()] + [System.String] + $TaskPath = 'SharePoint' # Path of the task folder + ) + + # Initialize variables + $TaskCmd = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' # Path to PowerShell executable + $UserName = $ExecuteAsCredential.UserName + $Password = $ExecuteAsCredential.GetNetworkCredential().Password + + # Connect to the local TaskScheduler Service + $TaskSvc = New-Object -ComObject ('Schedule.service') + $TaskSvc.Connect($env:COMPUTERNAME) + + # Check if the folder exists, if not, create it + try { + $TaskFolder = $TaskSvc.GetFolder($TaskPath) # Attempt to get the task folder + } + catch { + Write-Output "Task folder '$TaskPath' does not exist. Creating folder..." + $RootFolder = $TaskSvc.GetFolder('\') # Get the root folder + $RootFolder.CreateFolder($TaskPath) # Create the missing task folder + $TaskFolder = $TaskSvc.GetFolder($TaskPath) # Get the newly created folder + Write-Output "Successfully created task folder '$TaskPath'" + } + + Write-Output '--------------------------------------------------------------' + Write-Output "Adding or updating '$Name' script in Task Scheduler Service ..." + + # Get credentials for Task Schedule + $TaskAuthor = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name # Author of the task + $TaskUser = $UserName # Username for task registration + $TaskUserPwd = $Password # Password for task registration + + # Add a new Task Schedule + $TaskSchd = $TaskSvc.NewTask(0) + $TaskSchd.RegistrationInfo.Description = "$($Description)" # Task description + $TaskSchd.RegistrationInfo.Author = $TaskAuthor # Task author + $TaskSchd.Principal.RunLevel = 1 # Task run level (1 = Highest) + + # Task Schedule - Modify Settings Section + $TaskSettings = $TaskSchd.Settings + $TaskSettings.AllowDemandStart = $true + $TaskSettings.Enabled = $true + $TaskSettings.Hidden = $false + $TaskSettings.StartWhenAvailable = $true + + # Define the task action + $TaskAction = $TaskSchd.Actions.Create(0) # 0 = Executable action + $TaskAction.Path = $TaskCmd # Path to the executable + $TaskAction.Arguments = $ActionArguments # Arguments for the executable + + try { + # Register/update the task (6 = create or update). Cast to [void] so the + # returned RegisteredTask COM object is not dumped into the transcript. + [void]$TaskFolder.RegisterTaskDefinition($Name, $TaskSchd, 6, $TaskUser, $TaskUserPwd, 1) + Write-Output "Successfully added or updated '$Name' script in Task Scheduler Service" + } + catch { + $catchMessage = @" +An error occurred while adding/updating the script in scheduled task: $($Name) +ActionArguments: $($ActionArguments) +Exception: $($_.Exception.Message) +"@ + Write-Error -Message $catchMessage # Handle any errors during task registration + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Add-SPSUpdateEvent.ps1 b/src/Modules/SPSUpdate.Common/Public/Add-SPSUpdateEvent.ps1 new file mode 100644 index 0000000..1aa3a53 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Add-SPSUpdateEvent.ps1 @@ -0,0 +1,110 @@ +function Add-SPSUpdateEvent { + <# + .SYNOPSIS + Writes an entry to the dedicated SPSUpdate Windows Event Log. + + .DESCRIPTION + Add-SPSUpdateEvent writes an entry to the custom 'SPSUpdate' Windows Event + Log under the specified Source. The Source typically identifies the stage + that raised the event (e.g. 'Start-SPSProductUpdate', 'Mount-SPSContentDatabase'), + which makes filtering and SCOM monitoring straightforward. + + When the Source does not exist yet, it is created under the SPSUpdate log; + when the log itself does not exist, it is created on first use. A source + previously mapped to another log (e.g. 'Application') is re-pointed to the + SPSUpdate log. Creating an event source requires administrative privileges, + which the SPSUpdate script already validates. + + Each message is prefixed with a header containing the module version, the + current user and the computer name to ease cross-server correlation. + + .PARAMETER Message + The event message body. The header is prepended automatically. + + .PARAMETER Source + Identifier of the event source. + + .PARAMETER EntryType + Severity of the event. Defaults to Information. + + .PARAMETER EventID + Numeric event identifier. Defaults to 1. + + .EXAMPLE + Add-SPSUpdateEvent -Message 'ProductUpdate completed' -Source 'Start-SPSProductUpdate' -EventID 1000 + #> + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Message, + + [Parameter(Mandatory = $true)] + [System.String] + $Source, + + [Parameter()] + [ValidateSet('Error', 'Information', 'FailureAudit', 'SuccessAudit', 'Warning')] + [System.String] + $EntryType = 'Information', + + [Parameter()] + [System.UInt32] + $EventID = 1 + ) + + $LogName = 'SPSUpdate' + + # Ensure the event source exists and is mapped to the SPSUpdate log. A source + # registered under another log (e.g. 'Application') is re-pointed to SPSUpdate + # instead of silently giving up. Requires admin (the script validates it). + try { + if ([System.Diagnostics.EventLog]::SourceExists($Source)) { + $sourceLogName = [System.Diagnostics.EventLog]::LogNameFromSourceName($Source, '.') + if ($LogName -ne $sourceLogName) { + Write-Verbose -Message "Source '$Source' is mapped to log '$sourceLogName'; re-pointing it to '$LogName'." + [System.Diagnostics.EventLog]::DeleteEventSource($Source) + [System.Diagnostics.EventLog]::CreateEventSource($Source, $LogName) + } + } + else { + if ([System.Diagnostics.EventLog]::Exists($LogName) -eq $false) { + $null = New-EventLog -LogName $LogName -Source $Source + } + else { + [System.Diagnostics.EventLog]::CreateEventSource($Source, $LogName) + } + } + } + catch { + Write-Warning -Message "Could not register event source '$Source' on log '$LogName' (need admin?): $($_.Exception.Message)" + return + } + + $autoVersion = $MyInvocation.MyCommand.Module.Version + if ($null -eq $autoVersion) { + $autoVersion = (Get-Module -Name 'SPSUpdate.Common' -ErrorAction SilentlyContinue).Version + } + $scriptVersion = if ($null -ne $autoVersion) { $autoVersion.ToString() } else { 'unknown' } + $userName = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name + + try { + $headerMessage = @" +SPSUpdate Version: $scriptVersion +User: $userName +ComputerName: $($env:COMPUTERNAME) +-------------------------------------------------------------- +"@ + Write-EventLog -LogName $LogName -Source $Source -EventId $EventID -Message ($headerMessage + "`r`n" + $Message) -EntryType $EntryType + } + catch { + Write-Warning -Message @" +SPSUpdate Version: $scriptVersion +An error occurred while writing to Event Log in Source: $Source +User: $userName +ComputerName: $($env:COMPUTERNAME) +Exception: $_ +"@ + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Copy-SPSSideBySideFilesRemote.ps1 b/src/Modules/SPSUpdate.Common/Public/Copy-SPSSideBySideFilesRemote.ps1 new file mode 100644 index 0000000..d1e586d --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Copy-SPSSideBySideFilesRemote.ps1 @@ -0,0 +1,26 @@ +function Copy-SPSSideBySideFilesRemote { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Server, + + [Parameter()] + [System.Management.Automation.PSCredential] + $InstallAccount + ) + + $result = Invoke-SPSCommand -Credential $InstallAccount ` + -Arguments @($PSBoundParameters, $MyInvocation.MyCommand.Source) ` + -Server $Server ` + -ScriptBlock { + $params = $args[0] + + Write-Output "Running CmdLet Copy-SPSideBySideFiles on server: $($params.Server)" + Copy-SPSideBySideFiles -Verbose + } + return $result +} + +Set-Alias -Name Copy-SPSSideBySideFilesAllServers -Value Copy-SPSSideBySideFilesRemote diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSInstalledProductVersion.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSInstalledProductVersion.ps1 new file mode 100644 index 0000000..0ced2c7 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSInstalledProductVersion.ps1 @@ -0,0 +1,14 @@ +function Get-SPSInstalledProductVersion { + [CmdletBinding()] + [OutputType([System.Diagnostics.FileVersionInfo])] + param () + + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' + $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 + if ($null -eq $fullPath) { + Write-Error -Message 'SharePoint path {C:\Program Files\Common Files\microsoft shared\Web Server Extensions} does not exist' + } + else { + return [System.Diagnostics.FileVersionInfo]::GetVersionInfo($fullPath.FullName) + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSSecret.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSSecret.ps1 new file mode 100644 index 0000000..20d2dfb --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSSecret.ps1 @@ -0,0 +1,82 @@ +function Get-SPSSecret { + <# + .SYNOPSIS + Loads secrets.psd1 and returns a PSCredential for the requested key. + + .DESCRIPTION + Reads the secrets.psd1 file under the given Config folder and decrypts + the SecureString stored under the requested CredentialKey to build a + PSCredential. This replaces the previous Windows Credential Manager + (credentialmanager module) storage. + + PasswordSecure values must be created with ConvertFrom-SecureString, + which on Windows uses DPAPI keyed by the current user account on the + current machine. As a result, secrets.psd1 is only usable by the same + Windows account that generated its values (typically the InstallAccount + that runs the SPSUpdate scheduled tasks). + + Returns $null when secrets.psd1 is missing or the key is absent, so the + caller can surface a clear remediation message. + + .PARAMETER CredentialKey + Key under the root hashtable of secrets.psd1 (matches CredentialKey in + the environment config). + + .PARAMETER ConfigPath + Optional folder containing secrets.psd1. Defaults to src/Config next to + the module. + + .EXAMPLE + $cred = Get-SPSSecret -CredentialKey 'PROD-ADM' -ConfigPath $pathConfigFolder + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialKey', + Justification = 'CredentialKey is a lookup key into secrets.psd1, not a password. The actual secret is decrypted from a DPAPI SecureString and returned as a PSCredential.')] + [OutputType([System.Management.Automation.PSCredential])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CredentialKey, + + [Parameter()] + [System.String] + $ConfigPath + ) + + if ([string]::IsNullOrEmpty($ConfigPath)) { + $ConfigPath = Get-SPSConfigRoot + } + + $file = Join-Path -Path $ConfigPath -ChildPath 'secrets.psd1' + + if (-not (Test-Path -Path $file)) { + Write-Verbose -Message "Secrets file not found at '$file'." + return $null + } + + $secrets = Import-PowerShellDataFile -Path $file + + if (-not $secrets.ContainsKey($CredentialKey)) { + Write-Verbose -Message "Secret entry '$CredentialKey' not found in '$file'." + return $null + } + + $entry = $secrets[$CredentialKey] + + if ([string]::IsNullOrEmpty($entry.Username)) { + throw "Secret entry '$CredentialKey' has no Username defined in '$file'." + } + if ([string]::IsNullOrEmpty($entry.PasswordSecure) -or $entry.PasswordSecure -like 'PASTE-*') { + throw "Secret entry '$CredentialKey' has no real PasswordSecure value in '$file'. Generate one with: Read-Host -AsSecureString | ConvertFrom-SecureString" + } + + try { + $secureString = ConvertTo-SecureString -String $entry.PasswordSecure -ErrorAction Stop + } + catch { + throw "Failed to decode SecureString for secret '$CredentialKey'. The value must be the output of ConvertFrom-SecureString on the current user account and machine. Original error: $($_.Exception.Message)" + } + + return New-Object System.Management.Automation.PSCredential($entry.Username, $secureString) +} diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSServersPatchStatus.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSServersPatchStatus.ps1 new file mode 100644 index 0000000..24b87c5 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSServersPatchStatus.ps1 @@ -0,0 +1,24 @@ +function Get-SPSServersPatchStatus { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Server + ) + + $spFarm = Get-SPFarm + $productVersions = [Microsoft.SharePoint.Administration.SPProductVersions]::GetProductVersions($spFarm) + $spServer = Get-SPServer $Server + $serverProductInfo = $productVersions.GetServerProductInfo($spServer.Id) + if ($null -ne $serverProductInfo) { + $statusType = $serverProductInfo.InstallStatus + if ($statusType -ne 0) { + $statusType = $serverProductInfo.GetUpgradeStatus($spFarm, $spServer) + } + } + else { + $statusType = [Microsoft.SharePoint.Administration.SPServerProductInfo+StatusType]::NoActionRequired + } + return $statusType +} diff --git a/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 b/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 new file mode 100644 index 0000000..0e2e508 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 @@ -0,0 +1,121 @@ +function Initialize-SPSContentDbJsonFile { + [CmdletBinding()] + param + ( + [Parameter()] + [System.String] + $Path + ) + + #Initialize jSON Object, variables and class + New-Variable -Name jsonObject ` + -Description 'jSON object variable' ` + -Option AllScope ` + -Force + + $jsonObject = [PSCustomObject]@{} + $tbSPContentDb1 = New-Object -TypeName System.Collections.ArrayList + $tbSPContentDb2 = New-Object -TypeName System.Collections.ArrayList + $tbSPContentDb3 = New-Object -TypeName System.Collections.ArrayList + $tbSPContentDb4 = New-Object -TypeName System.Collections.ArrayList + class SPDbContent { + [System.String]$Name + [System.String]$Server + [System.String]$WebAppUrl + } + + #Get all content databases + $spAllDatabases = Get-SPContentDatabase -ErrorAction SilentlyContinue + + if ($null -ne $spAllDatabases) { + # --- LPT (Longest Processing Time First) scheduling --- + # Balance databases across 4 sequences by total DiskSizeRequired + # rather than by count, so parallel upgrade workloads finish closer + # to the same time. + + # 1. Sort databases by size descending + $spSortedDatabases = $spAllDatabases | + Sort-Object -Property DiskSizeRequired -Descending + + # 2. Track cumulative load (bytes) per sequence (index 0 = Seq1 .. 3 = Seq4) + $sequenceLoad = @(0.0, 0.0, 0.0, 0.0) + $sequenceLists = @($tbSPContentDb1, $tbSPContentDb2, $tbSPContentDb3, $tbSPContentDb4) + + # 3. Assign each database to the sequence with the lowest current load + foreach ($spDatabase in $spSortedDatabases) { + $minLoad = $sequenceLoad[0] + $minIndex = 0 + for ($s = 1; $s -lt 4; $s++) { + if ($sequenceLoad[$s] -lt $minLoad) { + $minLoad = $sequenceLoad[$s] + $minIndex = $s + } + } + [void]$sequenceLists[$minIndex].Add([SPDbContent]@{ + Name = $spDatabase.Name; + Server = $spDatabase.Server; + WebAppUrl = $spDatabase.WebApplication.Url; + }) + $sequenceLoad[$minIndex] += $spDatabase.DiskSizeRequired + } + + # --- Distribution report (visible in transcript) --- + $totalBytes = ($sequenceLoad | Measure-Object -Sum).Sum + $totalMB = [math]::Round($totalBytes / 1MB, 0) + $dbCount = @($spSortedDatabases).Count + Write-Output '--- ContentDatabase Distribution Report ---' + Write-Output ("Total : {0} database(s) | {1:N0} MB" -f $dbCount, $totalMB) + for ($s = 0; $s -lt 4; $s++) { + $loadMB = [math]::Round($sequenceLoad[$s] / 1MB, 0) + $pct = if ($totalBytes -gt 0) { + [math]::Round($sequenceLoad[$s] / $totalBytes * 100, 1) + } + else { 0 } + Write-Output (" Sequence {0} : {1,3} database(s) | {2,7:N0} MB | {3,5:N1}%" ` + -f ($s + 1), $sequenceLists[$s].Count, $loadMB, $pct) + } + Write-Output '-------------------------------------------' + + #Add each array to jsonObject + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name 'SPContentDatabase1' ` + -Value $tbSPContentDb1 + + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name 'SPContentDatabase2' ` + -Value $tbSPContentDb2 + + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name 'SPContentDatabase3' ` + -Value $tbSPContentDb3 + + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name 'SPContentDatabase4' ` + -Value $tbSPContentDb4 + + # Serialize once and write both the canonical file (consumed by SPSUpdate.ps1) + # and a timestamped snapshot in the same folder so previous inventories are + # retained for troubleshooting and rollback. + $jsonPayload = $jsonObject | ConvertTo-Json + $jsonPayload | Set-Content -Path $Path -Force + + try { + $snapshotDir = [System.IO.Path]::GetDirectoryName($Path) + $snapshotBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Path) + $snapshotExtension = [System.IO.Path]::GetExtension($Path) + $snapshotTimestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' + $snapshotFileName = '{0}_{1}{2}' -f $snapshotBaseName, $snapshotTimestamp, $snapshotExtension + $snapshotPath = if ([string]::IsNullOrEmpty($snapshotDir)) { + $snapshotFileName + } + else { + Join-Path -Path $snapshotDir -ChildPath $snapshotFileName + } + $jsonPayload | Set-Content -Path $snapshotPath -Force + Write-Output "ContentDatabase inventory snapshot saved to: $snapshotPath" + } + catch { + Write-Verbose -Message "Failed to write ContentDatabase inventory snapshot: $($_.Exception.Message)" + } + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Mount-SPSContentDatabase.ps1 b/src/Modules/SPSUpdate.Common/Public/Mount-SPSContentDatabase.ps1 new file mode 100644 index 0000000..b02dcf3 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Mount-SPSContentDatabase.ps1 @@ -0,0 +1,49 @@ +function Mount-SPSContentDatabase { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, + + [Parameter(Mandatory = $true)] + [System.String] + $WebAppUrl, + + [Parameter()] + [System.String] + $DatabaseServer + ) + + # Skip mount if the content database is already attached to the farm + $getSPContentDb = Get-SPContentDatabase -Identity $Name -ErrorAction SilentlyContinue + if ($null -ne $getSPContentDb) { + Write-Output "SPContentDatabase $($Name) is already mounted - No action needed" + return + } + + # Validate that the target web application exists + $getSPWebApp = Get-SPWebApplication -Identity $WebAppUrl -ErrorAction SilentlyContinue + if ($null -eq $getSPWebApp) { + $catchMessage = "SPWebApplication '$WebAppUrl' was not found - Cannot mount SPContentDatabase '$Name'" + Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' + throw $catchMessage + } + + Write-Output "Mounting SPContentDatabase $($Name) on WebApplication $($WebAppUrl)" + $mountStarted = Get-Date + Write-Output "Started at $mountStarted - Please Wait ..." + if ($PSCmdlet.ShouldProcess($Name, "Mount SharePoint content database on $WebAppUrl")) { + $mountParams = @{ + Name = $Name + WebApplication = $WebAppUrl + Confirm = $false + } + if (-not [string]::IsNullOrWhiteSpace($DatabaseServer)) { + $mountParams['DatabaseServer'] = $DatabaseServer + } + Mount-SPContentDatabase @mountParams -Verbose + } + $mountFinished = Get-Date + Write-Output "Mount for SPContentDatabase $($Name) is finished at $mountFinished" +} diff --git a/src/Modules/SPSUpdate.Common/Public/Remove-SPSScheduledTask.ps1 b/src/Modules/SPSUpdate.Common/Public/Remove-SPSScheduledTask.ps1 new file mode 100644 index 0000000..b8c418e --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Remove-SPSScheduledTask.ps1 @@ -0,0 +1,50 @@ +function Remove-SPSScheduledTask { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, # Name of the scheduled task to be removed + + [Parameter()] + [System.String] + $TaskPath = 'SharePoint' # Path of the task folder + ) + + # Connect to the local TaskScheduler Service + $TaskSvc = New-Object -ComObject ('Schedule.service') + $TaskSvc.Connect($env:COMPUTERNAME) + + # Check if the folder exists + try { + $TaskFolder = $TaskSvc.GetFolder($TaskPath) # Attempt to get the task folder + } + catch { + Write-Output "Task folder '$TaskPath' does not exist." + } + + # Retrieve the scheduled task + $getScheduledTask = $TaskFolder.GetTasks(0) | Where-Object -FilterScript { + $_.Name -eq $Name + } + + if ($null -eq $getScheduledTask) { + Write-Warning -Message 'Scheduled Task already removed - skipping.' # Task not found + } + else { + Write-Output '--------------------------------------------------------------' + Write-Output "Removing $($Name) script in Task Scheduler Service ..." + try { + if ($PSCmdlet.ShouldProcess($Name, 'Remove scheduled task')) { + $TaskFolder.DeleteTask($Name, $null) # Remove the task + Write-Output "Successfully removed $($Name) script from Task Scheduler Service" + } + } + catch { + $catchMessage = @" +An error occurred while removing the script in scheduled task: $($Name) +Exception: $($_.Exception.Message) +"@ + Write-Error -Message $catchMessage # Handle any errors during task removal + } + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSSecret.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSSecret.ps1 new file mode 100644 index 0000000..d835b02 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSSecret.ps1 @@ -0,0 +1,123 @@ +function Set-SPSSecret { + <# + .SYNOPSIS + Writes, updates or removes a credential entry in secrets.psd1. + + .DESCRIPTION + Set-SPSSecret persists a service credential as a DPAPI-encrypted + SecureString in secrets.psd1, replacing the previous Windows Credential + Manager (credentialmanager module) storage. It encrypts + Credential.Password with ConvertFrom-SecureString (DPAPI keyed to the + current account/machine on Windows) so only the same account can later + decrypt it via Get-SPSSecret. + + Existing entries for other keys are preserved. The file is (re)written as + UTF-8 with BOM so Windows PowerShell 5.1 reads it correctly. secrets.psd1 + is gitignored and must never be committed. + + Run -Action Install as the InstallAccount that will run the scheduled + tasks, so the DPAPI blob is decryptable at run time. + + .PARAMETER CredentialKey + Key under the root hashtable of secrets.psd1 (matches CredentialKey in + the environment config). + + .PARAMETER Credential + The credential to store. Required unless -Remove is specified. + + .PARAMETER ConfigPath + Folder containing secrets.psd1. Defaults to src/Config next to the + module. Created if missing. + + .PARAMETER Remove + Remove the entry for CredentialKey instead of writing it. + + .EXAMPLE + Set-SPSSecret -CredentialKey 'PROD-ADM' -Credential $InstallAccount -ConfigPath $pathConfigFolder + + .EXAMPLE + Set-SPSSecret -CredentialKey 'PROD-ADM' -Remove -ConfigPath $pathConfigFolder + #> + [CmdletBinding(SupportsShouldProcess = $true)] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialKey', + Justification = 'CredentialKey is a lookup key into secrets.psd1, not a password. The secret itself is supplied as a PSCredential and stored as a DPAPI SecureString.')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CredentialKey, + + [Parameter()] + [System.Management.Automation.PSCredential] + $Credential, + + [Parameter()] + [System.String] + $ConfigPath, + + [Parameter()] + [switch] + $Remove + ) + + if ([string]::IsNullOrEmpty($ConfigPath)) { + $ConfigPath = Get-SPSConfigRoot + } + if (-not (Test-Path -Path $ConfigPath)) { + $null = New-Item -Path $ConfigPath -ItemType Directory -Force + } + + $file = Join-Path -Path $ConfigPath -ChildPath 'secrets.psd1' + + # Load existing entries so other keys are preserved. + $secrets = @{} + if (Test-Path -Path $file) { + $existing = Import-PowerShellDataFile -Path $file + foreach ($k in $existing.Keys) { + $secrets[$k] = $existing[$k] + } + } + + if ($Remove) { + if (-not $secrets.ContainsKey($CredentialKey)) { + Write-Verbose -Message "Secret '$CredentialKey' not present in '$file'; nothing to remove." + return + } + if (-not $PSCmdlet.ShouldProcess($CredentialKey, "Remove secret from $file")) { + return + } + $secrets.Remove($CredentialKey) + } + else { + if ($null -eq $Credential) { + throw 'Set-SPSSecret requires -Credential when -Remove is not specified.' + } + if (-not $PSCmdlet.ShouldProcess($CredentialKey, "Write secret to $file")) { + return + } + $passwordSecure = ConvertFrom-SecureString -SecureString $Credential.Password + $secrets[$CredentialKey] = @{ + Username = $Credential.UserName + PasswordSecure = $passwordSecure + } + } + + # Serialize the hashtable back to a .psd1 document. + $builder = New-Object System.Text.StringBuilder + [void]$builder.AppendLine('@{') + foreach ($key in ($secrets.Keys | Sort-Object)) { + $entry = $secrets[$key] + $safeKey = $key -replace "'", "''" + $safeUser = ([string]$entry.Username) -replace "'", "''" + $safePwd = ([string]$entry.PasswordSecure) -replace "'", "''" + [void]$builder.AppendLine((" '{0}' = @{{" -f $safeKey)) + [void]$builder.AppendLine((" Username = '{0}'" -f $safeUser)) + [void]$builder.AppendLine((" PasswordSecure = '{0}'" -f $safePwd)) + [void]$builder.AppendLine(' }') + } + [void]$builder.AppendLine('}') + + $content = ($builder.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n" + $encoding = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($file, $content, $encoding) +} diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 new file mode 100644 index 0000000..b631c9e --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 @@ -0,0 +1,61 @@ +function Set-SPSSideBySideToken { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param + ( + [Parameter()] + [System.String] + $BuildVersion, + + [Parameter()] + [System.Boolean] + $EnableSideBySide + ) + + $webApps = Get-SPWebApplication -ErrorAction SilentlyContinue + if ($null -ne $webApps) { + foreach ($webApp in $webApps) { + $spWebAppName = $webApp.Name + if ($EnableSideBySide) { + if ($webApp.WebService.EnableSideBySide) { + Write-Output "EnableSideBySide is already enabled on $spWebAppName Web Application" + } + else { + Write-Output "Enabling EnableSideBySide on $spWebAppName Web Application" + if ($PSCmdlet.ShouldProcess($spWebAppName, 'Enable SharePoint side-by-side mode')) { + $webApp.WebService.EnableSideBySide = $true + $webApp.WebService.Update() + } + } + if ($webApp.WebService.SideBySideToken -eq $BuildVersion) { + Write-Output "SideBySideToken $BuildVersion is already enabled on $spWebAppName Web Application" + } + else { + Write-Output "Enabling SideBySideToken $BuildVersion on $spWebAppName Web Application" + if ($PSCmdlet.ShouldProcess($spWebAppName, "Set SharePoint SideBySideToken to $BuildVersion")) { + $webApp.WebService.SideBySideToken = $BuildVersion + $webApp.WebService.Update() + } + } + Write-Output 'Running CmdLet Copy-SPSideBySideFiles' + if ($PSCmdlet.ShouldProcess($spWebAppName, 'Copy SharePoint side-by-side files')) { + Copy-SPSideBySideFiles -Verbose + } + } + else { + if ($webApp.WebService.EnableSideBySide) { + Write-Output "Disabling EnableSideBySide on $spWebAppName Web Application" + if ($PSCmdlet.ShouldProcess($spWebAppName, 'Disable SharePoint side-by-side mode')) { + $webApp.WebService.EnableSideBySide = $false + $webApp.WebService.Update() + } + } + else { + Write-Output "EnableSideBySide is already disabled on $spWebAppName Web Application" + } + } + } + } + else { + throw 'Did not find SPWebApplication Object' + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Start-SPSConfigExe.ps1 b/src/Modules/SPSUpdate.Common/Public/Start-SPSConfigExe.ps1 new file mode 100644 index 0000000..fa14e06 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Start-SPSConfigExe.ps1 @@ -0,0 +1,81 @@ +function Start-SPSConfigExe { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param () + + # Check which version of SharePoint is installed + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' + $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 + $getSPInstalledProductVersion = (Get-Command $fullPath).FileVersionInfo + + if ($getSPInstalledProductVersion.FileMajorPart -eq 15) { + $wssRegKey = 'hklm:SOFTWARE\Microsoft\Shared Tools\Web Server Extensions\15.0\WSS' + $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\15\BIN" + } + else { + $wssRegKey = 'hklm:SOFTWARE\Microsoft\Shared Tools\Web Server Extensions\16.0\WSS' + $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\16\BIN" + } + $psconfigExe = Join-Path -Path $binaryDir -ChildPath "psconfig.exe" + + # Read LanguagePackInstalled and SetupType registry keys + $languagePackInstalled = Get-ItemProperty -LiteralPath $wssRegKey -Name 'LanguagePackInstalled' -ErrorAction SilentlyContinue + $setupType = Get-ItemProperty -LiteralPath $wssRegKey -Name 'SetupType' + + # Determine if LanguagePackInstalled=1 or SetupType=B2B_Upgrade. + # If so, the Config Wizard is required + if (($languagePackInstalled.LanguagePackInstalled -eq 1) -or ($setupType.SetupType -eq "B2B_UPGRADE")) { + Write-Output "Starting Configuration Wizard" + Write-Output "Starting 'Product Version Job' timer job" + $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' + $lastRunTime = $pvTimerJob.LastRunTime + + Start-SPTimerJob -Identity $pvTimerJob + + $jobRunning = $true + $maxCount = 30 + $count = 0 + Write-Output "Waiting for 'Product Version Job' timer job to complete" + while ($jobRunning -and $count -le $maxCount) { + Start-Sleep -Seconds 10 + + $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' + $jobRunning = $lastRunTime -eq $pvTimerJob.LastRunTime + + $count++ + } + + # Fix for issue with psconfig on SharePoint 2019 + if ($getSPInstalledProductVersion.FileMajorPart -eq 16) { + Upgrade-SPFarm -ServerOnly -SkipDatabaseUpgrade -SkipSiteUpgrade -Confirm:$false + } + + $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" + $psconfig = Start-Process -FilePath $psconfigExe ` + -ArgumentList "-cmd upgrade -inplace b2b -wait -cmd applicationcontent -install -cmd installfeatures -cmd secureresources -cmd services -install" ` + -RedirectStandardOutput $stdOutTempFile ` + -Wait ` + -PassThru + + $cmdOutput = Get-Content -Path $stdOutTempFile -Raw + Remove-Item -Path $stdOutTempFile + + if ($null -ne $cmdOutput) { + Write-Output $cmdOutput.Trim() + } + + Write-Output "PSConfig Exit Code: $($psconfig.ExitCode)" + return $psconfig.ExitCode + } + # Error codes: https://aka.ms/installerrorcodes + switch ($result) { + 0 { + Write-Output "SharePoint Post Setup Configuration Wizard ran successfully" + } + Default { + $message = ("SharePoint Post Setup Configuration Wizard failed, " + ` + "exit code was $result. Error codes can be found at " + ` + "https://aka.ms/installerrorcodes") + throw $message + } + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Start-SPSConfigExeRemote.ps1 b/src/Modules/SPSUpdate.Common/Public/Start-SPSConfigExeRemote.ps1 new file mode 100644 index 0000000..6dd60ef --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Start-SPSConfigExeRemote.ps1 @@ -0,0 +1,90 @@ +function Start-SPSConfigExeRemote { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Server, + + [Parameter()] + [System.Management.Automation.PSCredential] + $InstallAccount + ) + + # Check which version of SharePoint is installed + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' + $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 + $getSPInstalledProductVersion = (Get-Command $fullPath).FileVersionInfo + + if ($getSPInstalledProductVersion.FileMajorPart -eq 15) { + $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\15\BIN" + } + else { + $binaryDir = Join-Path $env:CommonProgramFiles "Microsoft Shared\Web Server Extensions\16\BIN" + } + $psconfigExe = Join-Path -Path $binaryDir -ChildPath "psconfig.exe" + + # Start wizard + Write-Verbose -Message "Starting Configuration Wizard on server: $Server" + $result = Invoke-SPSCommand -Credential $InstallAccount ` + -Server $Server ` + -Arguments $psconfigExe ` + -ScriptBlock { + + $psconfigExe = $args[0] + + # Check which version of SharePoint is installed + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' + $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 + $getSPInstalledProductVersion = (Get-Command $fullPath).FileVersionInfo + + Write-Verbose -Message "Starting 'Product Version Job' timer job" + $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' + $lastRunTime = $pvTimerJob.LastRunTime + + Start-SPTimerJob -Identity $pvTimerJob + + $jobRunning = $true + $maxCount = 30 + $count = 0 + Write-Verbose -Message "Waiting for 'Product Version Job' timer job to complete" + while ($jobRunning -and $count -le $maxCount) { + Start-Sleep -Seconds 10 + $pvTimerJob = Get-SPTimerJob -Identity 'job-admin-product-version' + $jobRunning = $lastRunTime -eq $pvTimerJob.LastRunTime + $count++ + } + + # Fix for issue with psconfig on SharePoint 2019 + if ($getSPInstalledProductVersion.FileMajorPart -ne 15) { + Upgrade-SPFarm -ServerOnly -SkipDatabaseUpgrade -SkipSiteUpgrade -Confirm:$false + } + + $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" + $psconfig = Start-Process -FilePath $psconfigExe ` + -ArgumentList "-cmd upgrade -inplace b2b -wait -cmd applicationcontent -install -cmd installfeatures -cmd secureresources -cmd services -install" ` + -RedirectStandardOutput $stdOutTempFile ` + -Wait ` + -PassThru + + $cmdOutput = Get-Content -Path $stdOutTempFile -Raw + Remove-Item -Path $stdOutTempFile + if ($null -ne $cmdOutput) { + Write-Verbose -Message $cmdOutput.Trim() + } + Write-Verbose -Message "PSConfig Exit Code: $($psconfig.ExitCode)" + return $psconfig.ExitCode + } + # Error codes: https://aka.ms/installerrorcodes + switch ($result) { + 0 { + Write-Verbose -Message "SharePoint Post Setup Configuration Wizard ran successfully" + } + Default { + $message = ("SharePoint Post Setup Configuration Wizard failed, " + ` + "exit code was $result. Error codes can be found at " + ` + "https://aka.ms/installerrorcodes") + throw $message + } + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 b/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 new file mode 100644 index 0000000..fb08b19 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 @@ -0,0 +1,147 @@ +function Start-SPSProductUpdate { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $SetupFile, + + [Parameter(Mandatory = $true)] + [System.Boolean] + $ShutdownServices + ) + + Write-Verbose -Message "Getting install status of SP binaries" + Write-Verbose -Message "Check if the setup file exists" + if (-Not (Test-Path -Path $SetupFile)) { + Throw "ERROR: Setup files could not be found: $SetupFile" + } + + Write-Verbose -Message "Checking file status of $SetupFile" + Write-Verbose -Message "Checking status now" + try { + $zone = Get-Item -Path $SetupFile -Stream "Zone.Identifier" -EA SilentlyContinue + } + catch { + Write-Verbose -Message 'Encountered error while reading file stream. Ignoring file stream.' + } + + if ($null -ne $zone) { + $catchMessage = @" +Setup file is blocked! Please use 'Unblock-File -Path $SetupFile' to unblock the file before continuing. +"@ + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' + throw $catchMessage + } + Write-Verbose -Message "File not blocked, continuing." + Write-Verbose -Message "Get file information from setup file" + $setupFileInfo = Get-ItemProperty -Path $SetupFile + $fileVersion = $setupFileInfo.VersionInfo.FileVersion + Write-Verbose -Message "Update has version $fileVersion" + $fileVersionInfo = New-Object -TypeName System.Version -ArgumentList $fileVersion + if ($fileVersionInfo.Build.ToString().Length -eq 4) { + $sharePointVersion = '2016' + } + else { + if ($fileVersionInfo.Build -lt 13000) { + $sharePointVersion = '2019' + } + else { + $sharePointVersion = 'SE' + } + } + + Write-Verbose -Message "Update is a Cumulative Update." + # For SP 2016 + 2019 Patches + $setupFileInformation = New-Object -TypeName System.IO.FileInfo -ArgumentList $SetupFile + if ($setupFileInformation.Name.StartsWith("wssloc")) { + Write-Verbose -Message "Cumulative Update is multilingual" + $versionInfo = Get-SPSLocalVersionInfo -ProductVersion $sharePointVersion -IsWssPackage + } + else { + Write-Verbose -Message "Cumulative Update is generic" + $versionInfo = Get-SPSLocalVersionInfo -ProductVersion $sharePointVersion + } + + Write-Verbose -Message "The lowest version of any SharePoint component is $($versionInfo)" + if ($versionInfo -lt $fileVersionInfo) { + # Version of SharePoint is lower than the patch version. Patch is not installed. + Write-Verbose -Message "The version of SharePoint installed is lower than the update. Starting update process." + $installedVersion = Get-SPSInstalledProductVersion + if ($ShutdownServices) { + $listOfServices = @("SPSearchHostController", "SPTimerV4", "IISADMIN") + if ($installedVersion.ProductMajorPart -eq 15) { + + $listOfServices += "OSearch15" + } + else { + $listOfServices += "OSearch16" + } + Write-Verbose -Message "Gettings services status before stopping services for installation." + $servicesStatusFilePath = Join-Path -Path $PSScriptRoot -ChildPath "ServicesStatus_$($env:COMPUTERNAME)_$(Get-Date -Format 'yyyyMMddHHmmss').json" + Get-Service -Name $listOfServices -ErrorAction SilentlyContinue | Select-Object Name, StartType, Status | ConvertTo-Json | Set-Content -Path $servicesStatusFilePath -Force + Write-Verbose -Message "Services status saved to $servicesStatusFilePath" + Write-Verbose -Message "Stopping services to speed up installation process" + foreach ($service in $listOfServices) { + Write-Verbose -Message "Stopping service: $service - Setting startup type to disabled" + Set-Service -Name $service -StartupType Disabled -ErrorAction SilentlyContinue + Stop-Service -Name $service -Force -ErrorAction SilentlyContinue + } + write-Verbose -Message "All services stopped. Starting installation process." + $null = Start-Process -FilePath "iisreset.exe" ` + -ArgumentList "-stop -noforce" ` + -Wait ` + -PassThru + + + } + + $setupInstall = Start-Process -FilePath $SetupFile -ArgumentList '/passive' -Wait -PassThru + # Error codes: https://aka.ms/installerrorcodes + switch ($setupInstall.ExitCode) { + 0 { + Write-Verbose -Message "SharePoint update binary installation complete." + } + 17022 { + Write-Verbose -Message ("SharePoint update binary installation complete, however a reboot is required.") + } + 17025 { + Write-Verbose -Message ("The SharePoint update was already installed on your system." + ` + "Please report an issue about this behavior at https://github.com/dsccommunity/SharePointDsc") + } + Default { + $catchMessage = @" +SharePoint update install failed, exit code was $($setupInstall.ExitCode). +Error codes can be found at https://aka.ms/installerrorcodes +"@ + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' + throw $catchMessage + } + } + if ($ShutdownServices) { + Write-Verbose -Message "Getting services status from json configuration." + $servicesStatusFromFile = Get-Content -Path $servicesStatusFilePath -Raw | ConvertFrom-Json + foreach ($service in $servicesStatusFromFile) { + Write-Verbose -Message "Service: $($service.Name) - Startup Type before installation: $($service.StartType) - Status before installation: $($service.Status)" + if ($service.Status -ne "Running") { + Write-Verbose -Message "Service: $($service.Name) was not running before installation. Keeping it stopped." + Set-Service -Name $service.Name -StartupType $service.StartType -ErrorAction SilentlyContinue + } + else { + Write-Verbose -Message "Service: $($service.Name) was running before installation. Restoring its startup type." + Set-Service -Name $service.Name -StartupType $service.StartType -ErrorAction SilentlyContinue + Start-Service -Name $service.Name -ErrorAction SilentlyContinue + } + } + Start-Process -FilePath "iisreset.exe" ` + -ArgumentList "-start" ` + -Wait ` + -PassThru + write-Verbose -Message "All services started. Installation process complete." + } + } + else { + # Version of SharePoint is equal or greater than the patch version. Patch is installed. + Write-Verbose -Message "The version of SharePoint installed is equal or higher than the update. No action needed." + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Start-SPSScheduledTask.ps1 b/src/Modules/SPSUpdate.Common/Public/Start-SPSScheduledTask.ps1 new file mode 100644 index 0000000..0f2742f --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Start-SPSScheduledTask.ps1 @@ -0,0 +1,41 @@ +function Start-SPSScheduledTask { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, + + [Parameter()] + [System.String] + $TaskPath = 'SharePoint' + ) + + $normalizedTaskPath = if ($TaskPath.StartsWith('\')) { + $TaskPath + } + else { + "\$TaskPath" + } + if (-not $normalizedTaskPath.EndsWith('\')) { + $normalizedTaskPath = "$normalizedTaskPath\" + } + + $getScheduledTask = Get-ScheduledTask -TaskName $Name -TaskPath $normalizedTaskPath -ErrorAction SilentlyContinue + if (-not $getScheduledTask) { + throw "Scheduled Task $Name does not exist in $TaskPath Task Path" + } + + if ($PSCmdlet.ShouldProcess($Name, 'Start scheduled task')) { + Start-ScheduledTask -TaskName $Name ` + -TaskPath $normalizedTaskPath ` + -ErrorAction Stop + } + + $startedTask = Get-ScheduledTask -TaskName $Name -TaskPath $normalizedTaskPath -ErrorAction Stop + return [PSCustomObject]@{ + Name = $Name + TaskPath = $normalizedTaskPath + State = $startedTask.State + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Test-SPSPendingReboot.ps1 b/src/Modules/SPSUpdate.Common/Public/Test-SPSPendingReboot.ps1 new file mode 100644 index 0000000..f0aeed4 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Test-SPSPendingReboot.ps1 @@ -0,0 +1,48 @@ +function Test-SPSPendingReboot { + [CmdletBinding()] + param () + + $rebootReasons = New-Object -TypeName System.Collections.Generic.List[string] + + $registryChecks = @( + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'; Reason = 'WindowsUpdateRebootRequired' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'; Reason = 'ComponentBasedServicingRebootPending' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootInProgress'; Reason = 'ComponentBasedServicingRebootInProgress' }, + @{ Path = 'HKLM:\SOFTWARE\Microsoft\ServerManager\CurrentRebootAttempts'; Reason = 'ServerManagerCurrentRebootAttempts' } + ) + + foreach ($check in $registryChecks) { + if (Test-Path -Path $check.Path -ErrorAction SilentlyContinue) { + $rebootReasons.Add($check.Reason) + } + } + + # WindowsUpdate\Services\Pending can exist even after reboot; require at least one child entry. + $wuServicesPendingPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending' + if (Test-Path -Path $wuServicesPendingPath -ErrorAction SilentlyContinue) { + $wuPendingEntries = Get-ChildItem -Path $wuServicesPendingPath -ErrorAction SilentlyContinue + if ($null -ne $wuPendingEntries -and $wuPendingEntries.Count -gt 0) { + $rebootReasons.Add('WindowsUpdateServicesPending') + } + } + + $sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -ErrorAction SilentlyContinue + if ($null -ne $sessionManager -and $null -ne $sessionManager.PendingFileRenameOperations -and $sessionManager.PendingFileRenameOperations.Count -gt 0) { + $rebootReasons.Add('PendingFileRenameOperations') + } + + $activeComputerName = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -Name ComputerName -ErrorAction SilentlyContinue + $pendingComputerName = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -Name ComputerName -ErrorAction SilentlyContinue + if ($null -ne $activeComputerName -and $null -ne $pendingComputerName -and $activeComputerName.ComputerName -ne $pendingComputerName.ComputerName) { + $rebootReasons.Add('PendingComputerRename') + } + + if (Test-Path -Path 'HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client\Reboot Management\RebootData' -ErrorAction SilentlyContinue) { + $rebootReasons.Add('ConfigMgrRebootPending') + } + + return [PSCustomObject]@{ + IsPending = ($rebootReasons.Count -gt 0) + Reasons = $rebootReasons.ToArray() + } +} diff --git a/src/Modules/SPSUpdate.Common/Public/Update-SPSContentDatabase.ps1 b/src/Modules/SPSUpdate.Common/Public/Update-SPSContentDatabase.ps1 new file mode 100644 index 0000000..d389eb4 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Update-SPSContentDatabase.ps1 @@ -0,0 +1,31 @@ +function Update-SPSContentDatabase { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name + ) + + $getSPContentDb = Get-SPContentDatabase -Identity $Name -ErrorAction SilentlyContinue + if ($null -ne $getSPContentDb) { + Write-Output "Checking Upgrading status for $($Name) ..." + if ($getSPContentDb.NeedsUpgrade) { + Write-Output "Upgrading SharePoint SPContentDatabase $($Name)" + $updateStarted = Get-date + Write-Output "Started at $updateStarted - Please Wait ..." + if ($PSCmdlet.ShouldProcess($Name, 'Upgrade SharePoint content database')) { + Upgrade-SPContentDatabase $Name -Confirm:$false -Verbose + } + $updateFinished = Get-date + Write-Output "Update for SharePoint SPContentDatabase $($Name) is finished at $updateFinished" + } + else { + Write-Output "SPContentDatabase $($Name) already upgraded - No action needed" + } + } + else { + Write-Output "SPContentDatabase $($Name) does not exist - No action needed" + } + +} diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 new file mode 100644 index 0000000..fe8c8bb --- /dev/null +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 @@ -0,0 +1,44 @@ +@{ + RootModule = 'SPSUpdate.Common.psm1' + ModuleVersion = '4.0.0' + GUID = 'd6f4e2b7-3a1c-4d8e-9f2a-6c5b7e0a1d34' + Author = 'Jean-Cyril DROUHIN' + CompanyName = 'luigilink' + Copyright = '(c) Jean-Cyril DROUHIN. All rights reserved.' + Description = 'Shared functions for the SPSUpdate toolkit (install SharePoint Server cumulative updates: product update, PSConfig, content database mount/upgrade, side-by-side token, scheduled tasks and DPAPI secret helpers).' + + PowerShellVersion = '5.1' + + FunctionsToExport = @( + 'Add-SPSScheduledTask' + 'Add-SPSUpdateEvent' + 'Copy-SPSSideBySideFilesRemote' + 'Get-SPSInstalledProductVersion' + 'Get-SPSSecret' + 'Get-SPSServersPatchStatus' + 'Initialize-SPSContentDbJsonFile' + 'Mount-SPSContentDatabase' + 'Remove-SPSScheduledTask' + 'Set-SPSSecret' + 'Set-SPSSideBySideToken' + 'Start-SPSConfigExe' + 'Start-SPSConfigExeRemote' + 'Start-SPSProductUpdate' + 'Start-SPSScheduledTask' + 'Test-SPSPendingReboot' + 'Update-SPSContentDatabase' + ) + + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @('Copy-SPSSideBySideFilesAllServers') + + PrivateData = @{ + PSData = @{ + Tags = @('SharePoint', 'SharePointServer', 'CumulativeUpdate', 'Patching', 'PSConfig', 'ContentDatabase') + LicenseUri = 'https://github.com/luigilink/SPSUpdate/blob/main/LICENSE' + ProjectUri = 'https://github.com/luigilink/SPSUpdate' + ReleaseNotes = 'https://github.com/luigilink/SPSUpdate/blob/main/RELEASE-NOTES.md' + } + } +} diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psm1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psm1 new file mode 100644 index 0000000..065a56c --- /dev/null +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psm1 @@ -0,0 +1,26 @@ +# ===================================================================================== +# SPSUpdate.Common - module loader +# +# Dot-sources every *.ps1 in Private/ and Public/, then exports only the public +# function names (read from the Public folder) plus any aliases declared in the +# manifest. Private functions remain accessible inside the module but are hidden +# from callers. +# ===================================================================================== + +$script:ModuleRoot = $PSScriptRoot + +$private = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private\*.ps1') -ErrorAction SilentlyContinue) +$public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public\*.ps1') -ErrorAction SilentlyContinue) + +foreach ($file in @($private + $public)) { + try { + . $file.FullName + } + catch { + Write-Error -Message "Failed to import function file '$($file.FullName)': $_" + } +} + +if ($public.Count -gt 0) { + Export-ModuleMember -Function $public.BaseName -Alias 'Copy-SPSSideBySideFilesAllServers' +} diff --git a/scripts/SPSUpdate.ps1 b/src/SPSUpdate.ps1 similarity index 80% rename from scripts/SPSUpdate.ps1 rename to src/SPSUpdate.ps1 index 72d4555..e5088d0 100644 --- a/scripts/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -1,804 +1,813 @@ -<# - .SYNOPSIS - SPSUpdate script for SharePoint Server. - - .DESCRIPTION - SPSUpdate is a PowerShell script tool designed to install cumulative updates in your SharePoint environment. - It's compatible with PowerShell version 5.1 and later. - - .PARAMETER ConfigFile - Need parameter ConfigFile, example: - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'E:\SCRIPT\CONFIG\contoso-PROD.json' - - .PARAMETER Action - Use the Action parameter equal to Install if you want to add the SPSUpdate script in taskscheduler - InstallAccount parameter need to be set - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) - - Use the Action parameter equal to Uninstall if you want to remove the SPSUpdate script from taskscheduler - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall - - Use the Action parameter equal to ProductUpdate if you want to run the ProductUpdate locally - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'contoso-PROD.json' - - Use the Action parameter equal to InitContentDB if you want to (re)generate the ContentDatabase JSON - inventory file used to prepare a farm upgrade (for example SharePoint 2019 to Subscription Edition). - This action runs Initialize-SPSContentDbJsonFile against the local farm and overwrites the existing - inventory file so that it always reflects the current state of the source farm. - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'contoso-PROD.json' - - .PARAMETER Sequence - Need parameter Sequence for SPS Farm, example: - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Sequence 1 - - .PARAMETER InstallAccount - Need parameter InstallAccount when you use the Action Install parameter - PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' - - .EXAMPLE - SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' - SPSUpdate.ps1 -Action Uninstall -ConfigFile 'contoso-PROD.json' - SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'contoso-PROD.json' - SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'contoso-PROD.json' - - .NOTES - FileName: SPSUpdate.ps1 - Author: Jean-Cyril DROUHIN - Date: June 11, 2026 - Version: 3.2.1 - - .LINK - https://spjc.fr/ - https://github.com/luigilink/SPSUpdate -#> -[CmdletBinding()] -param -( - [Parameter(Position = 0, Mandatory = $true)] - [ValidateScript({ (Test-Path $_) -and ($_ -like '*.json') })] - [System.String] - $ConfigFile, # Path to the configuration file - - [Parameter(Position = 1)] - [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', IgnoreCase = $true)] - [System.String] - $Action = 'Default', - - [Parameter(Position = 2)] - [ValidateRange(1, 4)] - [System.UInt32] - $Sequence, - - [Parameter(Position = 3)] - [System.Management.Automation.PSCredential] - $InstallAccount # Credential for the InstallAccount (when Action is Install) -) - -#region Initialization -# When the script is invoked with -Verbose, forward that preference to all downstream commands -# that support the common Verbose parameter, including imported module functions. -if ($PSBoundParameters.ContainsKey('Verbose')) { - $PSDefaultParameterValues['*:Verbose'] = $true -} - -# Clear the host console -Clear-Host - -# Define the path to the helper module -$script:HelperModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules' - -# Import the helper module -try { - Import-Module -Name (Join-Path -Path $script:HelperModulePath -ChildPath 'util.psm1') -Force -} -catch { - # Handle errors during Import of helper module - Write-Error -Message @" -Failed to import helper module from path: $($script:HelperModulePath) -Exception: $_ -"@ - Exit -} - - -# Import the credentialmanager module -try { - Import-Module -Name (Join-Path -Path (Join-Path -Path $script:HelperModulePath -ChildPath 'credentialmanager') -ChildPath 'CredentialManager.psd1') -Force -} -catch { - # Handle errors during Import of credentialmanager module - Write-Error -Message @" -Failed to import credentialmanager module from path: $(Join-Path -Path $script:HelperModulePath -ChildPath 'credentialmanager') -Exception: $_ -"@ - Exit -} - -# Ensure the script is running with administrator privileges -if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { - Throw "Administrator rights are required. Please re-run this script as an Administrator." -} - -# Define task name constants -$script:TaskNameFullScript = 'SPSUpdate-FullScript' -$script:TaskNameSequencePrefix = 'SPSUpdate-Sequence' -$script:TaskPath = 'SharePoint' - -# Function to validate configuration file -function Test-ConfigurationFile { - param( - [Parameter(Mandatory = $true)] - [System.String] - $ConfigFilePath, - - [Parameter(Mandatory = $true)] - [ref] - $ConfigObject - ) - - $requiredProperties = @('ApplicationName', 'ConfigurationName', 'Domain', 'FarmName', 'StoredCredential') - - # Check if file exists and is readable - if (-not (Test-Path $ConfigFilePath -PathType Leaf)) { - throw "Configuration file not found or not accessible: $ConfigFilePath" - } - - # Parse JSON - try { - $config = Get-Content $ConfigFilePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - } - catch { - throw "Configuration file is not valid JSON: $_" - } - - # Validate required properties - foreach ($property in $requiredProperties) { - if (-not ($config | Get-Member -Name $property -MemberType NoteProperty)) { - throw "Configuration file is missing required property: $property" - } - if ([string]::IsNullOrWhiteSpace($config.$property)) { - throw "Configuration property '$property' cannot be empty" - } - } - - $ConfigObject.Value = $config - return $true -} - -# Load and validate the configuration file -try { - $configRef = $null - if (Test-ConfigurationFile -ConfigFilePath $ConfigFile -ConfigObject ([ref]$configRef)) { - $jsonEnvCfg = $configRef - $Application = $jsonEnvCfg.ApplicationName - $Environment = $jsonEnvCfg.ConfigurationName - $scriptFQDN = $jsonEnvCfg.Domain - $spFarmName = $jsonEnvCfg.FarmName - Write-Verbose "Configuration file validated successfully: $ConfigFile" - } -} -catch { - Write-Error "Failed to load configuration file: $_" - Exit -} - -# Define variables -$SPSUpdateVersion = '3.2.1' -$getDateFormatted = Get-Date -Format yyyy-MM-dd_H-mm -$spsUpdateFileName = "$($Application)-$($Environment)_$($getDateFormatted)" -$spsUpdateDBsFile = "$($Application)-$($Environment)-$($spFarmName)-ContentDBs.json" -$currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name -$pathLogsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Logs' -$pathConfigFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Config' -$fullScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'SPSUpdate.ps1' -$spsUpdateDBsPath = Join-Path -Path $pathConfigFolder -ChildPath $spsUpdateDBsFile - -# Initialize logs -if (-Not (Test-Path -Path $pathLogsFolder)) { - New-Item -ItemType Directory -Path $pathLogsFolder -Force -} -if ($PSBoundParameters.ContainsKey('Sequence')) { - $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_Sequence$($Sequence)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') -} -elseif ($PSBoundParameters.ContainsKey('Action') -and $Action -eq 'ProductUpdate') { - $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_ProductUpdate-$($env:COMPUTERNAME)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') -} -elseif ($PSBoundParameters.ContainsKey('Action') -and $Action -eq 'InitContentDB') { - $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_InitContentDB-$($env:COMPUTERNAME)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') -} -else { - $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ($spsUpdateFileName + '.log') -} -$DateStarted = Get-Date -$psVersion = $PSVersionTable.PSVersion.ToString() -$script:TranscriptStarted = $false - -# Start transcript to log the output -try { - Start-Transcript -Path $pathLogFile -IncludeInvocationHeader -ErrorAction Stop - $script:TranscriptStarted = $true - Write-Output "Transcript log file: $pathLogFile" -} -catch { - Write-Warning "Unable to start transcript: $($_.Exception.Message)" - Write-Output "Transcript disabled for this run. Intended log file path: $pathLogFile" -} - -# Output the script information -Write-Output '-----------------------------------------------' -Write-Output "| SPSUpdate Script - v$SPSUpdateVersion" -Write-Output "| Started on - $DateStarted by $currentUser" -Write-Output "| PowerShell Version - $psVersion" -Write-Output '-----------------------------------------------' -#endregion - -#region Main Process - -# 0. Set power management plan to "High Performance" -Write-Verbose -Message "Setting power management plan to 'High Performance'..." -Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow - -# 1. Load SharePoint Powershell Snapin or Import-Module -try { - $installedVersion = Get-SPSInstalledProductVersion - Write-Output "Installed SharePoint Product Version: $($installedVersion.FileVersion)" - if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { - if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { - Add-PSSnapin Microsoft.SharePoint.PowerShell - } - } - else { - Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue - } -} -catch { - # Handle errors during retrieval of Installed Product Version - $catchMessage = @" -Failed to get installed Product Version for $($env:COMPUTERNAME) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-SPSInstalledProductVersion' -EntryType 'Error' -} - -# 2. Initialize or read ContentDatabase json file if UpgradeContentDatabase or MountContentDatabase equal to true -try { - if ($jsonEnvCfg.UpgradeContentDatabase -or $jsonEnvCfg.MountContentDatabase) { - if (-Not (Test-Path -Path $pathConfigFolder)) { - # If the path does not exist, create the directory - New-Item -ItemType Directory -Path $pathConfigFolder - } - if (Test-Path $spsUpdateDBsPath) { - Write-Output "Get ContentDatabase json file for SPFARM: $($spFarmName)" - $jsonDbCfg = Get-Content $spsUpdateDBsPath | ConvertFrom-Json - } - else { - # Initialize contentDb json file - "Initialize ContentDatabase json file for SPFARM: $($spFarmName)" - Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath - $jsonDbCfg = Get-Content $spsUpdateDBsPath | ConvertFrom-Json - } - } -} -catch { - # Handle errors during Initialize ContentDatabase json file - $catchMessage = @" -Failed to Initialize ContentDatabase json file for SPFARM: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Initialize-SPSContentDbJsonFile' -EntryType 'Error' -} - -# 3. Execute Action parameter -switch ($Action) { - 'InitContentDB' { - # (Re)generate the ContentDatabase inventory JSON file for the local farm. - # Typically used on a source farm (for example SP2019) to prepare an upgrade - # to a target farm (for example Subscription Edition) where the file will - # later be consumed by the MountContentDatabase flow. - try { - if (-Not (Test-Path -Path $pathConfigFolder)) { - New-Item -ItemType Directory -Path $pathConfigFolder -Force | Out-Null - } - Write-Output "Initializing ContentDatabase json file for SPFARM: $($spFarmName)" - Write-Output "Target file: $spsUpdateDBsPath" - Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath - if (Test-Path -Path $spsUpdateDBsPath) { - Write-Output "ContentDatabase json file generated successfully: $spsUpdateDBsPath" - } - else { - throw "ContentDatabase json file was not created: $spsUpdateDBsPath" - } - } - catch { - $catchMessage = @" -Failed to (re)Initialize ContentDatabase json file for SPFARM: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Initialize-SPSContentDbJsonFile' -EntryType 'Error' - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } - exit - } - } - 'Uninstall' { - # Remove scheduled Task for Update Full Script - try { - Write-Output "Removing Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path" - Remove-SPSScheduledTask -Name $script:TaskNameFullScript -TaskPath $script:TaskPath - } - catch { - # Handle errors during Remove scheduled Task for Update Full Script - $catchMessage = @" -Failed to Remove Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Remove-SPSScheduledTask' -EntryType 'Error' - } - # Remove scheduled Task for Upgrade SPContentDatabase in Parallel - try { - foreach ($taskId in (1..4)) { - $taskName = "$script:TaskNameSequencePrefix$taskId" - Write-Output "Removing Scheduled Tasks $taskName in $script:TaskPath Task Path" - Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath - } - } - catch { - $catchMessage = @" -Failed to Remove Scheduled Task $script:TaskNameSequencePrefix$taskId in $script:TaskPath Task Path -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Remove-SPSScheduledTask' -EntryType 'Error' - } - # Remove Credential from Credential Manager - try { - $credential = Get-StoredCredential -Target "$($jsonEnvCfg.StoredCredential)" -ErrorAction SilentlyContinue - if ($null -ne $credential) { - Remove-StoredCredential -Target "$($jsonEnvCfg.StoredCredential)" - } - } - catch { - # Handle errors during Get or Remove Credential in Crededential Manager - $catchMessage = @" -Failed to Get or Remove Credential in Crededential Manager for SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Remove-StoredCredential' -EntryType 'Error' - } - } - 'Install' { - # Check UserName and Password if Install parameter is used - if (-not($PSBoundParameters.ContainsKey('InstallAccount'))) { - Write-Warning -Message ('SPSUpdate: Install parameter is set. Please set also InstallAccount ' + ` - "parameter. `nSee https://github.com/luigilink/SPSUpdate/wiki for details.") - exit - } - else { - $UserName = $InstallAccount.UserName - $Password = $InstallAccount.GetNetworkCredential().Password - $currentDomain = 'LDAP://' + ([ADSI]'').distinguishedName - Write-Output "Checking Account `"$UserName`" ..." - $dom = New-Object System.DirectoryServices.DirectoryEntry($currentDomain, $UserName, $Password) - if ($null -eq $dom.Path) { - Write-Warning -Message "Password Invalid for user:`"$UserName`"" - exit - } - else { - # Add Credential in Credential Manager - try { - $credential = Get-StoredCredential -Target "$($jsonEnvCfg.StoredCredential)" -ErrorAction SilentlyContinue - if ($null -eq $credential) { - New-StoredCredential -Credentials $InstallAccount -Target "$($jsonEnvCfg.StoredCredential)" -Type Generic -Persist LocalMachine - } - } - catch { - # Handle errors during Get or Add Credential in Crededential Manager - $catchMessage = @" -Failed to Get or Add Credential in Crededential Manager for SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'New-StoredCredential' -EntryType 'Error' - } - } - # Add scheduled Task for Update Full Script - try { - # Initialize ActionArguments parameter - $ActionArguments = "-ExecutionPolicy Bypass -File `"$($fullScriptPath)`" -ConfigFile `"$($ConfigFile)`" -Verbose" - Write-Output "Adding Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path" - - # Check if task already exists - $existingTask = Get-ScheduledTask -TaskName $script:TaskNameFullScript -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue - if ($null -ne $existingTask) { - Write-Warning "Scheduled task '$script:TaskNameFullScript' already exists. Removing and recreating..." - Remove-SPSScheduledTask -Name $script:TaskNameFullScript -TaskPath $script:TaskPath - } - - Add-SPSScheduledTask -Name $script:TaskNameFullScript ` - -Description 'Scheduled Task for Update SharePoint Server after installation of cumulative update' ` - -ActionArguments $ActionArguments ` - -ExecuteAsCredential $InstallAccount ` - -TaskPath $script:TaskPath - } - catch { - # Handle errors during Add scheduled Task for Update Full Script - $catchMessage = @" -Failed to Add Scheduled Task in SharePoint Task Path for SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' - } - # Add scheduled Task for Upgrade SPContentDatabase if UpgradeContentDatabase or MountContentDatabase equal to true - if ($jsonEnvCfg.UpgradeContentDatabase -or $jsonEnvCfg.MountContentDatabase) { - # Get credential from Credential Manager - $credential = Get-StoredCredential -Target "$($jsonEnvCfg.StoredCredential)" -ErrorAction SilentlyContinue - if ($null -eq $credential) { - $credential = $InstallAccount - } - # Add scheduled Task for Upgrade SPContentDatabase in Parallel - foreach ($taskId in (1..4)) { - try { - $taskName = "$script:TaskNameSequencePrefix$taskId" - # Initialize ActionArguments parameter - $ActionArguments = "-ExecutionPolicy Bypass -File `"$($fullScriptPath)`" -ConfigFile `"$($ConfigFile)`" -Sequence $taskId -Verbose" - Write-Output "Adding Scheduled Task $taskName in $script:TaskPath Task Path" - - # Check if task already exists - $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue - if ($null -ne $existingTask) { - Write-Warning "Scheduled task '$taskName' already exists. Removing and recreating..." - Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath - } - - Add-SPSScheduledTask -Name $taskName ` - -Description "Scheduled Task Sequence$taskId for Update SharePoint Server after installation of cumulative update" ` - -ActionArguments $ActionArguments ` - -ExecuteAsCredential $credential ` - -TaskPath $script:TaskPath - } - catch { - # Handle errors during Add scheduled Task for Update Full Script - $catchMessage = @" -Failed to Add Scheduled Task in $script:TaskPath Task Path -Task Name: $taskName -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage # Handle any errors during task removal - Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } - exit - } - } - } - } - } - 'ProductUpdate' { - # Run ProductUpdate - try { - foreach ($setupFile in $jsonEnvCfg.Binaries.SetupFileName) { - $fullSetupFilePath = Join-Path -Path $jsonEnvCfg.Binaries.SetupFullPath -ChildPath $setupFile - $spTargetServer = ([System.Net.Dns]::GetHostByName($env:COMPUTERNAME).HostName).ToString() - Write-Output @" -Running ProductUpdate with following parameters: -SharePoint Server: $($spTargetServer) -Setup File Path: $($fullSetupFilePath) -Shutdown Services: $($jsonEnvCfg.Binaries.ShutdownServices) -"@ - # NOTE: Pending reboot detection was removed because on production farms the - # Windows reboot markers (CBS, PendingFileRenameOperations, etc.) commonly - # remain set after several reboots, which caused the script to abort the - # ProductUpdate even when the system was actually in a healthy state. - # Unblock setup file if it is blocked - Unblock-File -Path $fullSetupFilePath -Verbose - Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $jsonEnvCfg.Binaries.ShutdownServices -Verbose - } - } - catch { - # Handle errors during Run ProductUpdate - $catchMessage = @" -Failed to run ProductUpdate on server: $($env:COMPUTERNAME) -Target Server: $($spTargetServer) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } - exit - } - } - Default { - if ($PSBoundParameters.ContainsKey('Sequence')) { - try { - Write-Output "Update Script in progress | Sequence $Sequence - Please Wait ..." - switch ($Sequence) { - 1 { $dbs = $jsonDbCfg.SPContentDatabase1 } - 2 { $dbs = $jsonDbCfg.SPContentDatabase2 } - 3 { $dbs = $jsonDbCfg.SPContentDatabase3 } - 4 { $dbs = $jsonDbCfg.SPContentDatabase4 } - } - foreach ($db in $dbs) { - # Mount SPContentDatabase (typically used to attach databases coming from a - # previous farm version, for example SP2019 -> Subscription Edition migration). - # Mounts run inside each Sequence scheduled task, so the 4 sequences process - # their respective DB groups in parallel. Each database list is loaded from - # the ContentDatabase inventory JSON file produced by Initialize-SPSContentDbJsonFile. - # Mount and Upgrade are independent: a farm can be configured for Mount only, - # Upgrade only, or both (Mount then Upgrade for SP2019 -> SE migration). - if ($jsonEnvCfg.MountContentDatabase) { - try { - Mount-SPSContentDatabase -Name $db.Name -WebAppUrl $db.WebAppUrl -DatabaseServer $db.Server - } - catch { - $catchMessage = @" -Failed to Mount SPContentDatabase '$($db.Name)' on WebApplication '$($db.WebAppUrl)' -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' - } - } - if ($jsonEnvCfg.UpgradeContentDatabase) { - Update-SPSContentDatabase -Name $db.Name - } - } - } - catch { - # Handle errors during Update Script Sequence - $catchMessage = @" -Failed to Upgrade SPContentDatabse '$($db.Name)' during sequence: $($Sequence) -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Update-SPSContentDatabase' -EntryType 'Error' - } - } - else { - # Initialize Security - try { - $credential = Get-StoredCredential -Target "$($jsonEnvCfg.StoredCredential)" - } - catch { - # Handle errors during Update Script Sequence - $catchMessage = @" -Failed to initialize Security from Crededential Manager -The Target $($jsonEnvCfg.StoredCredential) not present in Credential Manager -Please review your configuration file or contact your administrator. -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-StoredCredential' -EntryType 'Error' - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } - exit - } - Write-Output "Update Script in progress | FULL Mode - Please Wait ..." - # Mount and/or Upgrade SPContentDatabase via parallel scheduled tasks. - # The sequence tasks themselves decide what to do for each database based on - # the MountContentDatabase and UpgradeContentDatabase flags in the JSON config. - if ($jsonEnvCfg.UpgradeContentDatabase -or $jsonEnvCfg.MountContentDatabase) { - # Add scheduled Task for Upgrade SPContentDatabase in Parallel - foreach ($taskId in (1..4)) { - try { - # Initialize ActionArguments parameter - $ActionArguments = "-ExecutionPolicy Bypass -File `"$($fullScriptPath)`" -ConfigFile `"$($ConfigFile)`" -Sequence $taskId -Verbose" - $taskName = "$script:TaskNameSequencePrefix$taskId" - Write-Output "Adding Scheduled Task $taskName in $script:TaskPath Task Path" - - # Check if task already exists - $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue - if ($null -ne $existingTask) { - Write-Warning "Scheduled task '$taskName' already exists. Removing and recreating..." - Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath - } - - Add-SPSScheduledTask -Name $taskName ` - -Description "Scheduled Task Sequence$taskId for Update SharePoint Server after installation of cumulative update" ` - -ActionArguments $ActionArguments ` - -ExecuteAsCredential $credential ` - -TaskPath $script:TaskPath - } - catch { - # Handle errors during Add scheduled Task for Update Full Script - $catchMessage = @" -Failed to Add Scheduled Task in $script:TaskPath Task Path -Task Name: $taskName -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } - exit - } - } - - # Run scheduled Task for Upgrade SPContentDatabase in Parallel - foreach ($taskId in (1..4)) { - try { - $taskName = "$script:TaskNameSequencePrefix$taskId" - Write-Output "Running Scheduled Task $taskName in $script:TaskPath Task Path" - $startResult = Start-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath -ErrorAction Stop - Write-Output "Start requested for $($startResult.Name) in $($startResult.TaskPath). Current state: $($startResult.State)" - Write-Output 'Avoid conflicts with OWSTimer process - Pause between 60 to 90 seconds' - Start-Sleep -Seconds (get-random (60..90)) - } - catch { - # Handle errors during Start scheduled Task for Upgrade SPContentDatabase in Parallel - $catchMessage = @" -Failed to Start Scheduled Task in $script:TaskPath Task Path -Task Name: $taskName -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSScheduledTask' -EntryType 'Error' - } - } - - # Wait until all scheduled Tasks are finished - # Define list variable of scheduled tasks - $scheduledTasks = @( - "$script:TaskNameSequencePrefix`1", - "$script:TaskNameSequencePrefix`2", - "$script:TaskNameSequencePrefix`3", - "$script:TaskNameSequencePrefix`4" - ) - - # Continuously check the status of tasks until all are finished - $allTasksFinished = $false - while (-not $allTasksFinished) { - $allTasksFinished = $true - foreach ($scheduledTask in $scheduledTasks) { - $taskStatus = Get-ScheduledTask -TaskName $scheduledTask -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue - if ($null -eq $taskStatus) { - Write-Warning "Scheduled Task $scheduledTask was not found in $script:TaskPath Task Path" - continue - } - if ($taskStatus.State -ne 'Running' -and $taskStatus.State -ne 'Queued') { - Write-Output "Scheduled Task $($scheduledTask) has finished or is not running" - } - else { - $allTasksFinished = $false - } - } - if (-not $allTasksFinished) { - Write-Output 'At least one task is still running. Waiting...' - Start-Sleep -Seconds 10 - } - } - Write-Output "All Scheduled Tasks have finished" - } - - # Run Configuration Wizard on Master SharePoint Server - try { - # Get patch status on Master SharePoint Server - Write-Output "Getting Patch Status on server: $($env:COMPUTERNAME)" - if ((Get-SPSServersPatchStatus -Server "$($env:COMPUTERNAME)") -eq 'NoActionRequired') { - Write-Output "No Action Required on server: $($env:COMPUTERNAME). Skipping Configuration Wizard." - } - else { - Write-Output "Action Required on server: $($env:COMPUTERNAME). Proceeding to run Configuration Wizard." - Start-SPSConfigExe - } - } - catch { - # Handle errors during Run SPConfigWizard on Master SharePoint Server - $catchMessage = @" -Failed to Run SPConfigWizard on Master SharePoint Server -Target Server: $($env:COMPUTERNAME) -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExe' -EntryType 'Error' - } - - # Run SPConfigWizard on other SharePoint Server - $spServers = Get-SPServer | Where-Object -FilterScript { $_.Role -ne 'Invalid' -and $_.Address -ne "$($env:COMPUTERNAME)" } - foreach ($spServer in $spServers) { - try { - # Get patch status on Master SharePoint Server - Write-Output "Getting Patch Status on server: $($spServer.Name)" - if ((Get-SPSServersPatchStatus -Server "$($spServer.Name)") -eq 'NoActionRequired') { - Write-Output "No Action Required on server: $($spServer.Name). Skipping Configuration Wizard." - } - else { - Write-Output "Action Required on server: $($spServer.Name). Proceeding to run Configuration Wizard." - $spTargetServer = "$($spServer.Name).$($scriptFQDN)" - Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential - } - } - catch { - # Handle errors during Run SPConfigWizard on remote SharePoint Server - $catchMessage = @" -Failed to Run SPConfigWizard on Remote SharePoint Server -Target Server: $($spTargetServer) -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExeRemote' -EntryType 'Error' - } - } - - # Enable SideBySideToken and run Copy-SPSideBySideFiles on master server - if (-not([string]::IsNullOrEmpty($jsonEnvCfg.SideBySideToken.BuildVersion))) { - try { - Write-Output "Configuring SharePoint SideBySideToken on farm $($spFarmName)" - Set-SPSSideBySideToken -BuildVersion "$($jsonEnvCfg.SideBySideToken.BuildVersion)" -EnableSideBySide $jsonEnvCfg.SideBySideToken.Enable - } - catch { - # Handle errors during Run Set-SPSSideBySideToken - $catchMessage = @" -Failed to Run Set-SPSSideBySideToken CmdLet -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSSideBySideToken' -EntryType 'Error' - } - } - - # Run Copy-SPSideBySideFiles on other servers - if ($jsonEnvCfg.SideBySideToken.Enable) { - $spServers = Get-SPServer | Where-Object -FilterScript { $_.Role -ne 'Invalid' -and $_.Address -ne "$($env:COMPUTERNAME)" } - foreach ($spServer in $spServers) { - try { - $spTargetServer = "$($spServer.Name).$($scriptFQDN)" - Copy-SPSSideBySideFilesRemote -Server $spTargetServer -InstallAccount $credential - } - catch { - # Handle errors during Run Copy-SPSSideBySideFilesAllServers - $catchMessage = @" -Failed to Run Copy-SPSideBySideFiles CmdLet -Target Server: $($spTargetServer) -Target SPFarm: $($spFarmName) -Exception: $_ -"@ - Write-Error -Message $catchMessage - Add-SPSUpdateEvent -Message $catchMessage -Source 'Copy-SPSSideBySideFiles' -EntryType 'Error' - } - } - } - } - } -} -#endregion - -# Clean-Up -$DateEnded = Get-Date -Write-Output '-----------------------------------------------' -Write-Output "| SPSUpdate Script Completed" -Write-Output "| Started on - $DateStarted" -Write-Output "| Ended on - $DateEnded" -Write-Output '-----------------------------------------------' -if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false -} -$loadedModules = @('util', 'CredentialManager') -$loadedModules | ForEach-Object { Remove-Module -Name $_ -ErrorAction SilentlyContinue } -$error.Clear() -Exit +<# + .SYNOPSIS + SPSUpdate script for SharePoint Server. + + .DESCRIPTION + SPSUpdate is a PowerShell script tool designed to install cumulative updates in your SharePoint environment. + It's compatible with PowerShell version 5.1 and later. + + Shared logic lives in the SPSUpdate.Common module (src/Modules/SPSUpdate.Common). The + environment configuration is a PowerShell data file (*.psd1) and the InstallAccount + credential is stored as a DPAPI-encrypted SecureString in Config\secrets.psd1 (there + is no longer any dependency on the Windows Credential Manager module). + + .PARAMETER ConfigFile + Path to the environment configuration file (*.psd1), example: + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'E:\SCRIPT\CONFIG\CONTOSO-PROD-CONTENT.psd1' + + .PARAMETER Action + Use the Action parameter equal to Install if you want to add the SPSUpdate script in taskscheduler. + InstallAccount parameter needs to be set. + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + + Use the Action parameter equal to Uninstall if you want to remove the SPSUpdate script from taskscheduler. + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + + Use the Action parameter equal to ProductUpdate if you want to run the ProductUpdate locally. + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + + Use the Action parameter equal to InitContentDB if you want to (re)generate the ContentDatabase JSON + inventory file used to prepare a farm upgrade (for example SharePoint 2019 to Subscription Edition). + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + + .PARAMETER Sequence + Need parameter Sequence for SPS Farm, example: + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Sequence 1 + + .PARAMETER InstallAccount + Need parameter InstallAccount when you use the Action Install parameter + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + + .EXAMPLE + SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + SPSUpdate.ps1 -Action Uninstall -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + + .NOTES + FileName: SPSUpdate.ps1 + Author: Jean-Cyril DROUHIN + Date: June 29, 2026 + Version: Defined by the SPSUpdate.Common module manifest (ModuleVersion) + + .LINK + https://spjc.fr/ + https://github.com/luigilink/SPSUpdate +#> +[CmdletBinding()] +param +( + [Parameter(Position = 0, Mandatory = $true)] + [ValidateScript({ (Test-Path $_) -and ($_ -like '*.psd1') })] + [System.String] + $ConfigFile, # Path to the configuration file + + [Parameter(Position = 1)] + [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', IgnoreCase = $true)] + [System.String] + $Action = 'Default', + + [Parameter(Position = 2)] + [ValidateRange(1, 4)] + [System.UInt32] + $Sequence, + + [Parameter(Position = 3)] + [System.Management.Automation.PSCredential] + $InstallAccount # Credential for the InstallAccount (when Action is Install) +) + +#region Initialization +# When the script is invoked with -Verbose, forward that preference to all downstream commands +# that support the common Verbose parameter, including imported module functions. +if ($PSBoundParameters.ContainsKey('Verbose')) { + $PSDefaultParameterValues['*:Verbose'] = $true +} + +# Clear the host console +Clear-Host +$Host.UI.RawUI.WindowTitle = "SPSUpdate script running on $env:COMPUTERNAME" + +# Import the helper module (SPSUpdate.Common) +$script:HelperModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules' +try { + Import-Module -Name (Join-Path -Path $script:HelperModulePath -ChildPath 'SPSUpdate.Common\SPSUpdate.Common.psd1') -Force -ErrorAction Stop +} +catch { + Write-Error -Message @" +Failed to import SPSUpdate.Common module from path: $($script:HelperModulePath) +Exception: $_ +"@ + Exit +} + +# Ensure the script is running with administrator privileges +if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { + Throw "Administrator rights are required. Please re-run this script as an Administrator." +} + +# Define task name constants +$script:TaskNameFullScript = 'SPSUpdate-FullScript' +$script:TaskNameSequencePrefix = 'SPSUpdate-Sequence' +$script:TaskPath = 'SharePoint' + +# Function to load, validate and normalize the psd1 configuration file. +# Required keys raise a clear error; optional behaviour keys fall back to safe defaults. +function Get-SPSUpdateConfiguration { + param( + [Parameter(Mandatory = $true)] + [System.String] + $ConfigFilePath + ) + + if (-not (Test-Path $ConfigFilePath -PathType Leaf)) { + throw "Configuration file not found or not accessible: $ConfigFilePath" + } + + try { + $config = Import-PowerShellDataFile -Path $ConfigFilePath -ErrorAction Stop + } + catch { + throw "Configuration file is not a valid PowerShell data file (psd1): $_" + } + + # Validate required top-level properties + $requiredProperties = @('ApplicationName', 'ConfigurationName', 'Domain', 'FarmName', 'CredentialKey') + foreach ($property in $requiredProperties) { + if (-not $config.ContainsKey($property)) { + throw "Configuration file is missing required property: $property" + } + if ([string]::IsNullOrWhiteSpace([string]$config[$property])) { + throw "Configuration property '$property' cannot be empty" + } + } + + # Normalize the Binaries block and apply defaults + if (-not $config.ContainsKey('Binaries') -or $null -eq $config.Binaries) { + $config.Binaries = @{} + } + if (-not $config.Binaries.ContainsKey('ProductUpdate')) { + $config.Binaries.ProductUpdate = $true + } + if (-not $config.Binaries.ContainsKey('ShutdownServices')) { + $config.Binaries.ShutdownServices = $true + } + + # Apply content-database defaults + if (-not $config.ContainsKey('MountContentDatabase')) { + $config.MountContentDatabase = $false + } + if (-not $config.ContainsKey('UpgradeContentDatabase')) { + $config.UpgradeContentDatabase = $true + } + + # Normalize the SideBySideToken block and apply defaults + if (-not $config.ContainsKey('SideBySideToken') -or $null -eq $config.SideBySideToken) { + $config.SideBySideToken = @{} + } + if (-not $config.SideBySideToken.ContainsKey('Enable')) { + $config.SideBySideToken.Enable = $false + } + if (-not $config.SideBySideToken.ContainsKey('BuildVersion')) { + $config.SideBySideToken.BuildVersion = '' + } + + return $config +} + +# Load and validate the configuration file +try { + $envCfg = Get-SPSUpdateConfiguration -ConfigFilePath $ConfigFile + $Application = $envCfg.ApplicationName + $Environment = $envCfg.ConfigurationName + $scriptFQDN = $envCfg.Domain + $spFarmName = $envCfg.FarmName + Write-Verbose "Configuration file validated successfully: $ConfigFile" +} +catch { + Write-Error "Failed to load configuration file: $_" + Exit +} + +# Define variables +$SPSUpdateVersion = (Get-Module -Name 'SPSUpdate.Common').Version.ToString() +$getDateFormatted = Get-Date -Format yyyy-MM-dd_H-mm +$spsUpdateFileName = "$($Application)-$($Environment)_$($getDateFormatted)" +$spsUpdateDBsFile = "$($Application)-$($Environment)-$($spFarmName)-ContentDBs.json" +$currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name +$pathLogsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Logs' +$pathConfigFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Config' +$fullScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'SPSUpdate.ps1' +$spsUpdateDBsPath = Join-Path -Path $pathConfigFolder -ChildPath $spsUpdateDBsFile + +# Initialize logs +if (-Not (Test-Path -Path $pathLogsFolder)) { + New-Item -ItemType Directory -Path $pathLogsFolder -Force | Out-Null +} +if ($PSBoundParameters.ContainsKey('Sequence')) { + $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_Sequence$($Sequence)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') +} +elseif ($PSBoundParameters.ContainsKey('Action') -and $Action -eq 'ProductUpdate') { + $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_ProductUpdate-$($env:COMPUTERNAME)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') +} +elseif ($PSBoundParameters.ContainsKey('Action') -and $Action -eq 'InitContentDB') { + $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_InitContentDB-$($env:COMPUTERNAME)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') +} +else { + $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ($spsUpdateFileName + '.log') +} +$DateStarted = Get-Date +$psVersion = $PSVersionTable.PSVersion.ToString() +$script:TranscriptStarted = $false + +# Start transcript to log the output +try { + Start-Transcript -Path $pathLogFile -IncludeInvocationHeader -ErrorAction Stop + $script:TranscriptStarted = $true + Write-Output "Transcript log file: $pathLogFile" +} +catch { + Write-Warning "Unable to start transcript: $($_.Exception.Message)" + Write-Output "Transcript disabled for this run. Intended log file path: $pathLogFile" +} + +# Output the script information +Write-Output '-----------------------------------------------' +Write-Output "| SPSUpdate Script - v$SPSUpdateVersion" +Write-Output "| Started on - $DateStarted by $currentUser" +Write-Output "| PowerShell Version - $psVersion" +Write-Output '-----------------------------------------------' +#endregion + +#region Main Process + +# 0. Set power management plan to "High Performance" +Write-Verbose -Message "Setting power management plan to 'High Performance'..." +Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow + +# 1. Load SharePoint Powershell Snapin or Import-Module +try { + $installedVersion = Get-SPSInstalledProductVersion + Write-Output "Installed SharePoint Product Version: $($installedVersion.FileVersion)" + if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { + if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { + Add-PSSnapin Microsoft.SharePoint.PowerShell + } + } + else { + Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue + } +} +catch { + # Handle errors during retrieval of Installed Product Version + $catchMessage = @" +Failed to get installed Product Version for $($env:COMPUTERNAME) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-SPSInstalledProductVersion' -EntryType 'Error' +} + +# 2. For the Default action only (full run on the master + each -Sequence sub-run), +# read or prime the ContentDatabase inventory JSON used by the mount/upgrade sequences. +# ProductUpdate/Install/Uninstall never touch the inventory; InitContentDB regenerates it +# in its own action block below. +try { + if ($Action -eq 'Default' -and ($envCfg.UpgradeContentDatabase -or $envCfg.MountContentDatabase)) { + if (-Not (Test-Path -Path $pathConfigFolder)) { + # If the path does not exist, create the directory + New-Item -ItemType Directory -Path $pathConfigFolder | Out-Null + } + if (Test-Path $spsUpdateDBsPath) { + Write-Output "Get ContentDatabase json file for SPFARM: $($spFarmName)" + $jsonDbCfg = Get-Content $spsUpdateDBsPath | ConvertFrom-Json + } + else { + # Initialize contentDb json file + "Initialize ContentDatabase json file for SPFARM: $($spFarmName)" + Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath + $jsonDbCfg = Get-Content $spsUpdateDBsPath | ConvertFrom-Json + } + } +} +catch { + # Handle errors during Initialize ContentDatabase json file + $catchMessage = @" +Failed to Initialize ContentDatabase json file for SPFARM: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Initialize-SPSContentDbJsonFile' -EntryType 'Error' +} + +# 3. Execute Action parameter +switch ($Action) { + 'InitContentDB' { + # (Re)generate the ContentDatabase inventory JSON file for the local farm. + # Typically used on a source farm (for example SP2019) to prepare an upgrade + # to a target farm (for example Subscription Edition) where the file will + # later be consumed by the MountContentDatabase flow. + try { + if (-Not (Test-Path -Path $pathConfigFolder)) { + New-Item -ItemType Directory -Path $pathConfigFolder -Force | Out-Null + } + Write-Output "Initializing ContentDatabase json file for SPFARM: $($spFarmName)" + Write-Output "Target file: $spsUpdateDBsPath" + Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath + if (Test-Path -Path $spsUpdateDBsPath) { + Write-Output "ContentDatabase json file generated successfully: $spsUpdateDBsPath" + } + else { + throw "ContentDatabase json file was not created: $spsUpdateDBsPath" + } + } + catch { + $catchMessage = @" +Failed to (re)Initialize ContentDatabase json file for SPFARM: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Initialize-SPSContentDbJsonFile' -EntryType 'Error' + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } + exit + } + } + 'Uninstall' { + # Remove scheduled Task for Update Full Script + try { + Write-Output "Removing Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path" + Remove-SPSScheduledTask -Name $script:TaskNameFullScript -TaskPath $script:TaskPath + } + catch { + # Handle errors during Remove scheduled Task for Update Full Script + $catchMessage = @" +Failed to Remove Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Remove-SPSScheduledTask' -EntryType 'Error' + } + # Remove scheduled Task for Upgrade SPContentDatabase in Parallel + try { + foreach ($taskId in (1..4)) { + $taskName = "$script:TaskNameSequencePrefix$taskId" + Write-Output "Removing Scheduled Tasks $taskName in $script:TaskPath Task Path" + Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath + } + } + catch { + $catchMessage = @" +Failed to Remove Scheduled Task $script:TaskNameSequencePrefix$taskId in $script:TaskPath Task Path +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Remove-SPSScheduledTask' -EntryType 'Error' + } + # Remove the stored secret from secrets.psd1 (if present) + try { + Set-SPSSecret -CredentialKey $envCfg.CredentialKey -ConfigPath $pathConfigFolder -Remove + Write-Output "Removed secret '$($envCfg.CredentialKey)' from Config\secrets.psd1 (if it was present)." + } + catch { + # Handle errors during secret removal + $catchMessage = @" +Failed to remove secret '$($envCfg.CredentialKey)' from Config\secrets.psd1 for SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSSecret' -EntryType 'Error' + } + } + 'Install' { + # Check UserName and Password if Install parameter is used + if (-not($PSBoundParameters.ContainsKey('InstallAccount'))) { + Write-Warning -Message ('SPSUpdate: Install parameter is set. Please set also InstallAccount ' + ` + "parameter. `nSee https://github.com/luigilink/SPSUpdate/wiki for details.") + exit + } + else { + $UserName = $InstallAccount.UserName + $Password = $InstallAccount.GetNetworkCredential().Password + $currentDomain = 'LDAP://' + ([ADSI]'').distinguishedName + Write-Output "Checking Account `"$UserName`" ..." + $dom = New-Object System.DirectoryServices.DirectoryEntry($currentDomain, $UserName, $Password) + if ($null -eq $dom.Path) { + Write-Warning -Message "Password Invalid for user:`"$UserName`"" + exit + } + else { + # Persist the InstallAccount as a DPAPI-encrypted SecureString in secrets.psd1. + # Run -Action Install AS the InstallAccount so it can be decrypted at run time. + try { + Set-SPSSecret -CredentialKey $envCfg.CredentialKey -Credential $InstallAccount -ConfigPath $pathConfigFolder + Write-Output "Stored secret '$($envCfg.CredentialKey)' in Config\secrets.psd1." + } + catch { + # Handle errors during secret storage + $catchMessage = @" +Failed to store secret '$($envCfg.CredentialKey)' in Config\secrets.psd1 for SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSSecret' -EntryType 'Error' + } + } + # Add scheduled Task for Update Full Script + try { + # Initialize ActionArguments parameter + $ActionArguments = "-ExecutionPolicy Bypass -File `"$($fullScriptPath)`" -ConfigFile `"$($ConfigFile)`" -Verbose" + Write-Output "Adding Scheduled Task $script:TaskNameFullScript in $script:TaskPath Task Path" + + # Check if task already exists + $existingTask = Get-ScheduledTask -TaskName $script:TaskNameFullScript -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue + if ($null -ne $existingTask) { + Write-Warning "Scheduled task '$script:TaskNameFullScript' already exists. Removing and recreating..." + Remove-SPSScheduledTask -Name $script:TaskNameFullScript -TaskPath $script:TaskPath + } + + Add-SPSScheduledTask -Name $script:TaskNameFullScript ` + -Description 'Scheduled Task for Update SharePoint Server after installation of cumulative update' ` + -ActionArguments $ActionArguments ` + -ExecuteAsCredential $InstallAccount ` + -TaskPath $script:TaskPath + } + catch { + # Handle errors during Add scheduled Task for Update Full Script + $catchMessage = @" +Failed to Add Scheduled Task in SharePoint Task Path for SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' + } + # Add scheduled Task for Upgrade SPContentDatabase if UpgradeContentDatabase or MountContentDatabase equal to true + if ($envCfg.UpgradeContentDatabase -or $envCfg.MountContentDatabase) { + # Get credential from the DPAPI secret store; fall back to the InstallAccount + $credential = Get-SPSSecret -CredentialKey $envCfg.CredentialKey -ConfigPath $pathConfigFolder + if ($null -eq $credential) { + $credential = $InstallAccount + } + # Add scheduled Task for Upgrade SPContentDatabase in Parallel + foreach ($taskId in (1..4)) { + try { + $taskName = "$script:TaskNameSequencePrefix$taskId" + # Initialize ActionArguments parameter + $ActionArguments = "-ExecutionPolicy Bypass -File `"$($fullScriptPath)`" -ConfigFile `"$($ConfigFile)`" -Sequence $taskId -Verbose" + Write-Output "Adding Scheduled Task $taskName in $script:TaskPath Task Path" + + # Check if task already exists + $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue + if ($null -ne $existingTask) { + Write-Warning "Scheduled task '$taskName' already exists. Removing and recreating..." + Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath + } + + Add-SPSScheduledTask -Name $taskName ` + -Description "Scheduled Task Sequence$taskId for Update SharePoint Server after installation of cumulative update" ` + -ActionArguments $ActionArguments ` + -ExecuteAsCredential $credential ` + -TaskPath $script:TaskPath + } + catch { + # Handle errors during Add scheduled Task for Update Full Script + $catchMessage = @" +Failed to Add Scheduled Task in $script:TaskPath Task Path +Task Name: $taskName +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage # Handle any errors during task removal + Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } + exit + } + } + } + } + } + 'ProductUpdate' { + # Run ProductUpdate + try { + foreach ($setupFile in $envCfg.Binaries.SetupFileName) { + $fullSetupFilePath = Join-Path -Path $envCfg.Binaries.SetupFullPath -ChildPath $setupFile + $spTargetServer = ([System.Net.Dns]::GetHostByName($env:COMPUTERNAME).HostName).ToString() + Write-Output @" +Running ProductUpdate with following parameters: +SharePoint Server: $($spTargetServer) +Setup File Path: $($fullSetupFilePath) +Shutdown Services: $($envCfg.Binaries.ShutdownServices) +"@ + # NOTE: Pending reboot detection was removed because on production farms the + # Windows reboot markers (CBS, PendingFileRenameOperations, etc.) commonly + # remain set after several reboots, which caused the script to abort the + # ProductUpdate even when the system was actually in a healthy state. + # Unblock setup file if it is blocked + Unblock-File -Path $fullSetupFilePath -Verbose + Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $envCfg.Binaries.ShutdownServices -Verbose + } + } + catch { + # Handle errors during Run ProductUpdate + $catchMessage = @" +Failed to run ProductUpdate on server: $($env:COMPUTERNAME) +Target Server: $($spTargetServer) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } + exit + } + } + Default { + if ($PSBoundParameters.ContainsKey('Sequence')) { + try { + Write-Output "Update Script in progress | Sequence $Sequence - Please Wait ..." + switch ($Sequence) { + 1 { $dbs = $jsonDbCfg.SPContentDatabase1 } + 2 { $dbs = $jsonDbCfg.SPContentDatabase2 } + 3 { $dbs = $jsonDbCfg.SPContentDatabase3 } + 4 { $dbs = $jsonDbCfg.SPContentDatabase4 } + } + foreach ($db in $dbs) { + # Mount SPContentDatabase (typically used to attach databases coming from a + # previous farm version, for example SP2019 -> Subscription Edition migration). + # Mounts run inside each Sequence scheduled task, so the 4 sequences process + # their respective DB groups in parallel. Each database list is loaded from + # the ContentDatabase inventory JSON file produced by Initialize-SPSContentDbJsonFile. + # Mount and Upgrade are independent: a farm can be configured for Mount only, + # Upgrade only, or both (Mount then Upgrade for SP2019 -> SE migration). + if ($envCfg.MountContentDatabase) { + try { + Mount-SPSContentDatabase -Name $db.Name -WebAppUrl $db.WebAppUrl -DatabaseServer $db.Server + } + catch { + $catchMessage = @" +Failed to Mount SPContentDatabase '$($db.Name)' on WebApplication '$($db.WebAppUrl)' +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' + } + } + if ($envCfg.UpgradeContentDatabase) { + Update-SPSContentDatabase -Name $db.Name + } + } + } + catch { + # Handle errors during Update Script Sequence + $catchMessage = @" +Failed to Upgrade SPContentDatabse '$($db.Name)' during sequence: $($Sequence) +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Update-SPSContentDatabase' -EntryType 'Error' + } + } + else { + # Initialize Security from the DPAPI secret store + try { + $credential = Get-SPSSecret -CredentialKey $envCfg.CredentialKey -ConfigPath $pathConfigFolder + if ($null -eq $credential) { + throw "Secret '$($envCfg.CredentialKey)' was not found in Config\secrets.psd1." + } + } + catch { + # Handle errors during Security initialization + $catchMessage = @" +Failed to initialize Security from Config\secrets.psd1 +The secret '$($envCfg.CredentialKey)' is not present. Run SPSUpdate.ps1 -Action Install as the +InstallAccount, or populate secrets.psd1 manually. See the wiki for details. +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-SPSSecret' -EntryType 'Error' + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } + exit + } + Write-Output "Update Script in progress | FULL Mode - Please Wait ..." + # Mount and/or Upgrade SPContentDatabase via parallel scheduled tasks. + # The sequence tasks themselves decide what to do for each database based on + # the MountContentDatabase and UpgradeContentDatabase flags in the config. + if ($envCfg.UpgradeContentDatabase -or $envCfg.MountContentDatabase) { + # Add scheduled Task for Upgrade SPContentDatabase in Parallel + foreach ($taskId in (1..4)) { + try { + # Initialize ActionArguments parameter + $ActionArguments = "-ExecutionPolicy Bypass -File `"$($fullScriptPath)`" -ConfigFile `"$($ConfigFile)`" -Sequence $taskId -Verbose" + $taskName = "$script:TaskNameSequencePrefix$taskId" + Write-Output "Adding Scheduled Task $taskName in $script:TaskPath Task Path" + + # Check if task already exists + $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue + if ($null -ne $existingTask) { + Write-Warning "Scheduled task '$taskName' already exists. Removing and recreating..." + Remove-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath + } + + Add-SPSScheduledTask -Name $taskName ` + -Description "Scheduled Task Sequence$taskId for Update SharePoint Server after installation of cumulative update" ` + -ActionArguments $ActionArguments ` + -ExecuteAsCredential $credential ` + -TaskPath $script:TaskPath + } + catch { + # Handle errors during Add scheduled Task for Update Full Script + $catchMessage = @" +Failed to Add Scheduled Task in $script:TaskPath Task Path +Task Name: $taskName +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } + exit + } + } + + # Run scheduled Task for Upgrade SPContentDatabase in Parallel + foreach ($taskId in (1..4)) { + try { + $taskName = "$script:TaskNameSequencePrefix$taskId" + Write-Output "Running Scheduled Task $taskName in $script:TaskPath Task Path" + $startResult = Start-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath -ErrorAction Stop + Write-Output "Start requested for $($startResult.Name) in $($startResult.TaskPath). Current state: $($startResult.State)" + Write-Output 'Avoid conflicts with OWSTimer process - Pause between 60 to 90 seconds' + Start-Sleep -Seconds (get-random (60..90)) + } + catch { + # Handle errors during Start scheduled Task for Upgrade SPContentDatabase in Parallel + $catchMessage = @" +Failed to Start Scheduled Task in $script:TaskPath Task Path +Task Name: $taskName +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSScheduledTask' -EntryType 'Error' + } + } + + # Wait until all scheduled Tasks are finished + # Define list variable of scheduled tasks + $scheduledTasks = @( + "$script:TaskNameSequencePrefix`1", + "$script:TaskNameSequencePrefix`2", + "$script:TaskNameSequencePrefix`3", + "$script:TaskNameSequencePrefix`4" + ) + + # Continuously check the status of tasks until all are finished + $allTasksFinished = $false + while (-not $allTasksFinished) { + $allTasksFinished = $true + foreach ($scheduledTask in $scheduledTasks) { + $taskStatus = Get-ScheduledTask -TaskName $scheduledTask -TaskPath "\$script:TaskPath\" -ErrorAction SilentlyContinue + if ($null -eq $taskStatus) { + Write-Warning "Scheduled Task $scheduledTask was not found in $script:TaskPath Task Path" + continue + } + if ($taskStatus.State -ne 'Running' -and $taskStatus.State -ne 'Queued') { + Write-Output "Scheduled Task $($scheduledTask) has finished or is not running" + } + else { + $allTasksFinished = $false + } + } + if (-not $allTasksFinished) { + Write-Output 'At least one task is still running. Waiting...' + Start-Sleep -Seconds 10 + } + } + Write-Output "All Scheduled Tasks have finished" + } + + # Run Configuration Wizard on Master SharePoint Server + try { + # Get patch status on Master SharePoint Server + Write-Output "Getting Patch Status on server: $($env:COMPUTERNAME)" + if ((Get-SPSServersPatchStatus -Server "$($env:COMPUTERNAME)") -eq 'NoActionRequired') { + Write-Output "No Action Required on server: $($env:COMPUTERNAME). Skipping Configuration Wizard." + } + else { + Write-Output "Action Required on server: $($env:COMPUTERNAME). Proceeding to run Configuration Wizard." + Start-SPSConfigExe + } + } + catch { + # Handle errors during Run SPConfigWizard on Master SharePoint Server + $catchMessage = @" +Failed to Run SPConfigWizard on Master SharePoint Server +Target Server: $($env:COMPUTERNAME) +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExe' -EntryType 'Error' + } + + # Run SPConfigWizard on other SharePoint Server + $spServers = Get-SPServer | Where-Object -FilterScript { $_.Role -ne 'Invalid' -and $_.Address -ne "$($env:COMPUTERNAME)" } + foreach ($spServer in $spServers) { + try { + # Get patch status on Master SharePoint Server + Write-Output "Getting Patch Status on server: $($spServer.Name)" + if ((Get-SPSServersPatchStatus -Server "$($spServer.Name)") -eq 'NoActionRequired') { + Write-Output "No Action Required on server: $($spServer.Name). Skipping Configuration Wizard." + } + else { + Write-Output "Action Required on server: $($spServer.Name). Proceeding to run Configuration Wizard." + $spTargetServer = "$($spServer.Name).$($scriptFQDN)" + Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential + } + } + catch { + # Handle errors during Run SPConfigWizard on remote SharePoint Server + $catchMessage = @" +Failed to Run SPConfigWizard on Remote SharePoint Server +Target Server: $($spTargetServer) +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExeRemote' -EntryType 'Error' + } + } + + # Enable SideBySideToken and run Copy-SPSideBySideFiles on master server + if (-not([string]::IsNullOrEmpty($envCfg.SideBySideToken.BuildVersion))) { + try { + Write-Output "Configuring SharePoint SideBySideToken on farm $($spFarmName)" + Set-SPSSideBySideToken -BuildVersion "$($envCfg.SideBySideToken.BuildVersion)" -EnableSideBySide $envCfg.SideBySideToken.Enable + } + catch { + # Handle errors during Run Set-SPSSideBySideToken + $catchMessage = @" +Failed to Run Set-SPSSideBySideToken CmdLet +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSSideBySideToken' -EntryType 'Error' + } + } + + # Run Copy-SPSideBySideFiles on other servers + if ($envCfg.SideBySideToken.Enable) { + $spServers = Get-SPServer | Where-Object -FilterScript { $_.Role -ne 'Invalid' -and $_.Address -ne "$($env:COMPUTERNAME)" } + foreach ($spServer in $spServers) { + try { + $spTargetServer = "$($spServer.Name).$($scriptFQDN)" + Copy-SPSSideBySideFilesRemote -Server $spTargetServer -InstallAccount $credential + } + catch { + # Handle errors during Run Copy-SPSSideBySideFilesAllServers + $catchMessage = @" +Failed to Run Copy-SPSideBySideFiles CmdLet +Target Server: $($spTargetServer) +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Copy-SPSSideBySideFiles' -EntryType 'Error' + } + } + } + } + } +} +#endregion + +# Clean-Up +$DateEnded = Get-Date +Write-Output '-----------------------------------------------' +Write-Output "| SPSUpdate Script Completed" +Write-Output "| Started on - $DateStarted" +Write-Output "| Ended on - $DateEnded" +Write-Output '-----------------------------------------------' +if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false +} +Remove-Module -Name 'SPSUpdate.Common' -ErrorAction SilentlyContinue +$error.Clear() +Exit diff --git a/src/SPSUpdate_README.md b/src/SPSUpdate_README.md new file mode 100644 index 0000000..ddeb96d --- /dev/null +++ b/src/SPSUpdate_README.md @@ -0,0 +1,193 @@ +# SPSUpdate Installation Guide + +This document provides instructions for installing and configuring the **SPSUpdate** +PowerShell script in environments **without internet access**. It is intended for +SharePoint On-Premises administrators who need to install cumulative updates in their +SharePoint environment. + +> This guide ships inside the release package so it is available offline on the server. +> For the full online documentation, see the [SPSUpdate Wiki](https://github.com/luigilink/SPSUpdate/wiki). + +## 📦 Prerequisites + +- SharePoint Server 2016, 2019 or Subscription Edition +- Administrator privileges on the server +- PowerShell 5.1 or later (no DSC module required) +- A service account (`InstallAccount`) for the scheduled tasks and CredSSP remoting +- CredSSP configured (used to reach the other farm servers) + +## 📁 Files Required + +The release package extracts directly to the following layout — keep them together: + +- `SPSUpdate.ps1` (main script) +- `Modules\SPSUpdate.Common\` (the helper module — **required**) +- `Config\` (your environment `*.psd1` config and the DPAPI `secrets.psd1`) + +## 🛠 Installation Steps + +### 1. Copy files to the server + +Place `SPSUpdate.ps1`, the `Modules\` folder and the `Config\` folder in a local folder +on the SharePoint server, e.g. `E:\SCRIPT\`. + +### 2. Prepare your environment configuration (psd1) + +Copy `Config\CONTOSO-PROD.example.psd1` to a real file (one per farm, e.g. +`CONTOSO-PROD-CONTENT.psd1`) and edit the values: + +```powershell +@{ + ConfigurationName = 'PROD' + ApplicationName = 'contoso' + FarmName = 'CONTENT' + Domain = 'contoso.com' + CredentialKey = 'PROD-ADM' + + Binaries = @{ + ProductUpdate = $true + SetupFullPath = 'D:\SoftwarePackages\SPS\cumulativeupdates' + SetupFileName = @('uber-subscription-kb5002651-fullfile-x64-glb.exe') + ShutdownServices = $true + } + + MountContentDatabase = $false + UpgradeContentDatabase = $true + + SideBySideToken = @{ + Enable = $false + BuildVersion = '' + } +} +``` + +#### ConfigurationName, ApplicationName and FarmName + +`ConfigurationName`, `ApplicationName` and `FarmName` populate the `Environment`, +`Application` and `FarmName` PowerShell variables and are used in the log/result and +ContentDB inventory file names. All three are required. + +#### CredentialKey and the secret store (DPAPI) + +`CredentialKey` is the name of the entry in `Config\secrets.psd1` that holds the +`InstallAccount`. SPSUpdate no longer uses the Windows Credential Manager: the credential +is stored as a DPAPI-encrypted SecureString. Running `-Action Install` as the service +account writes it for you (see step 3). To create it manually, on the server signed in as +that account: + +```powershell +Read-Host -AsSecureString -Prompt 'Password' | ConvertFrom-SecureString +``` + +Paste the result into `Config\secrets.psd1` (copy `secrets.example.psd1`). The value can +only be decrypted by the same account on the same machine. `secrets.psd1` must never be +shared or committed. + +#### Binaries settings + +Use `ProductUpdate`, `SetupFullPath`, `SetupFileName` and `ShutdownServices` to configure +the binary installation step. `SetupFileName` is an array (single uber package, or the +STS + WSSLOC language pair installed in order). + +#### UpgradeContentDatabase and MountContentDatabase + +`UpgradeContentDatabase` runs `Upgrade-SPContentDatabase` in parallel (4 sequences) for +databases that need it. `MountContentDatabase` attaches databases listed in the ContentDB +inventory (typically for a 2019 → Subscription Edition migration). Both accept `$true` or +`$false`. + +#### SideBySideToken + +Use `Enable` to turn on side-by-side patching and `BuildVersion` to set the token build +(leave empty to skip). + +### 3. Run the script with the Install action + +Open PowerShell **as the service account** (Run as a different user) and as Administrator, +then execute: + +```powershell +E:\SCRIPT\SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +This will: + +- Validate the credential and store it in `Config\secrets.psd1` (DPAPI) +- Add one scheduled task (`SPSUpdate-FullScript`) when `UpgradeContentDatabase` and + `MountContentDatabase` are both `$false` +- Add five scheduled tasks (`SPSUpdate-FullScript` + `SPSUpdate-Sequence1..4`) when either + is `$true` + +> Run `-Action Install` as the **same account** you pass to `-InstallAccount`, so the +> DPAPI secret can be decrypted by the scheduled tasks at run time. + +### 4. (Optional) Generate the ContentDB inventory — read-only + +On the source farm, you can build the content-database inventory without changing anything: + +```powershell +E:\SCRIPT\SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +This writes `---ContentDBs.json` (plus a +timestamped snapshot) under `Config\` and installs/upgrades nothing. + +### 5. Install the cumulative update binaries on each server + +Copy the same folder to each SharePoint server. On each server, open PowerShell as +Administrator and execute: + +```powershell +E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +This will: + +- Unblock the cumulative update file(s) if blocked +- Run `Start-SPSProductUpdate` for each setup file (optionally stopping/restoring services + when `ShutdownServices` is `$true`) + +`ProductUpdate` runs the SharePoint installer locally and does **not** require the +`InstallAccount` parameter. + +## ▶️ Running the upgrade (full mode) + +After the binaries are installed on every server, run the default mode once on the master +server. It mounts/upgrades the content databases in parallel (4 sequences), then runs the +post-setup Configuration Wizard (PSConfig) locally and on every other server over CredSSP, +and configures the side-by-side token when enabled: + +```powershell +E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +## 🔄 Uninstalling + +To remove the scheduled tasks and the stored secret: + +```powershell +E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +## 📚 Additional notes + +- Creates a `Logs` folder and a per-run transcript (sequence/action-aware naming). +- Verifies the script runs with Administrator rights before proceeding. +- Detects the installed SharePoint version (`Get-SPSInstalledProductVersion`) and loads the + appropriate SharePoint snap-in (2016/2019) or the `SharePointServer` module (SE). +- Full mode creates four sequence tasks (`SPSUpdate-Sequence1..4`) and starts them in + parallel (with short random sleeps to avoid OWSTimer conflicts). +- Remote operations (PSConfig, side-by-side) use CredSSP and fail with a clear error if the + session cannot be opened (they never silently fall back to the local server). +- Lifecycle and error events are written to the dedicated `SPSUpdate` Windows Event Log. + +## 📄 License + +MIT License + +## 👤 Authors + +- Jean-Cyril Drouhin (luigilink) + +For more details, refer to the embedded comments in `SPSUpdate.ps1` or the +[SPSUpdate Wiki](https://github.com/luigilink/SPSUpdate/wiki). diff --git a/tests/Configuration.Tests.ps1 b/tests/Configuration.Tests.ps1 new file mode 100644 index 0000000..6b37086 --- /dev/null +++ b/tests/Configuration.Tests.ps1 @@ -0,0 +1,70 @@ +# Tests for the example environment configuration and secret templates. +# Cross-platform: only validates psd1 parsing and the documented key contract. + +$repoRoot = Split-Path -Path $PSScriptRoot -Parent +$configDir = Join-Path -Path $repoRoot -ChildPath 'src/Config' +$envExample = Join-Path -Path $configDir -ChildPath 'CONTOSO-PROD.example.psd1' +$secExample = Join-Path -Path $configDir -ChildPath 'secrets.example.psd1' + +Describe 'Environment config example (CONTOSO-PROD.example.psd1)' { + BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $envExample = Join-Path -Path $repoRoot -ChildPath 'src/Config/CONTOSO-PROD.example.psd1' + $cfg = Import-PowerShellDataFile -Path $envExample + } + + It 'exists' { + Test-Path -Path $envExample | Should -BeTrue + } + + It 'parses as a PowerShell data file' { + { Import-PowerShellDataFile -Path $envExample } | Should -Not -Throw + } + + It 'defines every required identity key' { + foreach ($key in @('ApplicationName', 'ConfigurationName', 'Domain', 'FarmName', 'CredentialKey')) { + $cfg.ContainsKey($key) | Should -BeTrue + [string]::IsNullOrWhiteSpace([string]$cfg[$key]) | Should -BeFalse + } + } + + It 'no longer uses the legacy StoredCredential key' { + $cfg.ContainsKey('StoredCredential') | Should -BeFalse + } + + It 'defines a Binaries block with SetupFullPath and SetupFileName' { + $cfg.Binaries | Should -Not -BeNullOrEmpty + $cfg.Binaries.ContainsKey('SetupFullPath') | Should -BeTrue + $cfg.Binaries.SetupFileName | Should -Not -BeNullOrEmpty + } + + It 'defines a SideBySideToken block' { + $cfg.SideBySideToken | Should -Not -BeNullOrEmpty + $cfg.SideBySideToken.ContainsKey('Enable') | Should -BeTrue + } +} + +Describe 'Secrets example (secrets.example.psd1)' { + BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $secExample = Join-Path -Path $repoRoot -ChildPath 'src/Config/secrets.example.psd1' + $sec = Import-PowerShellDataFile -Path $secExample + } + + It 'parses as a PowerShell data file' { + { Import-PowerShellDataFile -Path $secExample } | Should -Not -Throw + } + + It 'each entry has a Username and a PasswordSecure placeholder' { + foreach ($key in $sec.Keys) { + $sec[$key].ContainsKey('Username') | Should -BeTrue + $sec[$key].ContainsKey('PasswordSecure') | Should -BeTrue + } + } + + It 'still carries the PASTE placeholder (no real secret committed)' { + foreach ($key in $sec.Keys) { + $sec[$key].PasswordSecure | Should -BeLike 'PASTE-*' + } + } +} diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 deleted file mode 100644 index 09382f0..0000000 --- a/tests/Modules/sps.util.Tests.ps1 +++ /dev/null @@ -1,352 +0,0 @@ -$repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent -Import-Module -Name (Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/util.psm1') -Force -Import-Module -Name (Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/sps.util.psm1') -Force - -if (-not (Get-Command -Name Get-Service -ErrorAction SilentlyContinue)) { - function global:Get-Service { param([string[]]$Name) } -} -if (-not (Get-Command -Name Set-Service -ErrorAction SilentlyContinue)) { - function global:Set-Service { param([string]$Name, [string]$StartupType) } -} -if (-not (Get-Command -Name Stop-Service -ErrorAction SilentlyContinue)) { - function global:Stop-Service { param([string]$Name, [switch]$Force) } -} -if (-not (Get-Command -Name Start-Service -ErrorAction SilentlyContinue)) { - function global:Start-Service { param([string]$Name) } -} -if (-not (Get-Command -Name Get-SPContentDatabase -ErrorAction SilentlyContinue)) { - function global:Get-SPContentDatabase { param([string]$Identity) } -} -if (-not (Get-Command -Name Get-SPWebApplication -ErrorAction SilentlyContinue)) { - function global:Get-SPWebApplication { param([string]$Identity) } -} -if (-not (Get-Command -Name Mount-SPContentDatabase -ErrorAction SilentlyContinue)) { - function global:Mount-SPContentDatabase { - param( - [string]$Name, - [string]$WebApplication, - [string]$DatabaseServer, - [switch]$Confirm - ) - } -} - -Describe 'Get-SPSLocalVersionInfo' { - It 'returns product DisplayVersion when patch metadata is missing' { - Mock -ModuleName sps.util -CommandName Get-ChildItem -MockWith { - @([pscustomobject]@{ PsPath = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Installer\\UserData\\S-1-5-18\\Products\\00000000F01FEC' }) - } - - Mock -ModuleName sps.util -CommandName Get-ItemProperty -ParameterFilter { $Path -like '*InstallProperties' } -MockWith { - [pscustomobject]@{ - DisplayName = 'Microsoft SharePoint Server 2019 Core' - DisplayVersion = '16.0.10337.12109' - } - } - - Mock -ModuleName sps.util -CommandName Get-ItemProperty -ParameterFilter { $Path -like '*\\Patches' } -MockWith { - [pscustomobject]@{ AllPatches = $null } - } - - Mock -ModuleName sps.util -CommandName Get-ItemProperty -MockWith { - [pscustomobject]@{} - } - - $result = Get-SPSLocalVersionInfo -ProductVersion '2019' - - $result | Should -BeOfType ([version]) - $result.ToString() | Should -Be '16.0.10337.12109' - } -} - -Describe 'Start-SPSProductUpdate' { - It 'does not expose InstallAccount parameter anymore' { - $cmd = Get-Command -Name Start-SPSProductUpdate -ErrorAction Stop - $cmd.Parameters.Keys | Should -Not -Contain 'InstallAccount' - } - - BeforeEach { - Mock -ModuleName sps.util -CommandName Test-Path -MockWith { $true } - Mock -ModuleName sps.util -CommandName Get-ItemProperty -ParameterFilter { $Path -eq 'C:\\setup.exe' } -MockWith { - [pscustomobject]@{ VersionInfo = [pscustomobject]@{ FileVersion = '16.0.20000.10000' } } - } - Mock -ModuleName sps.util -CommandName Get-SPSLocalVersionInfo -MockWith { [version]'16.0.10000.10000' } - Mock -ModuleName sps.util -CommandName Get-SPSInstalledProductVersion -MockWith { [pscustomobject]@{ ProductMajorPart = 16 } } - Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent - Mock -ModuleName sps.util -CommandName Start-Process -MockWith { [pscustomobject]@{ ExitCode = 0 } } - } - - It 'throws and logs when setup file is blocked' -Skip:(-not $IsWindows) { - Mock -ModuleName sps.util -CommandName Get-Item -MockWith { - [pscustomobject]@{ Stream = 'Zone.Identifier' } - } - - { Start-SPSProductUpdate -SetupFile 'C:\\setup.exe' -ShutdownServices $false } | - Should -Throw '*Setup file is blocked*' - - Assert-MockCalled -ModuleName sps.util -CommandName Add-SPSUpdateEvent -Times 1 -Exactly - } - - It 'reports the actual setupInstall exit code when installation fails' { - Mock -ModuleName sps.util -CommandName Get-Item -ParameterFilter { $Path -eq 'C:\\setup.exe' -and $Stream -eq 'Zone.Identifier' } -MockWith { $null } - Mock -ModuleName sps.util -CommandName Start-Process -ParameterFilter { $FilePath -eq 'C:\\setup.exe' } -MockWith { - [pscustomobject]@{ ExitCode = 1234 } - } - - { - Start-SPSProductUpdate -SetupFile 'C:\\setup.exe' -ShutdownServices $false - } | Should -Throw '*exit code was 1234*' - } - - It 'restores original startup type for services that were previously stopped' { - Mock -ModuleName sps.util -CommandName Get-Item -ParameterFilter { $Path -eq 'C:\\setup.exe' -and $Stream -eq 'Zone.Identifier' } -MockWith { $null } - Mock -ModuleName sps.util -CommandName Get-SPSInstalledProductVersion -MockWith { [pscustomobject]@{ ProductMajorPart = 15 } } - - Mock -ModuleName sps.util -CommandName Get-Service -MockWith { - @([pscustomobject]@{ Name = 'SPSearchHostController'; StartType = 'Manual'; Status = 'Stopped' }) - } - Mock -ModuleName sps.util -CommandName Stop-Service - Mock -ModuleName sps.util -CommandName Start-Service - Mock -ModuleName sps.util -CommandName Set-Service - Mock -ModuleName sps.util -CommandName Set-Content - Mock -ModuleName sps.util -CommandName Get-Content -MockWith { - '[{"Name":"SPSearchHostController","StartType":"Manual","Status":"Stopped"}]' - } - - Mock -ModuleName sps.util -CommandName Start-Process -ParameterFilter { $FilePath -eq 'C:\\setup.exe' } -MockWith { - [pscustomobject]@{ ExitCode = 0 } - } - - Start-SPSProductUpdate -SetupFile 'C:\\setup.exe' -ShutdownServices $true - - Assert-MockCalled -ModuleName sps.util -CommandName Get-Service -Times 1 -ParameterFilter { $Name -contains 'OSearch15' } - Assert-MockCalled -ModuleName sps.util -CommandName Set-Service -Times 1 -ParameterFilter { - $Name -eq 'SPSearchHostController' -and $StartupType -eq 'Manual' - } - } -} - -Describe 'Mount-SPSContentDatabase' { - It 'is exported from sps.util module' { - $cmd = Get-Command -Name Mount-SPSContentDatabase -ErrorAction Stop - $cmd | Should -Not -BeNullOrEmpty - $cmd.CommandType | Should -Be 'Function' - $cmd.ModuleName | Should -Not -BeNullOrEmpty - } - - It 'exposes Name, WebAppUrl and DatabaseServer parameters' { - $cmd = Get-Command -Name Mount-SPSContentDatabase -ErrorAction Stop - $cmd.Parameters.Keys | Should -Contain 'Name' - $cmd.Parameters.Keys | Should -Contain 'WebAppUrl' - $cmd.Parameters.Keys | Should -Contain 'DatabaseServer' - } - - It 'skips mounting when the content database is already attached to the farm' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { - [pscustomobject]@{ Name = 'WSS_Content_Test' } - } - Mock -ModuleName sps.util -CommandName Get-SPWebApplication -MockWith { - [pscustomobject]@{ Url = 'https://intranet.contoso.com' } - } - Mock -ModuleName sps.util -CommandName Mount-SPContentDatabase - Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent - - Mount-SPSContentDatabase -Name 'WSS_Content_Test' -WebAppUrl 'https://intranet.contoso.com' - - Assert-MockCalled -ModuleName sps.util -CommandName Mount-SPContentDatabase -Times 0 -Exactly - } - - It 'mounts the database when it is not already attached' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { $null } - Mock -ModuleName sps.util -CommandName Get-SPWebApplication -MockWith { - [pscustomobject]@{ Url = 'https://intranet.contoso.com' } - } - Mock -ModuleName sps.util -CommandName Mount-SPContentDatabase - Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent - - Mount-SPSContentDatabase -Name 'WSS_Content_New' -WebAppUrl 'https://intranet.contoso.com' -DatabaseServer 'SQL01' - - Assert-MockCalled -ModuleName sps.util -CommandName Mount-SPContentDatabase -Times 1 -Exactly -ParameterFilter { - $Name -eq 'WSS_Content_New' -and $WebApplication -eq 'https://intranet.contoso.com' -and $DatabaseServer -eq 'SQL01' - } - } - - It 'throws when the target web application does not exist' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { $null } - Mock -ModuleName sps.util -CommandName Get-SPWebApplication -MockWith { $null } - Mock -ModuleName sps.util -CommandName Mount-SPContentDatabase - Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent - - { Mount-SPSContentDatabase -Name 'WSS_Content_Orphan' -WebAppUrl 'https://missing.contoso.com' } | - Should -Throw '*SPWebApplication*not found*' - - Assert-MockCalled -ModuleName sps.util -CommandName Mount-SPContentDatabase -Times 0 -Exactly - Assert-MockCalled -ModuleName sps.util -CommandName Add-SPSUpdateEvent -Times 1 -Exactly - } -} - -Describe 'Initialize-SPSContentDbJsonFile' { - BeforeEach { - $script:tempJsonPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) ` - -ChildPath ("sps-contentdb-{0}.json" -f [guid]::NewGuid()) - } - - AfterEach { - if ($script:tempJsonPath) { - # Remove the canonical file AND any timestamped snapshots produced alongside it. - $dir = Split-Path -Path $script:tempJsonPath -Parent - $base = [System.IO.Path]::GetFileNameWithoutExtension($script:tempJsonPath) - Get-ChildItem -Path $dir -Filter "$base*.json" -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } - } - - function script:New-FakeSPDb { - param( - [string]$Name, - [long]$Size, - [string]$Server = 'SQL01', - [string]$Url = 'https://intranet.contoso.com' - ) - [pscustomobject]@{ - Name = $Name - Server = $Server - DiskSizeRequired = $Size - WebApplication = [pscustomobject]@{ Url = $Url } - } - } - - It 'writes no file when Get-SPContentDatabase returns $null' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { $null } - - Initialize-SPSContentDbJsonFile -Path $script:tempJsonPath - - Test-Path -Path $script:tempJsonPath | Should -Be $false - - # And no timestamped snapshot either - $dir = Split-Path -Path $script:tempJsonPath -Parent - $base = [System.IO.Path]::GetFileNameWithoutExtension($script:tempJsonPath) - @(Get-ChildItem -Path $dir -Filter "$base*.json" -ErrorAction SilentlyContinue).Count | Should -Be 0 - } - - It 'distributes 3 databases across 3 distinct sequences (regression: old code put them all in sequence 4)' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { - @( - (New-FakeSPDb -Name 'WSS_Content_A' -Size 30MB) - (New-FakeSPDb -Name 'WSS_Content_B' -Size 20MB) - (New-FakeSPDb -Name 'WSS_Content_C' -Size 10MB) - ) - } - - Initialize-SPSContentDbJsonFile -Path $script:tempJsonPath - - $json = Get-Content -Path $script:tempJsonPath -Raw | ConvertFrom-Json - - # Use .Where({$_}) to safely count: ConvertFrom-Json yields $null for "[]", - # and @($null).Count is 1 (not 0) so a naive @(...).Count would mislead. - @($json.SPContentDatabase1).Where({ $_ }).Count | Should -Be 1 - @($json.SPContentDatabase2).Where({ $_ }).Count | Should -Be 1 - @($json.SPContentDatabase3).Where({ $_ }).Count | Should -Be 1 - @($json.SPContentDatabase4).Where({ $_ }).Count | Should -Be 0 - - # LPT: largest first → Seq1 gets the 30MB DB - $json.SPContentDatabase1.Name | Should -Be 'WSS_Content_A' - $json.SPContentDatabase2.Name | Should -Be 'WSS_Content_B' - $json.SPContentDatabase3.Name | Should -Be 'WSS_Content_C' - } - - It 'balances databases by total DiskSizeRequired rather than by count' { - # Worst case for the old "floor(count/4)" splitter: one large DB plus many small ones. - # Old code would put DBs 1..(N/4) into Seq1 (incl. the giant one) and dump the rest into Seq4. - # LPT should land the giant DB alone in one sequence and spread the small ones across the others. - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { - @( - (New-FakeSPDb -Name 'WSS_Content_BIG' -Size 100MB) - (New-FakeSPDb -Name 'WSS_Content_S1' -Size 10MB) - (New-FakeSPDb -Name 'WSS_Content_S2' -Size 10MB) - (New-FakeSPDb -Name 'WSS_Content_S3' -Size 10MB) - (New-FakeSPDb -Name 'WSS_Content_S4' -Size 10MB) - (New-FakeSPDb -Name 'WSS_Content_S5' -Size 10MB) - (New-FakeSPDb -Name 'WSS_Content_S6' -Size 10MB) - ) - } - - Initialize-SPSContentDbJsonFile -Path $script:tempJsonPath - - $json = Get-Content -Path $script:tempJsonPath -Raw | ConvertFrom-Json - - $sequences = @( - $json.SPContentDatabase1 - $json.SPContentDatabase2 - $json.SPContentDatabase3 - $json.SPContentDatabase4 - ) - - # All 7 databases must be present exactly once across the 4 sequences. - $allNames = $sequences | ForEach-Object { @($_).Where({ $_ }).Name } - @($allNames).Count | Should -Be 7 - @($allNames | Sort-Object -Unique).Count | Should -Be 7 - - # Sequence containing the big DB must hold ONLY the big DB - # (otherwise the splitter never beat the makespan of the largest single job). - $bigSequences = @( - foreach ($seq in $sequences) { - $names = @($seq).Where({ $_ }).Name - if ($names -contains 'WSS_Content_BIG') { , $names } - } - ) - $bigSequences.Count | Should -Be 1 - $bigSequences[0].Count | Should -Be 1 - } - - It 'produces the SPContentDatabase1..4 JSON contract consumed by SPSUpdate.ps1' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { - @( - (New-FakeSPDb -Name 'WSS_Content_X' -Size 5MB) - (New-FakeSPDb -Name 'WSS_Content_Y' -Size 5MB) - (New-FakeSPDb -Name 'WSS_Content_Z' -Size 5MB) - (New-FakeSPDb -Name 'WSS_Content_W' -Size 5MB) - ) - } - - Initialize-SPSContentDbJsonFile -Path $script:tempJsonPath - - $json = Get-Content -Path $script:tempJsonPath -Raw | ConvertFrom-Json - $json.PSObject.Properties.Name | Should -Contain 'SPContentDatabase1' - $json.PSObject.Properties.Name | Should -Contain 'SPContentDatabase2' - $json.PSObject.Properties.Name | Should -Contain 'SPContentDatabase3' - $json.PSObject.Properties.Name | Should -Contain 'SPContentDatabase4' - - # Each entry preserves Name / Server / WebAppUrl - $first = $json.SPContentDatabase1 - $first.Name | Should -Not -BeNullOrEmpty - $first.Server | Should -Be 'SQL01' - $first.WebAppUrl | Should -Be 'https://intranet.contoso.com' - } - - It 'writes a timestamped snapshot alongside the canonical file with matching content' { - Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { - @( (New-FakeSPDb -Name 'WSS_Content_A' -Size 10MB) ) - } - - Initialize-SPSContentDbJsonFile -Path $script:tempJsonPath - - # Canonical file is written (existing contract) - Test-Path -Path $script:tempJsonPath | Should -Be $true - - # A snapshot named _.json lives next to it - $dir = Split-Path -Path $script:tempJsonPath -Parent - $base = [System.IO.Path]::GetFileNameWithoutExtension($script:tempJsonPath) - $ext = [System.IO.Path]::GetExtension($script:tempJsonPath) - $snapshotPattern = "^$([regex]::Escape($base))_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$([regex]::Escape($ext))$" - $snapshots = @( - Get-ChildItem -Path $dir -Filter "$base*$ext" -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match $snapshotPattern } - ) - $snapshots.Count | Should -Be 1 - - # Snapshot content is identical to the canonical file - (Get-Content -Path $script:tempJsonPath -Raw) | - Should -Be (Get-Content -Path $snapshots[0].FullName -Raw) - } -} diff --git a/tests/Modules/util.Tests.ps1 b/tests/Modules/util.Tests.ps1 deleted file mode 100644 index ae08347..0000000 --- a/tests/Modules/util.Tests.ps1 +++ /dev/null @@ -1,189 +0,0 @@ -# Resolve repo root - works on both local and CI/CD -$repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent -$modulePath = Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/util.psm1' - -Import-Module -Name $modulePath -Force - -Describe 'util.psm1 Module' { - It 'module loads successfully' { - Get-Module -Name util | Should -Not -BeNullOrEmpty - } - - It 'exports Get-SPSInstalledProductVersion' { - Get-Command -Name Get-SPSInstalledProductVersion -Module util | Should -Not -BeNullOrEmpty - } - - It 'exports Add-SPSUpdateEvent' { - Get-Command -Name Add-SPSUpdateEvent -Module util | Should -Not -BeNullOrEmpty - } - - It 'exports Invoke-SPSCommand' { - Get-Command -Name Invoke-SPSCommand -Module util | Should -Not -BeNullOrEmpty - } - - It 'exports Test-SPSPendingReboot' { - Get-Command -Name Test-SPSPendingReboot -Module util | Should -Not -BeNullOrEmpty - } -} - -Describe 'Get-SPSInstalledProductVersion' { - It 'returns FileVersionInfo when a SharePoint binary path is found' -Skip:(-not $IsWindows) { - Mock -ModuleName util -CommandName Get-Item -MockWith { - [pscustomobject]@{ - FullName = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - Directory = '16\ISAPI' - } - } - - $result = Get-SPSInstalledProductVersion - - $result | Should -BeOfType ([System.Diagnostics.FileVersionInfo]) - } - - It 'throws a descriptive error when no SharePoint binary path is found' -Skip:(-not $IsWindows) { - Mock -ModuleName util -CommandName Get-Item -MockWith { $null } - - { Get-SPSInstalledProductVersion -ErrorAction Stop } | Should -Throw '*SharePoint path*does not exist*' - } -} - -Describe 'Add-SPSUpdateEvent' { - It 'has Message parameter as mandatory' { - $cmd = Get-Command -Name Add-SPSUpdateEvent -Module util - $param = $cmd.Parameters['Message'] - $param.Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -Be $true - } - - It 'has Source parameter as mandatory' { - $cmd = Get-Command -Name Add-SPSUpdateEvent -Module util - $param = $cmd.Parameters['Source'] - $param.Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -Be $true - } - - It 'has EntryType with ValidateSet' { - $cmd = Get-Command -Name Add-SPSUpdateEvent -Module util - $cmd.Parameters['EntryType'].Attributes.Where{ $_.TypeId.Name -eq 'ValidateSetAttribute' } | Should -Not -BeNullOrEmpty - } - - It 'function is callable with parameters' -Skip:(-not $IsWindows) { - Mock -ModuleName util -CommandName Write-EventLog -MockWith {} - { Add-SPSUpdateEvent -Message 'Test' -Source 'TestSource' -ErrorAction Stop } | Should -Not -Throw - } -} - -Describe 'Invoke-SPSCommand' { - It 'has Credential parameter' { - $cmd = Get-Command -Name Invoke-SPSCommand -Module util - $cmd.Parameters.Keys | Should -Contain 'Credential' - } - - It 'has Server parameter' { - $cmd = Get-Command -Name Invoke-SPSCommand -Module util - $cmd.Parameters.Keys | Should -Contain 'Server' - } - - It 'has ScriptBlock parameter' { - $cmd = Get-Command -Name Invoke-SPSCommand -Module util - $cmd.Parameters.Keys | Should -Contain 'ScriptBlock' - } -} - -Describe 'Start-SPSScheduledTask' { - It 'has TaskPath parameter' { - $cmd = Get-Command -Name Start-SPSScheduledTask -Module util - $cmd.Parameters.Keys | Should -Contain 'TaskPath' - } - - It 'normalizes TaskPath and starts task when task exists' { - Mock -ModuleName util -CommandName Get-ScheduledTask -MockWith { - [pscustomobject]@{ TaskName = 'SPSUpdate-Sequence1'; State = 'Ready' } - } - Mock -ModuleName util -CommandName Start-ScheduledTask -MockWith { } - - $result = Start-SPSScheduledTask -Name 'SPSUpdate-Sequence1' -TaskPath 'SharePoint' -Confirm:$false - - $result.Name | Should -Be 'SPSUpdate-Sequence1' - $result.TaskPath | Should -Be '\SharePoint\' - $result.State | Should -Be 'Ready' - - Assert-MockCalled -ModuleName util -CommandName Get-ScheduledTask -Times 2 -Exactly -ParameterFilter { - $TaskName -eq 'SPSUpdate-Sequence1' -and $TaskPath -eq '\SharePoint\' - } - Assert-MockCalled -ModuleName util -CommandName Start-ScheduledTask -Times 1 -Exactly -ParameterFilter { - $TaskName -eq 'SPSUpdate-Sequence1' -and $TaskPath -eq '\SharePoint\' - } - } - - It 'throws a clear message when task does not exist in given TaskPath' { - Mock -ModuleName util -CommandName Get-ScheduledTask -MockWith { $null } - Mock -ModuleName util -CommandName Start-ScheduledTask -MockWith { } - - { Start-SPSScheduledTask -Name 'MissingTask' -TaskPath 'SharePoint' -Confirm:$false } | Should -Throw 'Scheduled Task MissingTask does not exist in SharePoint Task Path' - - Assert-MockCalled -ModuleName util -CommandName Start-ScheduledTask -Times 0 - } -} - -Describe 'Test-SPSPendingReboot' { - It 'returns IsPending false when no reboot markers exist' { - Mock -ModuleName util -CommandName Test-Path -MockWith { $false } - Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } - - $result = Test-SPSPendingReboot - - $result.IsPending | Should -Be $false - $result.Reasons.Count | Should -Be 0 - } - - It 'returns IsPending true when reboot-required registry key exists' { - Mock -ModuleName util -CommandName Test-Path -MockWith { - param($Path) - if ($Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { - return $true - } - return $false - } - Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } - - $result = Test-SPSPendingReboot - - $result.IsPending | Should -Be $true - $result.Reasons | Should -Contain 'WindowsUpdateRebootRequired' - } - - It 'does not flag WindowsUpdateServicesPending when Pending key has no child entries' { - Mock -ModuleName util -CommandName Test-Path -MockWith { - param($Path) - if ($Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending') { - return $true - } - return $false - } - Mock -ModuleName util -CommandName Get-ChildItem -MockWith { @() } - Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } - - $result = Test-SPSPendingReboot - - $result.IsPending | Should -Be $false - $result.Reasons | Should -Not -Contain 'WindowsUpdateServicesPending' - } - - It 'flags WindowsUpdateServicesPending when Pending key has child entries' { - Mock -ModuleName util -CommandName Test-Path -MockWith { - param($Path) - if ($Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Services\Pending') { - return $true - } - return $false - } - Mock -ModuleName util -CommandName Get-ChildItem -MockWith { - @([pscustomobject]@{ Name = 'Service1' }) - } - Mock -ModuleName util -CommandName Get-ItemProperty -MockWith { $null } - - $result = Test-SPSPendingReboot - - $result.IsPending | Should -Be $true - $result.Reasons | Should -Contain 'WindowsUpdateServicesPending' - } -} diff --git a/tests/SPSUpdate.Common.Tests.ps1 b/tests/SPSUpdate.Common.Tests.ps1 new file mode 100644 index 0000000..0c0b52d --- /dev/null +++ b/tests/SPSUpdate.Common.Tests.ps1 @@ -0,0 +1,208 @@ +# Structural tests for the SPSUpdate.Common module and SPSUpdate.ps1 entry script. +# Cross-platform by design (no SharePoint / no Windows-only dependency) so they run +# on pwsh 7 / macOS locally and on windows-latest in CI. + +$repoRoot = Split-Path -Path $PSScriptRoot -Parent +$moduleDir = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common' +$modulePath = Join-Path -Path $moduleDir -ChildPath 'SPSUpdate.Common.psd1' + +$publicFiles = @(Get-ChildItem -Path (Join-Path -Path $moduleDir -ChildPath 'Public') -Filter *.ps1) +$privateFiles = @(Get-ChildItem -Path (Join-Path -Path $moduleDir -ChildPath 'Private') -Filter *.ps1) +$functionFiles = @($publicFiles + $privateFiles) +$psFiles = @( + $functionFiles + Get-Item -Path $modulePath + Get-Item -Path (Join-Path -Path $moduleDir -ChildPath 'SPSUpdate.Common.psm1') + Get-Item -Path (Join-Path -Path $repoRoot -ChildPath 'src/SPSUpdate.ps1') +) + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $moduleDir = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common' + $modulePath = Join-Path -Path $moduleDir -ChildPath 'SPSUpdate.Common.psd1' + Import-Module -Name $modulePath -Force +} + +AfterAll { + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'SPSUpdate.Common module' { + It 'imports without error' { + Get-Module -Name SPSUpdate.Common | Should -Not -BeNullOrEmpty + } + + It 'has a valid manifest' { + { Test-ModuleManifest -Path $modulePath -ErrorAction Stop } | Should -Not -Throw + } + + It 'manifest version is 4.0.0 or higher' { + (Test-ModuleManifest -Path $modulePath).Version | Should -BeGreaterOrEqual ([version]'4.0.0') + } + + It 'exports exactly the expected public functions' { + $expected = @( + 'Add-SPSScheduledTask' + 'Add-SPSUpdateEvent' + 'Copy-SPSSideBySideFilesRemote' + 'Get-SPSInstalledProductVersion' + 'Get-SPSSecret' + 'Get-SPSServersPatchStatus' + 'Initialize-SPSContentDbJsonFile' + 'Mount-SPSContentDatabase' + 'Remove-SPSScheduledTask' + 'Set-SPSSecret' + 'Set-SPSSideBySideToken' + 'Start-SPSConfigExe' + 'Start-SPSConfigExeRemote' + 'Start-SPSProductUpdate' + 'Start-SPSScheduledTask' + 'Test-SPSPendingReboot' + 'Update-SPSContentDatabase' + ) + $actual = (Get-Command -Module SPSUpdate.Common -CommandType Function).Name | Sort-Object + $actual | Should -Be ($expected | Sort-Object) + } + + It 'does not export the private helpers' { + foreach ($name in @('Invoke-SPSCommand', 'Clear-ComObject', 'Get-SPSLocalVersionInfo', 'Get-SPSConfigRoot')) { + Get-Command -Name $name -Module SPSUpdate.Common -CommandType Function -ErrorAction SilentlyContinue | + Should -BeNullOrEmpty + } + } + + It 'exports the Copy-SPSSideBySideFilesAllServers alias' { + $alias = Get-Command -Name 'Copy-SPSSideBySideFilesAllServers' -Module SPSUpdate.Common -CommandType Alias -ErrorAction SilentlyContinue + $alias | Should -Not -BeNullOrEmpty + $alias.ResolvedCommand.Name | Should -Be 'Copy-SPSSideBySideFilesRemote' + } + + It 'manifest FunctionsToExport matches the Public folder exactly' { + $declared = (Import-PowerShellDataFile -Path $modulePath).FunctionsToExport | Sort-Object + $files = (Get-ChildItem -Path (Join-Path -Path $moduleDir -ChildPath 'Public') -Filter *.ps1).BaseName | + Sort-Object + $declared | Should -Be $files + } + + It 'every exported function uses an approved verb' { + $approved = (Get-Verb).Verb + foreach ($command in (Get-Command -Module SPSUpdate.Common -CommandType Function)) { + $approved | Should -Contain $command.Verb + } + } +} + +Describe 'Module file conventions' { + It ' defines exactly one function named after the file' -ForEach $functionFiles { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errs) + $fns = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) + $fns.Count | Should -Be 1 + $fns[0].Name | Should -Be $_.BaseName + } + + It ' parses without errors' -ForEach $functionFiles { + $tokens = $null; $errs = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errs) | Out-Null + $errs | Should -BeNullOrEmpty + } + + It ' is stored as UTF-8 with BOM' -ForEach $psFiles { + $bytes = [System.IO.File]::ReadAllBytes($_.FullName) + $bytes[0] | Should -Be 0xEF + $bytes[1] | Should -Be 0xBB + $bytes[2] | Should -Be 0xBF + } +} + +Describe 'Public function contracts' { + It '<_> supports ShouldProcess (WhatIf/Confirm)' -ForEach @('Remove-SPSScheduledTask', 'Start-SPSScheduledTask', 'Mount-SPSContentDatabase', 'Update-SPSContentDatabase', 'Set-SPSSideBySideToken', 'Start-SPSProductUpdate', 'Set-SPSSecret') { + (Get-Command -Name $_ -Module SPSUpdate.Common).Parameters.Keys | + Should -Contain 'WhatIf' + } + + It '<_> requires a mandatory -Name' -ForEach @('Add-SPSScheduledTask', 'Remove-SPSScheduledTask', 'Start-SPSScheduledTask') { + $param = (Get-Command -Name $_ -Module SPSUpdate.Common).Parameters['Name'] + $param | Should -Not -BeNullOrEmpty + $param.Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -BeTrue + } + + It 'Add-SPSScheduledTask exposes an optional -Description parameter' { + $param = (Get-Command -Name Add-SPSScheduledTask -Module SPSUpdate.Common).Parameters['Description'] + $param | Should -Not -BeNullOrEmpty + $param.Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -BeFalse + } + + It 'Add-SPSScheduledTask creates or updates the task (mode 6)' { + $src = (Get-Command -Name Add-SPSScheduledTask -Module SPSUpdate.Common).Definition + $src | Should -Match 'RegisterTaskDefinition\([^)]*6,' + } + + It 'Add-SPSUpdateEvent requires a mandatory -Message and -Source' { + $cmd = Get-Command -Name Add-SPSUpdateEvent -Module SPSUpdate.Common + foreach ($p in @('Message', 'Source')) { + $cmd.Parameters[$p].Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -BeTrue + } + } + + It 'Add-SPSUpdateEvent restricts -EntryType with a ValidateSet' { + $cmd = Get-Command -Name Add-SPSUpdateEvent -Module SPSUpdate.Common + $validate = $cmd.Parameters['EntryType'].Attributes.Where{ $_.TypeId.Name -eq 'ValidateSetAttribute' } + $validate | Should -Not -BeNullOrEmpty + $validate[0].ValidValues | Should -Contain 'Information' + $validate[0].ValidValues | Should -Contain 'Warning' + $validate[0].ValidValues | Should -Contain 'Error' + } + + It 'Add-SPSUpdateEvent writes to the SPSUpdate event log and self-heals a misrouted source' { + $src = (Get-Command -Name Add-SPSUpdateEvent -Module SPSUpdate.Common).Definition + $src | Should -Match "LogName\s*=\s*'SPSUpdate'" + $src | Should -Match 'DeleteEventSource' + } + + It 'Get-SPSSecret requires a mandatory -CredentialKey' { + $param = (Get-Command -Name Get-SPSSecret -Module SPSUpdate.Common).Parameters['CredentialKey'] + $param.Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -BeTrue + } + + It 'Set-SPSSecret exposes a -Remove switch and -CredentialKey' { + $cmd = Get-Command -Name Set-SPSSecret -Module SPSUpdate.Common + $cmd.Parameters.Keys | Should -Contain 'Remove' + $cmd.Parameters['CredentialKey'].Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -BeTrue + } + + It 'Mount-SPSContentDatabase requires -Name and -WebAppUrl' { + $cmd = Get-Command -Name Mount-SPSContentDatabase -Module SPSUpdate.Common + foreach ($p in @('Name', 'WebAppUrl')) { + $cmd.Parameters[$p].Attributes.Where{ $_.TypeId.Name -eq 'ParameterAttribute' }[0].Mandatory | Should -BeTrue + } + } + + It 'Get-SPSInstalledProductVersion returns null off a SharePoint server' -Skip:($IsWindows -and (Test-Path 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions')) { + # Off a SharePoint server the function surfaces a non-terminating error and returns + # nothing; -ErrorAction SilentlyContinue suppresses it so the assertion (null result) + # is what is tested, deterministically on every OS. + Get-SPSInstalledProductVersion -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } +} + +Describe 'Secret store round-trip (DPAPI on Windows only)' { + It 'Set-SPSSecret then Get-SPSSecret returns the same username' -Skip:(-not $IsWindows) { + $tmp = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupdate-secret-" + [guid]::NewGuid()) + try { + $sec = ConvertTo-SecureString -String 'P@ssw0rd!test' -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential('CONTOSO\svc_test', $sec) + Set-SPSSecret -CredentialKey 'UNIT-TEST' -Credential $cred -ConfigPath $tmp -Confirm:$false + $back = Get-SPSSecret -CredentialKey 'UNIT-TEST' -ConfigPath $tmp + $back.UserName | Should -Be 'CONTOSO\svc_test' + } + finally { + Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Get-SPSSecret returns null when secrets.psd1 is missing' { + $tmp = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupdate-missing-" + [guid]::NewGuid()) + Get-SPSSecret -CredentialKey 'NOPE' -ConfigPath $tmp | Should -BeNullOrEmpty + } +} diff --git a/tests/SPSUpdate.Tests.ps1 b/tests/SPSUpdate.Tests.ps1 deleted file mode 100644 index d855cf5..0000000 --- a/tests/SPSUpdate.Tests.ps1 +++ /dev/null @@ -1,314 +0,0 @@ -# Resolve repo root - works on CI/CD (GitHub Actions) - -Describe 'SPSUpdate.ps1 File Existence' { - It 'SPSUpdate.ps1 exists' -Skip:(-not $IsWindows) { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - Test-Path -Path $scriptPath -PathType Leaf | Should -Be $true - } - - It 'is a PowerShell script file' -Skip:(-not $IsWindows) { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptPath | Should -Match '\.ps1$' - } - - It 'has valid PowerShell syntax' -Skip:(-not $IsWindows) { - $parseErrors = $null - $tokens = $null - - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $null = [System.Management.Automation.Language.Parser]::ParseInput((Get-Content -Path $scriptPath -Raw), [ref]$tokens, [ref]$parseErrors) - $parseErrors | Should -BeNullOrEmpty - } -} - -Describe 'SPSUpdate.ps1 Configuration Validation' { - BeforeAll { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction SilentlyContinue - } - - It 'contains Test-ConfigurationFile function' { - $scriptContent | Should -Match 'function Test-ConfigurationFile' - } - - It 'validates required configuration properties' { - $scriptContent | Should -Match 'ApplicationName|ConfigurationName|Domain|FarmName|StoredCredential' - } - - It 'checks for missing properties' { - $scriptContent | Should -Match 'Get-Member.*NoteProperty' - } - - It 'validates non-empty property values' { - $scriptContent | Should -Match 'IsNullOrWhiteSpace' - } - - It 'parses JSON configuration' { - $scriptContent | Should -Match 'ConvertFrom-Json' - } -} - -Describe 'SPSUpdate.ps1 Task Name Constants' { - BeforeAll { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction SilentlyContinue - } - - It 'defines TaskNameFullScript constant' { - $scriptContent | Should -Match '\$script:TaskNameFullScript\s*=\s*[''"]SPSUpdate-FullScript[''"]' - } - - It 'defines TaskNameSequencePrefix constant' { - $scriptContent | Should -Match '\$script:TaskNameSequencePrefix\s*=\s*[''"]SPSUpdate-Sequence[''"]' - } - - It 'defines TaskPath constant' { - $scriptContent | Should -Match '\$script:TaskPath\s*=\s*[''"]SharePoint[''"]' - } - - It 'uses constants in Uninstall action' { - $scriptContent | Should -Match '(?s)''Uninstall''\s*\{.*Remove-SPSScheduledTask -Name \$script:TaskNameFullScript -TaskPath \$script:TaskPath' - } - - It 'uses constants in Install action' { - $scriptContent | Should -Match '(?s)''Install''\s*\{.*Add-SPSScheduledTask -Name \$script:TaskNameFullScript.*-TaskPath \$script:TaskPath' - } -} - -Describe 'SPSUpdate.ps1 Scheduled Task Existence Check' { - BeforeAll { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction SilentlyContinue - } - - It 'checks if task already exists before creating' { - $scriptContent | Should -Match 'Get-ScheduledTask.*existingTask|existingTask.*Get-ScheduledTask' - } - - It 'warns when task exists' { - $scriptContent | Should -Match 'Write-Warning.*already exists' - } - - It 'removes existing task before recreating' { - $scriptContent | Should -Match '\$null\s*-ne\s*\$existingTask(?s:.*?)Remove-SPSScheduledTask' - } - - It 'uses existence check in Install action' { - $scriptContent | Should -Match '(?s)''Install''\s*\{.*Get-ScheduledTask -TaskName \$script:TaskNameFullScript -TaskPath "\\\$script:TaskPath\\"' - } - - It 'uses existence check in Default action' { - $scriptContent | Should -Match '(?s)Default\s*\{.*Get-ScheduledTask -TaskName \$taskName -TaskPath "\\\$script:TaskPath\\"' - } -} - -Describe 'SPSUpdate.ps1 Default Action Task Management' { - BeforeAll { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction SilentlyContinue - } - - It 'Default action uses task name variables for Sequence tasks' { - $scriptContent | Should -Match '\$taskName\s*=\s*"\$script:TaskNameSequencePrefix\$taskId"' - } - - It 'Default action passes -TaskPath to Start-SPSScheduledTask' { - $scriptContent | Should -Match 'Start-SPSScheduledTask.*-TaskPath\s*\$script:TaskPath' - } - - It 'Default action captures start result and enforces ErrorAction Stop' { - $scriptContent | Should -Match '\$startResult\s*=\s*Start-SPSScheduledTask\s+-Name\s+\$taskName\s+-TaskPath\s+\$script:TaskPath\s+-ErrorAction\s+Stop' - } - - It 'Default action queries scheduled task state with TaskPath' { - $scriptContent | Should -Match 'Get-ScheduledTask\s+-TaskName\s+\$scheduledTask\s+-TaskPath\s+"\\\$script:TaskPath\\"' - } - - It 'Default action treats Queued tasks as still active' { - $scriptContent | Should -Match '\$taskStatus\.State\s+-ne\s+''Running''\s+-and\s+\$taskStatus\.State\s+-ne\s+''Queued''' - } - - It 'Default action passes -TaskPath to Add-SPSScheduledTask' { - $scriptContent | Should -Match '(?s)Default\s*\{.*Add-SPSScheduledTask -Name \$taskName.*-TaskPath \$script:TaskPath' -Because 'Default action should use TaskPath constant when adding sequence tasks' - } - - It 'Default action logs task errors to event log' { - $scriptContent | Should -Match '(?s)Default\s*\{.*Add-SPSUpdateEvent -Message \$catchMessage -Source ''Add-SPSScheduledTask'' -EntryType ''Error''.*Add-SPSUpdateEvent -Message \$catchMessage -Source ''Start-SPSScheduledTask'' -EntryType ''Error''' -Because 'Default action should log scheduled task failures to event log' - } - - It 'uses consistent task name format in task list' { - $scriptContent | Should -Match '(?s)\$scheduledTasks\s*=\s*@\(\s*"\$script:TaskNameSequencePrefix`1"' -Because 'Task list should use script constant for task names' - } -} - -Describe 'SPSUpdate.ps1 Error Logging' { - BeforeAll { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction SilentlyContinue - } - - It 'uses catchMessage variables before writing errors' { - $scriptContent | Should -Match '\$catchMessage\s*=\s*@"' -Because 'Current error handling pattern builds a reusable catchMessage string' - } - - It 'logs initialization failures to the event log' { - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Get-SPSInstalledProductVersion'' -EntryType ''Error''' - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Initialize-SPSContentDbJsonFile'' -EntryType ''Error''' - } - - It 'logs install and uninstall task failures to the event log' { - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Remove-SPSScheduledTask'' -EntryType ''Error''' - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Add-SPSScheduledTask'' -EntryType ''Error''' - } - - It 'logs product update and credential failures to the event log' { - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Start-SPSProductUpdate'' -EntryType ''Error''' - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Get-StoredCredential'' -EntryType ''Error''' - } - - It 'logs configuration wizard and side-by-side failures to the event log' { - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Start-SPSConfigExe'' -EntryType ''Error''' - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Start-SPSConfigExeRemote'' -EntryType ''Error''' - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Set-SPSSideBySideToken'' -EntryType ''Error''' - $scriptContent | Should -Match 'Add-SPSUpdateEvent -Message \$catchMessage -Source ''Copy-SPSSideBySideFiles'' -EntryType ''Error''' - } -} - -Describe 'SPSUpdate.ps1 Content Validation' { - BeforeAll { - $scriptPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/SPSUpdate.ps1' - $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction SilentlyContinue - } - - It 'contains param block' { - $scriptContent | Should -Match 'param\s*\(' - } - - It 'defines the current script version' { - $scriptContent | Should -Match '\$SPSUpdateVersion\s*=\s*''3\.2\.1''' - } - - It 'imports util module' { - $scriptContent | Should -Match 'Import-Module.*util\.psm1|Import-Module.*util' - } - - It 'imports credentialmanager module' { - $scriptContent | Should -Match 'CredentialManager\.psd1|Import-Module.*credentialmanager' - } - - It 'uses Start-Transcript for logging' { - $scriptContent | Should -Match 'Start-Transcript' - } - - It 'guards Stop-Transcript behind TranscriptStarted state' { - $scriptContent | Should -Match '\$script:TranscriptStarted\s*=\s*\$false' - $scriptContent | Should -Match 'if\s*\(\$script:TranscriptStarted\)\s*\{\s*Stop-Transcript' - } - - It 'contains try-catch error handling' { - $scriptContent | Should -Match 'try\s*\{|catch\s*\{' - } - - It 'checks for administrator privileges' { - $scriptContent | Should -Match 'Administrator' - } - - It 'does not call Test-SPSPendingReboot in ProductUpdate flow' { - $scriptContent | Should -Not -Match 'Test-SPSPendingReboot' - } - - It 'does not contain legacy ProductUpdate MOF cleanup code' { - $scriptContent | Should -Not -Match 'Cleaning up DSC MOF File|\.mof' - } - - It 'declares InitContentDB in the Action validateSet' { - $scriptContent | Should -Match "validateSet\([^)]*'InitContentDB'" - } - - It 'implements the InitContentDB switch case' { - $scriptContent | Should -Match "(?s)'InitContentDB'\s*\{.*Initialize-SPSContentDbJsonFile\s+-Path\s+\`$spsUpdateDBsPath" - } - - It 'invokes Mount-SPSContentDatabase when MountContentDatabase is enabled' { - $scriptContent | Should -Match '(?s)if\s*\(\s*\$jsonEnvCfg\.MountContentDatabase\s*\).*Mount-SPSContentDatabase' - } - - It 'loads ContentDatabase json file when MountContentDatabase is enabled' { - $scriptContent | Should -Match '\$jsonEnvCfg\.UpgradeContentDatabase\s*-or\s*\$jsonEnvCfg\.MountContentDatabase' - } - - It 'spawns parallel scheduled tasks when MountContentDatabase or UpgradeContentDatabase is enabled' { - # Loader (top of script), Default master branch and Install branch must all use the OR condition - ([regex]::Matches($scriptContent, '\$jsonEnvCfg\.UpgradeContentDatabase\s*-or\s*\$jsonEnvCfg\.MountContentDatabase')).Count | Should -BeGreaterOrEqual 3 - } - - It 'gates Update-SPSContentDatabase inside Sequence loop with UpgradeContentDatabase flag' { - $scriptContent | Should -Match '(?s)if\s*\(\s*\$jsonEnvCfg\.UpgradeContentDatabase\s*\)\s*\{\s*Update-SPSContentDatabase\s+-Name\s+\$db\.Name' - } - - It 'runs Mount-SPSContentDatabase inside the per-DB Sequence loop (parallel via 4 sequences)' { - $scriptContent | Should -Match '(?s)foreach\s*\(\s*\$db\s+in\s+\$dbs\s*\)\s*\{[^}]*if\s*\(\s*\$jsonEnvCfg\.MountContentDatabase\s*\)[^}]*Mount-SPSContentDatabase\s+-Name\s+\$db\.Name\s+-WebAppUrl\s+\$db\.WebAppUrl\s+-DatabaseServer\s+\$db\.Server' - } -} - -Describe 'SPSUpdate.ps1 Dependencies' { - It 'util.psm1 module file exists' -Skip:(-not $IsWindows) { - $utilPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Modules/util.psm1' - Test-Path -Path $utilPath -PathType Leaf | Should -Be $true - } - - It 'sps.util.psm1 module file exists' -Skip:(-not $IsWindows) { - $spsUtilPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Modules/sps.util.psm1' - Test-Path -Path $spsUtilPath -PathType Leaf | Should -Be $true - } - - It 'credentialmanager folder exists' -Skip:(-not $IsWindows) { - $credPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Modules/credentialmanager' - Test-Path -Path $credPath -PathType Container | Should -Be $true - } - - It 'Config folder exists' -Skip:(-not $IsWindows) { - $configPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Config' - Test-Path -Path $configPath -PathType Container | Should -Be $true - } - - It 'sample config files exist' -Skip:(-not $IsWindows) { - $configPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Config' - @(Get-ChildItem -Path $configPath -Filter '*.json' -ErrorAction SilentlyContinue).Count | Should -BeGreaterThan 0 - } -} - -Describe 'Configuration Files' { - It 'all config files are valid JSON' -Skip:(-not $IsWindows) { - $configPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Config' - $configFiles = Get-ChildItem -Path $configPath -Filter '*.json' -ErrorAction SilentlyContinue - - foreach ($file in $configFiles) { - $content = Get-Content -Path $file.FullName -Raw - { $content | ConvertFrom-Json -ErrorAction Stop } | Should -Not -Throw -Because "Config file $($file.Name) must be valid JSON" - } - } - - It 'config files contain ConfigurationName property' -Skip:(-not $IsWindows) { - $configPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Config' - $configFiles = Get-ChildItem -Path $configPath -Filter '*.json' -ErrorAction SilentlyContinue - - foreach ($file in $configFiles) { - $config = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json - $config.ConfigurationName | Should -Not -BeNullOrEmpty -Because "Config file $($file.Name) must have ConfigurationName property" - } - } - - It 'config files contain required properties' -Skip:(-not $IsWindows) { - $requiredProperties = @('ApplicationName', 'ConfigurationName', 'Domain', 'FarmName', 'StoredCredential') - $configPath = Join-Path -Path (Get-Location).Path -ChildPath 'scripts/Config' - $configFiles = Get-ChildItem -Path $configPath -Filter '*.json' -ErrorAction SilentlyContinue - - foreach ($file in $configFiles) { - $config = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json - foreach ($prop in $requiredProperties) { - $config.$prop | Should -Not -BeNullOrEmpty -Because "Config file $($file.Name) must have $prop property" - } - } - } -} diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 684b028..12756da 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -1,74 +1,131 @@ # Configuration -To customize the script for your environment, you need to prepare a JSON configuration file. Below is a sample structure for the file: - -```json -{ - "$schema": "http://json-schema.org/schema#", - "contentVersion": "1.0.0.0", - "ConfigurationName": "PROD", - "ApplicationName": "contoso", - "FarmName": "CONTENT", - "Domain": "contoso.com", - "StoredCredential": "PROD-ADM", - "Binaries": { - "ProductUpdate": true, - "SetupFullPath": "D:\\SoftwarePackages\\SPS\\cumulativeupdates", - "SetupFileName": ["uber-subscription-kb5002651-fullfile-x64-glb.exe"], - "ShutdownServices": false - }, - "UpgradeContentDatabase": true, - "MountContentDatabase": false, - "SideBySideToken": { - "Enable": true, - "BuildVersion": "16.0.17928.20238" - } +SPSUpdate is driven by two PowerShell data files in the `Config\` folder: + +- a per-farm **environment config** (`*.psd1`) — hand-edited, gitignored; +- a **secret store** (`secrets.psd1`) — DPAPI-encrypted, gitignored, never committed. + +Only the `*.example.psd1` templates are tracked in source control. + +## Environment configuration (`*.psd1`) + +Copy `Config\CONTOSO-PROD.example.psd1` to a real file (one per farm, for example +`CONTOSO-PROD-CONTENT.psd1`) and edit the values: + +```powershell +@{ + ConfigurationName = 'PROD' + ApplicationName = 'contoso' + FarmName = 'CONTENT' + Domain = 'contoso.com' + CredentialKey = 'PROD-ADM' + + Binaries = @{ + ProductUpdate = $true + SetupFullPath = 'D:\SoftwarePackages\SPS\cumulativeupdates' + SetupFileName = @('uber-subscription-kb5002651-fullfile-x64-glb.exe') + ShutdownServices = $true + } + + MountContentDatabase = $false + UpgradeContentDatabase = $true + + SideBySideToken = @{ + Enable = $false + BuildVersion = '' + } } ``` -## Configuration, Application and FarmName +### Required keys -`ConfigurationName` is used to populate the content of `Environment` PowerShell Variable. -`ApplicationName` is used to populate the content of `Application` PowerShell Variable. -`FarmName` is used to populate the content of `FarmName` PowerShell Variable. +| Key | Description | +|---|---| +| `ConfigurationName` | Environment identifier (e.g. `PROD`, `PPRD`, `DEV`). Used in log/result file names. | +| `ApplicationName` | Application/customer code. Used in log/result file names. | +| `FarmName` | Logical farm name. Used in logs and in the ContentDB inventory file name. | +| `Domain` | DNS suffix appended to each farm server short name for CredSSP remoting. | +| `CredentialKey` | Name of the entry in `secrets.psd1` that holds the `InstallAccount`. | -## Credential Manager +### Optional keys and their defaults -`StoredCredential` is refered to the target of your credential that you used during the installation processus. +If an optional key is omitted, SPSUpdate applies a safe default: -## Binaries settings +| Key | Possible values | Default if omitted | +|---|---|---| +| `Binaries.ProductUpdate` | `$true` / `$false` | `$true` | +| `Binaries.ShutdownServices` | `$true` / `$false` | `$true` | +| `UpgradeContentDatabase` | `$true` / `$false` | `$true` | +| `MountContentDatabase` | `$true` / `$false` | `$false` | +| `SideBySideToken.Enable` | `$true` / `$false` | `$false` | +| `SideBySideToken.BuildVersion` | `''` or a build, e.g. `'16.0.17928.20238'` | `''` (skip) | -Use `ProductUpdate`, `SetupFullPath`, `SetupFileName` and `ShutdownServices` parameters to configure your binaries settings in your environment +`Binaries.SetupFullPath` and `Binaries.SetupFileName` are required as soon as +`ProductUpdate` is `$true`. -## UpgradeContentDatabase +> The previous JSON `StoredCredential` key has been renamed to `CredentialKey`, and the +> configuration format moved from JSON to psd1. Runtime/output files (the ContentDB +> inventory and logs) stay JSON by design. -The `UpgradeContentDatabase` parameter can be used to run upgrade-SPContentDatabase in parallel. +## Secret store (`secrets.psd1`) -The authorized values are : `true`, and `false`. +The `InstallAccount` credential is stored as a DPAPI-encrypted SecureString. Copy +`Config\secrets.example.psd1` to `Config\secrets.psd1`: -## MountContentDatabase +```powershell +@{ + 'PROD-ADM' = @{ + Username = 'CONTOSO\svc_spsupdate' + PasswordSecure = 'PASTE-ConvertFrom-SecureString-OUTPUT-HERE' + } +} +``` -The `MountContentDatabase` parameter can be used to attach content databases to the -target farm before the upgrade step. It is typically used in farm migration scenarios -(for example SharePoint Server 2019 → Subscription Edition) where content databases -restored from the source farm on the target SQL Server need to be mounted on the new -farm. +Each key (e.g. `PROD-ADM`) matches the `CredentialKey` of an environment config. The +recommended way to populate it is to run `-Action Install -InstallAccount (Get-Credential)` +**as the service account**, which writes the entry for you. To generate a value manually, +on the target server signed in as that account: -When set to `true`, the master server iterates through the ContentDatabase inventory -JSON file (`---ContentDBs.json`, normally -generated on the source farm with the `InitContentDB` action) and runs -`Mount-SPContentDatabase` for every database that is not already attached. Mounts are -performed sequentially on the master server to avoid concurrent writes to the -configuration database. +```powershell +Read-Host -AsSecureString -Prompt 'Password' | ConvertFrom-SecureString +``` -The authorized values are : `true`, and `false`. +> [!IMPORTANT] +> The encrypted value can only be decrypted by the **same user account on the same +> machine**. `secrets.psd1` is gitignored and must never be committed. -## SideBySideToken +## Identity variables -Use `Enable` to enable sidebysidetoken feature. -Use `BuildVersion` to set build version used in sidebysitetoken feature. +`ConfigurationName`, `ApplicationName` and `FarmName` populate the `Environment`, +`Application` and `FarmName` PowerShell variables used throughout the run and in the +generated file names. + +## Binaries settings + +Use `ProductUpdate`, `SetupFullPath`, `SetupFileName` and `ShutdownServices` to configure +the binary installation step. `SetupFileName` is an array, so you can list a single uber +package or the STS + WSSLOC (language) pair, installed in order. + +## UpgradeContentDatabase + +`UpgradeContentDatabase` runs `Upgrade-SPContentDatabase` in parallel (4 sequences) for +every content database that needs an upgrade. + +## MountContentDatabase + +`MountContentDatabase` attaches content databases to the target farm before the upgrade +step. It is typically used in farm migration scenarios (for example SharePoint Server +2019 → Subscription Edition) where content databases restored on the target SQL Server +need to be mounted on the new farm. The databases are read from the ContentDatabase +inventory JSON file (`---ContentDBs.json`, +generated with the `InitContentDB` action). + +## SideBySideToken -Zero downtime patching is a method of patching and upgrade developed in SharePoint in Microsoft 365. For more details see [SharePoint Server zero downtime patching steps](https://learn.microsoft.com/en-us/sharepoint/upgrade-and-update/sharepoint-server-2016-zero-downtime-patching-steps) +Use `Enable` to turn on the side-by-side feature and `BuildVersion` to set the build used +by the side-by-side token. Zero downtime patching is a method of patching and upgrade +developed in SharePoint in Microsoft 365. For more details see +[SharePoint Server zero downtime patching steps](https://learn.microsoft.com/en-us/sharepoint/upgrade-and-update/sharepoint-server-2016-zero-downtime-patching-steps). ## Next Step diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 341e857..33b9aac 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -2,52 +2,56 @@ ## Prerequisites -- PowerShell 5.0 or later -- CredSSP configured -- Administrative privileges on the SharePoint Server -- StoredCredential configured (if using `Install`) +- PowerShell 5.1 or later (no DSC module required) +- CredSSP configured (used for remoting to the other farm servers) +- Administrative privileges on each SharePoint Server +- A service account (`InstallAccount`) whose credential is stored in `Config\secrets.psd1` - SharePoint update binaries copied to a local or accessible path (if using `ProductUpdate`) ## Configure CredSSP ### Option 1: Manually configure CredSSP -You can manually configure CredSSP through the use of some PowerShell cmdlet's (and potentially group policy to configure the allowed delegate computers). Some basic instructions can be found at [https://technet.microsoft.com/en-us/magazine/ff700227.aspx](https://technet.microsoft.com/en-us/magazine/ff700227.aspx). +You can manually configure CredSSP through a few PowerShell cmdlets (and potentially group policy to configure the allowed delegate computers). Basic guidance is available in the Microsoft documentation. ### Option 2: Configure CredSSP with PowerShell commands -If you prefer automation instead of manual setup, you can configure CredSSP directly with PowerShell commands on the server and client. Example: +If you prefer automation instead of manual setup, configure CredSSP directly with PowerShell on the server and client. Example: ```powershell Enable-WSManCredSSP -Role Server -Force Enable-WSManCredSSP -Role Client -DelegateComputer '*.contoso.com' -Force ``` -In the above example, the delegate computer value can be a wildcard name such as `*.contoso.com`, or you can specify one or more explicit SharePoint servers. +In the above example the delegate computer value can be a wildcard such as `*.contoso.com`, or one or more explicit SharePoint servers. ## Installation -1. [Download the latest release](https://github.com/luigilink/SPSUpdate/releases/latest) and unzip to a directory on each SharePoint Server. -2. Prepare your JSON configuration file with the required Cumulative Updates and farm details. -3. Add the script in task scheduler by running the following command: +1. [Download the latest release](https://github.com/luigilink/SPSUpdate/releases/latest) and unzip to a directory on each SharePoint Server. The archive extracts straight to `SPSUpdate.ps1`, `Config\` and `Modules\` (no `src\` wrapper). +2. Copy `Config\CONTOSO-PROD.example.psd1` to a real config (for example `CONTOSO-PROD-CONTENT.psd1`) and edit the values for your farm. See [Configuration](./Configuration). +3. Register the scheduled tasks by running the following command **as the service account** that will run them: ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD-CONTENT.json' -Action Install -InstallAccount (Get-Credential) +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action Install -InstallAccount (Get-Credential) ``` -1. Install Cumulative Update binaries on each server by running the following command (or install manually): +4. Install the cumulative update binaries on each server (or install them manually): ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD-CONTENT.json' -Action ProductUpdate +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action ProductUpdate ``` -`ProductUpdate` runs the SharePoint installer directly and does not require any DSC module. +`ProductUpdate` runs the SharePoint installer directly, locally, and does not require the `InstallAccount` parameter. > [!IMPORTANT] -> Configure the StoredCredential parameter in JSON before running the script in installation mode. -> Run the Install mode with the same account than you used the in InstallAccount parameter +> Run `-Action Install` **as the same account** you pass to `-InstallAccount`. The credential is stored as a DPAPI-encrypted SecureString in `Config\secrets.psd1`, which can only be decrypted by that account on that machine. -`ProductUpdate` runs locally and does not require the `InstallAccount` parameter. +## The credential store (DPAPI) + +SPSUpdate no longer depends on the Windows Credential Manager module. The `InstallAccount` +credential is encrypted with `ConvertFrom-SecureString` (DPAPI) and stored under the +`CredentialKey` entry of `Config\secrets.psd1`. `-Action Install` writes it for you; +`-Action Uninstall` removes it. See [Configuration](./Configuration) for the file format. ## Next Step diff --git a/wiki/Home.md b/wiki/Home.md index 1c5bf59..b3717fa 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -1,22 +1,44 @@ -# SPSUpdate - SharePoint Cumulative Update Tool +# SPSUpdate Wiki -SPSUpdate is a PowerShell script tool designed to install cumulative updates and run SPConfig.exe in your SharePoint environment. +**SPSUpdate** is a PowerShell tool that installs SharePoint Server cumulative updates and runs the post-setup Configuration Wizard (PSConfig) across a farm. It is compatible with all supported on-premises versions of SharePoint Server (2016 to Subscription Edition) and requires only PowerShell 5.1 or later — there is no DSC dependency. -## Key Features +SPSUpdate installs the update binaries, mounts and/or upgrades content databases in parallel via scheduled tasks, runs PSConfig on the local and remote servers over **CredSSP remoting**, and configures the side-by-side patching token for zero-downtime upgrades. -- Loads environment settings from the JSON config (ApplicationName, ConfigurationName, Domain, FarmName, UpgradeContentDatabase, SideBySideToken, StoredCredential, etc.). -- Logging & diagnostics: Creates Logs folder and per-run log file (sequence-aware naming); starts a transcript (Start-Transcript) for full output capture. -- Safety checks: Verifies script is running with Administrator rights before proceeding. -- SharePoint integration: Detects installed SharePoint version (Get-SPSInstalledProductVersion) and loads the appropriate SharePoint snap-in or module. -- Credential management: Integrates with Credential Manager (via CredentialManager module) to store/retrieve the credential referenced by the JSON config. -- Scheduled-task driven parallelism: Uses helper functions (Add-SPSScheduledTask, Start-SPSScheduledTask, Remove-SPSScheduledTask) to create and run scheduled task -- Full-run mode creates 4 sequence tasks (SPSUpdate-Sequence1..4) and starts them in parallel (with random short sleeps to avoid OWSTimer conflicts). -- SPConfig execution & SideBySide handling: Runs Start-SPSConfigExe locally and Start-SPSConfigExeRemote for other servers; configures SideBySide token (Set-SPSSideBySideToken) and copies side-by-side files remotely if enabled. -- Robust error handling: Extensive try/catch blocks with clear error messages to surface failures per operation (module import, scheduled-task registration/start, credential operations, DB upgrades). -- Helper module usage: Relies on util.psm1 for utility functions (task registration, scheduled task start, Get-SPSInstalledProductVersion, remote invocation helpers). +## Key features -For details on usage, configuration, and parameters, explore the links below: +- Install cumulative update binaries locally (`ProductUpdate`) +- Parallel content-database mount/upgrade across 4 sequences (LPT-balanced by size) +- Post-setup Configuration Wizard (PSConfig) on local and remote servers via CredSSP +- Side-by-side token configuration for zero-downtime patching +- Configuration as a PowerShell data file (`*.psd1`) +- Service credential stored as a DPAPI-encrypted `secrets.psd1` — no third-party module +- Windows Event Log instrumentation (dedicated `SPSUpdate` log) +- Self-contained `SPSUpdate.Common` PowerShell module (manifest-driven version) -- [Getting Started](./Getting-Started) -- [Configuration](./Configuration) -- [Usage](./Usage) +## Architecture overview + +``` + SPSUpdate.ps1 (entry point, scheduled tasks) + | imports + v + SPSUpdate.Common (PowerShell module: Public/ + Private/) + | CredSSP remoting (Invoke-Command / New-PSSession) + v + Each SharePoint farm server --> binaries install / PSConfig / DB upgrade +``` + +The credential used for remoting and scheduled tasks is read from `Config\secrets.psd1` (DPAPI), and every run writes lifecycle entries to the `SPSUpdate` Windows Event Log. + +## Pages + +- [Getting Started](Getting-Started) — prerequisites, CredSSP, installation, first run +- [Configuration](Configuration) — `*.psd1` environment config and `secrets.psd1` explained +- [Usage](Usage) — actions, sequences, scheduling, output and the event log +- [Release Process](Release-Process) — for maintainers: how to ship a new version + +## Project links + +- [Source repository](https://github.com/luigilink/SPSUpdate) +- [Latest release](https://github.com/luigilink/SPSUpdate/releases/latest) +- [Issues](https://github.com/luigilink/SPSUpdate/issues) +- [Changelog](https://github.com/luigilink/SPSUpdate/blob/main/CHANGELOG.md) diff --git a/wiki/Release-Process.md b/wiki/Release-Process.md new file mode 100644 index 0000000..f232ad3 --- /dev/null +++ b/wiki/Release-Process.md @@ -0,0 +1,107 @@ +# Release Process + +This page documents how to ship a new version of SPSUpdate. The process is centered on a single source of truth — the `ModuleVersion` field of `SPSUpdate.Common.psd1` — and a `v*` git tag that triggers the GitHub release workflow. + +## Versioning policy + +SPSUpdate follows [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). + +| Bump | When | +|---|---| +| MAJOR (X.0.0) | Breaking change in the `.psd1` config/secrets format, the package layout, or a public module function signature. | +| MINOR (X.Y.0) | New backward-compatible feature (new action, new public function, new optional setting). | +| PATCH (X.Y.Z) | Bug fix or documentation-only change. | + +## Release checklist + +### 1. Bump the version + +Edit **one** value in `src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1`: + +```powershell +ModuleVersion = '4.0.0' # was '3.2.1' +``` + +This single change propagates automatically to: + +- The script banner (`$SPSUpdateVersion` is read from `(Get-Module SPSUpdate.Common).Version`) +- The `SPSUpdate` Event Log header (`SPSUpdate Version: 4.0.0`) +- The `Get-Module SPSUpdate.Common` version surfaced to users + +### 2. Promote `[Unreleased]` in `CHANGELOG.md` + +Move the `[Unreleased]` block to a dated section for the version being released and add a fresh empty `[Unreleased]` heading on top: + +```markdown +## [Unreleased] + +## [4.0.0] - 2026-MM-DD + +### Added +... +``` + +### 3. Replace `RELEASE-NOTES.md` + +`RELEASE-NOTES.md` is used **verbatim** as the body of the GitHub Release. It must contain **only** the section of the version being released (no `[Unreleased]` header, no stacked history). + +### 4. Validate locally + +```powershell +Import-Module .\src\Modules\SPSUpdate.Common\SPSUpdate.Common.psd1 -Force +(Get-Module SPSUpdate.Common).Version # should match the bumped version +Invoke-Pester -Path .\tests +Invoke-ScriptAnalyzer -Path .\src -Recurse -Settings .\PSScriptAnalyzerSettings.psd1 +``` + +### 5. Commit on a release branch + +```bash +git checkout -b release/4.0.0 +git add -A +git commit -m "release: v4.0.0" +git push -u origin release/4.0.0 +``` + +Test the branch ZIP on a real farm first, then open a Pull Request, review, and merge to `main`. + +### 6. Tag from `main` + +```bash +git checkout main +git pull +git tag v4.0.0 +git push origin v4.0.0 +``` + +The `.github/workflows/release.yml` workflow runs automatically. It: + +1. Packages the **contents** of `src/` into `SPSUpdate-v4.0.0.zip` (the archive extracts straight to `SPSUpdate.ps1`, `Config\` and `Modules\`, with no `src/` wrapper). +2. Publishes a GitHub Release using `RELEASE-NOTES.md` as the body. +3. Attaches the ZIP and `LICENSE` to the release. + +### 7. Verify + +- **Releases**: — the new release is listed with the expected body and ZIP. +- **Actions**: — `release.yml` and `pester.yml` ran green. +- **Wiki**: — `wiki.yml` synced any `wiki/` changes pushed in the same release. + +## Undoing a release + +If you tagged too early: + +```bash +git tag -d v4.0.0 +git push origin --delete v4.0.0 +``` + +Then delete the auto-created Release on GitHub, fix what needs fixing, commit, and re-tag from the new HEAD. + +> ⚠️ **Don't move a published tag** that has been live for more than a few minutes. Prefer publishing a `vX.Y.(Z+1)` patch release instead of rewriting `vX.Y.Z`. + +## See also + +- [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +- [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html) +- [Configuration reference](Configuration) +- [Usage](Usage) diff --git a/wiki/Usage.md b/wiki/Usage.md index bef0776..6cdfbc2 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -2,75 +2,91 @@ ## Overview -`SPSUpdate.ps1` is a PowerShell script tool designed to install cumulative updates and run SPConfig.exe in your SharePoint environment. +`SPSUpdate.ps1` installs SharePoint cumulative updates, mounts/upgrades content databases +in parallel and runs the post-setup Configuration Wizard (PSConfig) across the farm. Shared +logic lives in the `SPSUpdate.Common` module; the script just orchestrates it. ## Prerequisites - PowerShell 5.1 or later. -- Necessary permissions to access the SharePoint Farm. -- Ensure the script is placed in a directory accessible by the user. -- Copy the script and cumulative update files on each SharePoint Server. +- Administrator rights and access to the SharePoint farm. +- The package extracted on each SharePoint server (`SPSUpdate.ps1`, `Config\`, `Modules\`). +- Cumulative update files copied on each server (for `ProductUpdate`). ## Parameters -| Parameter | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ConfigFile` | Specifies the path to the configuration file. | -| `Sequence` | (Optional) Specifies the Sequence for parallel upgrade Content DB. | -| `Action` | (Optional) Accepts `Install`, `Uninstall`, `Default`, `ProductUpdate` or `InitContentDB`. `Install`/`Uninstall` manage the scheduled tasks (requires `InstallAccount` for `Install`). `ProductUpdate` installs the binaries locally. `InitContentDB` (re)generates the ContentDatabase inventory JSON file. | -| `InstallAccount` | (Optional) Need parameter InstallAccount when you use the Action parameter equal to Install. | +| Parameter | Description | +| --- | --- | +| `ConfigFile` | Path to the environment configuration file (`*.psd1`). **Required.** | +| `Action` | (Optional) `Install`, `Uninstall`, `Default`, `ProductUpdate` or `InitContentDB`. `Install`/`Uninstall` manage the scheduled tasks and the stored secret (`Install` requires `InstallAccount`). `ProductUpdate` installs the binaries locally. `InitContentDB` (re)generates the ContentDatabase inventory JSON. Defaults to `Default`. | +| `Sequence` | (Optional, 1–4) Internal: selects which content-database group a parallel scheduled task processes. | +| `InstallAccount` | (Optional) Required with `-Action Install`. The service account stored in `secrets.psd1`. | ## Examples -### Example 1: Default Usage Example +### Example 1: Default usage ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' ``` -### Example 2: Sequence Example +### Example 2: Sequence (internal, run by the scheduled tasks) ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Sequence 1 +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Sequence 1 ``` -### Example 3: Installation Usage Example +### Example 3: Installation ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action Install -InstallAccount (Get-Credential) +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action Install -InstallAccount (Get-Credential) ``` -### Example 4: Uninstallation Usage Example +### Example 4: Uninstallation ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action Uninstall +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action Uninstall ``` -### Example 5: ProductUpdate Usage Example +### Example 5: ProductUpdate ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action ProductUpdate +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action ProductUpdate ``` -### Example 6: InitContentDB Usage Example (source farm) +### Example 6: InitContentDB (source farm) ```powershell -.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action InitContentDB +.\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action InitContentDB ``` -This action (re)generates the ContentDatabase inventory JSON file -(`---ContentDBs.json`) for the local farm. -It is typically used on the source farm before a farm upgrade (for example -SharePoint Server 2019 → Subscription Edition) so that the inventory can be copied to -the target farm and consumed by the `MountContentDatabase` flow. +This (re)generates the ContentDatabase inventory JSON file +(`---ContentDBs.json`) for the local farm. It +is typically used on the source farm before a farm upgrade (for example SharePoint Server +2019 → Subscription Edition) so the inventory can be copied to the target farm and consumed +by the `MountContentDatabase` flow. + +## How a full run works (`Default`) + +1. Reads the `InstallAccount` credential from `secrets.psd1` (DPAPI). +2. If `UpgradeContentDatabase` or `MountContentDatabase` is on, registers and starts four + `SPSUpdate-Sequence1..4` scheduled tasks that process the content-database groups in + parallel (the groups are balanced by size using a Longest-Processing-Time heuristic), + then waits for all four to finish. +3. Runs PSConfig on the local (master) server when a patch action is required, then on each + remote server over CredSSP. +4. Configures the side-by-side token and copies side-by-side files (when enabled). ## Logging -The script logs the status of each task, including success or failure, and saves it to the specified log file or the default location. +Each run starts a transcript under `Logs\` (the file name encodes the application, +environment and — when relevant — the sequence or action). Lifecycle and error events are +also written to the dedicated **`SPSUpdate` Windows Event Log** via `Add-SPSUpdateEvent`. -## Error Handling +## Error handling -- Ensure the account running the script has administrator rights and access to the SharePoint Farm. +- Ensure the account running the script has administrator rights and access to the farm. +- A missing secret raises a clear error pointing you to `-Action Install`. ## Notes @@ -78,4 +94,5 @@ The script logs the status of each task, including success or failure, and saves ## Support -For issues or questions, please contact the script maintainer or refer to the project documentation. +For issues or questions, open an [issue](https://github.com/luigilink/SPSUpdate/issues) or +refer to the project documentation. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 0000000..e4ebeb5 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,16 @@ +**Navigation** + +- [🏠 Home](Home) +- [🚀 Getting Started](Getting-Started) +- [⚙️ Configuration](Configuration) +- [📖 Usage](Usage) +- [📦 Release Process](Release-Process) + +--- + +**Project** + +- [Repository](https://github.com/luigilink/SPSUpdate) +- [Releases](https://github.com/luigilink/SPSUpdate/releases) +- [Issues](https://github.com/luigilink/SPSUpdate/issues) +- [Changelog](https://github.com/luigilink/SPSUpdate/blob/main/CHANGELOG.md)