Public/Copy-AzureLocalItsmSample.ps1
|
function Copy-AzureLocalItsmSample { <# .SYNOPSIS Copy the bundled ITSM connector sample (matrix config + Mustache ticket-body template) out of the module install location into a target folder of the user's choice. .DESCRIPTION The AzLocal.UpdateManagement module ships a ready-to-edit ITSM connector sample under its `Automation-Pipeline-Examples/.itsm/` subfolder: - `azurelocal-itsm.yml` - the trigger matrix / authentication / defaults config consumed by `Get-AzureLocalItsmConfig` and `New-AzureLocalIncident`. - `templates/incident-body.md` - the Mustache template used to render the ServiceNow ticket body. Both files are CI-platform-agnostic. The YAML defines what is ticketed (trigger matrix) and how secrets are sourced (env vars, Key Vault, or both). The runtime difference between GitHub Actions and Azure DevOps is only in how secret values reach the runner's process environment - the YAML is identical for both. The bundled sample lives inside the PowerShell module install path (typically `C:\Program Files\WindowsPowerShell\Modules\AzLocal.Update Management\<version>\Automation-Pipeline-Examples\.itsm\` for AllUsers installs or the equivalent under `%USERPROFILE%\Documents\PowerShell\Modules\` for CurrentUser installs), which is awkward to browse manually. This function copies the sample into `-Destination` so it can be committed to a repository alongside the workflow / pipeline YAMLs copied by `Copy-AzureLocalPipelineExample`. The default `-Destination` is `.\.itsm` - the relative path that both `apply-updates.yml` workflows default `itsm_config_path` / `itsmConfigPath` to (resolved relative to the repo root at job runtime). The function is read-only relative to the module install (it never modifies anything under `$module.ModuleBase`). By default it also REFUSES to overwrite any file that already exists at the destination - all conflicts are listed in the error message and the copy is aborted. To refresh after a module upgrade pass `-Update`: you will be prompted per file (Y / A / N / L / S / ?) before each overwrite. Pair with `-Confirm:$false` to bypass the prompts (useful in CI). Use `-WhatIf` to preview without changing anything. Sample files are expected to live under git source control, so any overwrites can be reviewed via `git diff` before commit. Supports `-WhatIf` and `-Confirm`. .PARAMETER Destination Target folder to copy the ITSM sample into. Created if missing. Defaults to `.\.itsm` (relative to the current working directory). Run this from your repo root and the workflow defaults (which look for `./.itsm/azurelocal-itsm.yml` at job runtime) will work unchanged. .PARAMETER Update Allow overwriting destination files that already exist. Without this switch the function aborts with a list of conflicting files. With `-Update` you are prompted per file (`ShouldContinue` Y/A/N/L/S/?) before each overwrite - independent of `$ConfirmPreference`. Pass `-Confirm:$false` to suppress the prompts and overwrite unconditionally (suitable for scripted / CI refresh). `-WhatIf` overrides everything and only prints what would change. .PARAMETER PassThru Return the [System.IO.DirectoryInfo] of the destination folder. By default the function writes only informational messages. .OUTPUTS [System.IO.DirectoryInfo] when -PassThru is specified. Nothing otherwise. .EXAMPLE Copy-AzureLocalItsmSample Copies the ITSM connector sample into `.\.itsm\` under the current directory. Run from your repo root so the workflow defaults (`./.itsm/azurelocal-itsm.yml`) resolve unchanged. .EXAMPLE Copy-AzureLocalItsmSample -Destination C:\repos\fleet\.itsm Copies the ITSM connector sample into an explicit folder. .EXAMPLE Copy-AzureLocalItsmSample -Update Refresh the ITSM sample files from a (newer) installed module. You are prompted per file (Y / A / N / L / S / ?) before each overwrite. Review the result with `git diff` before committing. .EXAMPLE Copy-AzureLocalItsmSample -Update -Confirm:$false Same as above but without per-file prompts - suitable for scripted / CI refresh, typically after a `git diff` review against a fresh copy. .EXAMPLE Copy-AzureLocalItsmSample -WhatIf Preview what would be copied without changing anything on disk. .NOTES Author : Neil Bird, Microsoft Module : AzLocal.UpdateManagement Added in : v0.7.50 Pair with `Copy-AzureLocalPipelineExample` to lay out a fresh Apply-Updates pipeline that has ITSM ticketing enabled. The two functions target different destinations on purpose: - `Copy-AzureLocalPipelineExample -Destination .\.github\workflows -Platform GitHub` (or `-Platform AzureDevOps` into your pipelines folder) - `Copy-AzureLocalItsmSample` from the same starting directory (the repo root), so the sample lands at `.\.itsm\` where the workflow defaults look for it. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] [OutputType([System.IO.DirectoryInfo])] param( [Parameter(Position = 0)] [ValidateNotNullOrEmpty()] [string]$Destination = (Join-Path -Path $PWD.Path -ChildPath '.itsm'), # Allow overwriting destination files that already exist. Without # -Update, conflicts cause the function to abort. With -Update, # ShouldContinue prompts per file (bypassable via -Confirm:$false). [switch]$Update, [switch]$PassThru ) # ------------------------------------------------------------------ # 1. Locate the module install folder. We deliberately use # (Get-Module).ModuleBase rather than $PSScriptRoot so the function # works correctly when imported via the .psd1 path AND when the # function is dot-sourced standalone (e.g. in some test scenarios). # ------------------------------------------------------------------ $module = Get-Module -Name 'AzLocal.UpdateManagement' | Sort-Object Version -Descending | Select-Object -First 1 if (-not $module) { # Fallback: walk up from this file (Public/ -> module root) $moduleRoot = Split-Path -Parent $PSScriptRoot } else { $moduleRoot = $module.ModuleBase } $sourceRoot = Join-Path -Path $moduleRoot -ChildPath 'Automation-Pipeline-Examples\.itsm' if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) { throw "Copy-AzureLocalItsmSample: ITSM sample folder not found at '$sourceRoot'. The module install may be corrupt or this is a development checkout without the sample folder." } # ------------------------------------------------------------------ # 2. Resolve destination. Create parent if missing. # ------------------------------------------------------------------ if (-not (Test-Path -LiteralPath $Destination)) { if ($PSCmdlet.ShouldProcess($Destination, 'Create destination folder')) { $null = New-Item -ItemType Directory -Path $Destination -Force -ErrorAction Stop } } # When -WhatIf is supplied and the destination did not pre-exist, it still # does not exist at this point; fall back to the literal -Destination string # so the rest of the function can describe what *would* happen without # throwing on Resolve-Path. $destResolved = if (Test-Path -LiteralPath $Destination) { (Resolve-Path -LiteralPath $Destination -ErrorAction Stop).ProviderPath } else { # Best-effort absolute path for WhatIf messaging [System.IO.Path]::GetFullPath($Destination) } # ------------------------------------------------------------------ # 3. Build the (Source, Destination) pair list by mirroring the # `.itsm/` source tree under -Destination. The whole tree is # in-scope (azurelocal-itsm.yml + templates/incident-body.md); # there is no platform discriminator here because the ITSM sample # is CI-platform-agnostic. # ------------------------------------------------------------------ $copyPairs = New-Object System.Collections.Generic.List[pscustomobject] Get-ChildItem -LiteralPath $sourceRoot -Recurse -File -Force | ForEach-Object { $relative = $_.FullName.Substring($sourceRoot.Length).TrimStart('\', '/') [void]$copyPairs.Add([pscustomobject]@{ Source = $_.FullName Destination = Join-Path -Path $destResolved -ChildPath $relative }) } if ($copyPairs.Count -eq 0) { Write-Warning "Copy-AzureLocalItsmSample: nothing to copy - source folder '$sourceRoot' is empty." return } # ------------------------------------------------------------------ # 4. Pre-flight check on existing destinations. Default behaviour is # to refuse the operation entirely. -Update opts into per-file # overwrite prompts (ShouldContinue, see step 5). Either way we # collect every conflicting destination so the user sees the full # list up front rather than discovering them one at a time. # ------------------------------------------------------------------ $conflicts = @($copyPairs | Where-Object { Test-Path -LiteralPath $_.Destination -PathType Leaf }) if ($conflicts.Count -gt 0) { $conflictList = ($conflicts | ForEach-Object { " - $($_.Destination)" }) -join [Environment]::NewLine if (-not $Update) { throw ("Copy-AzureLocalItsmSample: refusing to overwrite {0} existing file(s) under '{1}'. Pass -Update to refresh (you will be prompted per file unless you also pass -Confirm:`$false). Sample files are expected to be under git source control so 'git diff' shows exactly what changed.`n{2}" -f $conflicts.Count, $destResolved, $conflictList) } Write-Verbose ("Copy-AzureLocalItsmSample: -Update specified; {0} existing file(s) may be overwritten - will prompt per file unless -Confirm:`$false is set.{1}{2}" -f $conflicts.Count, [Environment]::NewLine, $conflictList) } # ------------------------------------------------------------------ # 5. Perform the copy. One ShouldProcess gate at the operation level # rather than per file. Per-file ShouldContinue prompts are # emitted only when overwriting an existing file under -Update # (see below). Parent folders for each destination file are # created on demand (e.g. templates/). # ------------------------------------------------------------------ $copyDescription = "Copy {0} ITSM sample file(s) from '{1}' to '{2}'{3}" -f ` $copyPairs.Count, $sourceRoot, $destResolved, $(if ($Update) { ' (-Update)' } else { '' }) if (-not $PSCmdlet.ShouldProcess($destResolved, $copyDescription)) { return } # ShouldContinue state: Yes-to-All / No-to-All flags survive across # iterations so the user can pick a sample-wide choice once. They are # only consulted when -Update is set AND a destination file exists. $yesToAll = $false $noToAll = $false # -Confirm:$false (explicit) suppresses the per-file ShouldContinue # prompts entirely. This is the documented automation bypass. $confirmExplicitlyDisabled = $PSBoundParameters.ContainsKey('Confirm') -and -not [bool]$PSBoundParameters['Confirm'] $copiedCount = 0 $skippedCount = 0 foreach ($pair in $copyPairs) { $destExists = Test-Path -LiteralPath $pair.Destination -PathType Leaf # No-to-All only suppresses OVERWRITES; a brand-new file (no existing # destination) is still copied, because ShouldContinue would never # have prompted for it in the first place. This matches PowerShell's # canonical No-to-All semantics ("answer No to all remaining prompts") # rather than "halt all subsequent operations". if ($noToAll -and $destExists) { Write-Verbose ("Copy-AzureLocalItsmSample: skipped (No-to-All overwrite suppression): {0}" -f $pair.Destination) $skippedCount++ continue } if ($destExists -and -not $confirmExplicitlyDisabled -and -not $yesToAll) { $shouldOverwrite = $PSCmdlet.ShouldContinue( ("Overwrite existing file '{0}'?" -f $pair.Destination), 'Confirm ITSM sample overwrite', [ref]$yesToAll, [ref]$noToAll ) if ($noToAll) { Write-Verbose "Copy-AzureLocalItsmSample: user chose No-to-All - remaining overwrites will be skipped (new files will still be copied)." $skippedCount++ continue } if (-not $shouldOverwrite) { Write-Verbose ("Copy-AzureLocalItsmSample: skipped (user declined overwrite): {0}" -f $pair.Destination) $skippedCount++ continue } } $destDir = Split-Path -Parent $pair.Destination if (-not (Test-Path -LiteralPath $destDir)) { $null = New-Item -ItemType Directory -Path $destDir -Force -ErrorAction Stop } # -Force lets Copy-Item replace files even when they're marked read-only. # Safe here because step 4 already gated overwrites behind -Update and # the loop above gated each overwrite behind ShouldContinue. Copy-Item -LiteralPath $pair.Source -Destination $pair.Destination -Force -ErrorAction Stop $copiedCount++ } Write-Verbose "Copied $copiedCount ITSM sample file(s) from '$sourceRoot' to '$destResolved' (skipped: $skippedCount)." # ------------------------------------------------------------------ # 6. Friendly "what now" summary. Same Write-Host rationale as # Copy-AzureLocalPipelineExample - operator-facing UI text, not # pipeline output. # ------------------------------------------------------------------ Write-Host "" Write-Host "Copy-AzureLocalItsmSample - copy complete" -ForegroundColor Green Write-Host (" Source : {0}" -f $sourceRoot) Write-Host (" Destination : {0}" -f $destResolved) Write-Host (" Files copied: {0}" -f $copiedCount) if ($skippedCount -gt 0) { Write-Host (" Files skipped: {0} (user declined overwrite or -WhatIf)" -f $skippedCount) -ForegroundColor Yellow } Write-Host "" Write-Host "Next steps:" -ForegroundColor Cyan Write-Host " 1. Review the sample - especially the 'secrets:' block (keyvault vs envvar) and the 'triggers:' matrix. See ITSM/README.md for the full reference." Write-Host " 2. Wire the secrets in your CI platform:" Write-Host " - GitHub Actions: create repo / environment secrets ITSM_SN_INSTANCE_URL, ITSM_SN_CLIENT_ID, ITSM_SN_CLIENT_SECRET." Write-Host " - Azure DevOps : create a variable group named 'AzureLocal-ITSM-Secrets' containing the same three variable names (marked secret)." Write-Host " 3. In the workflow / pipeline run, set 'raise_itsm_ticket=true' (GitHub) or 'raiseItsmTicket=true' (ADO) to enable ticketing. The defaults preserve byte-identical pre-ITSM behaviour." Write-Host " 4. Validate end-to-end in dry-run mode first: 'itsm_dry_run=true' / 'itsmDryRun=true' builds payloads and runs the dedupe check without creating tickets in ServiceNow." Write-Host "" if ($PassThru.IsPresent) { return Get-Item -LiteralPath $destResolved } } |