Private/State.ps1
|
<#
Magasin d'état de migration. Principe : l'état est la source de vérité, les journaux en sont dérivés. La version précédente faisait l'inverse — elle reconstruisait les compteurs en cherchant des sous-chaînes dans un fichier de log, ce qui interdisait toute reprise fiable. Format : NDJSON append-only (une ligne = un événement d'unité de travail). Choisi plutôt que SQLite pour rester sans dépendance binaire, lisible à l'œil, et réparable à la main en cas d'interruption brutale. #> $script:SPMStateFile = $null $script:SPMStateIndex = $null $script:SPMStateWriter = $null function Initialize-SPMState { <# .SYNOPSIS Ouvre (ou crée) le magasin d'état d'une exécution de migration. .PARAMETER Path Fichier .ndjson d'état. S'il existe déjà, il est relu pour permettre la reprise. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [switch]$Fresh ) $dir = Split-Path -Parent $Path if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } if ($Fresh -and (Test-Path -LiteralPath $Path)) { Remove-Item -LiteralPath $Path -Force } $script:SPMStateFile = $Path $script:SPMStateIndex = [System.Collections.Generic.Dictionary[string, object]]::new() if (Test-Path -LiteralPath $Path) { $loaded = 0 foreach ($line in [System.IO.File]::ReadLines($Path)) { if ([string]::IsNullOrWhiteSpace($line)) { continue } try { $record = $line | ConvertFrom-Json # Le dernier événement d'une clé fait foi (append-only + écrasement logique) $script:SPMStateIndex[$record.key] = $record $loaded++ } catch { Write-SPMLog -Level WARNING -Operation 'State' -Message "Ligne d'état illisible ignorée : $($_.Exception.Message)" } } Write-SPMLog -Level INFO -Operation 'State' -Message "État rechargé : $loaded événement(s), $($script:SPMStateIndex.Count) unité(s) distincte(s)" } return [pscustomobject]@{ Path = $Path Units = $script:SPMStateIndex.Count Completed = @($script:SPMStateIndex.Values | Where-Object { $_.status -eq 'Completed' }).Count } } function Get-SPMStateKey { <# .SYNOPSIS Construit la clé d'idempotence d'une unité de travail. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$SourceUrl, [Parameter(Mandatory)][string]$TargetUrl ) ($SourceUrl.TrimEnd('/') + '=>' + $TargetUrl.TrimEnd('/')).ToLowerInvariant() } function Test-SPMUnitCompleted { <# .SYNOPSIS Indique si une unité de travail est déjà terminée avec succès. .DESCRIPTION En mode incrémental, une unité terminée est retenue seulement si la signature de la source n'a pas changé (taille + date de modification). « Le dossier existe » ne suffit pas à conclure « il est à jour ». #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][string]$Key, [string]$SourceSignature ) if (-not $script:SPMStateIndex -or -not $script:SPMStateIndex.ContainsKey($Key)) { return $false } $record = $script:SPMStateIndex[$Key] if ($record.status -ne 'Completed') { return $false } if ($SourceSignature) { $stored = $record.PSObject.Properties['sourceSignature'] if (-not $stored -or $stored.Value -ne $SourceSignature) { return $false # la source a changé depuis la dernière migration } } return $true } function Write-SPMStateRecord { <# .SYNOPSIS Enregistre l'état d'une unité de travail (append-only). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Key, # « Submitted » : le service a ACCEPTÉ le travail de copie, il ne l'a pas # encore exécuté. Distinguer cet état de « Completed » est ce qui empêche # un rapport d'annoncer 100 % de réussite sur une destination vide. [Parameter(Mandatory)] [ValidateSet('Pending', 'Running', 'Submitted', 'Completed', 'Failed', 'Skipped')] [string]$Status, [string]$SourceUrl, [string]$TargetUrl, [string]$SourceSignature, [string]$JobId, [string]$ErrorMessage, [hashtable]$Data ) $record = [ordered]@{ ts = (Get-Date).ToString('o') key = $Key status = $Status } if ($SourceUrl) { $record.sourceUrl = $SourceUrl } if ($TargetUrl) { $record.targetUrl = $TargetUrl } if ($SourceSignature) { $record.sourceSignature = $SourceSignature } if ($JobId) { $record.jobId = $JobId } if ($ErrorMessage) { $record.error = $ErrorMessage } if ($Data) { foreach ($k in $Data.Keys) { $record[$k] = $Data[$k] } } $json = $record | ConvertTo-Json -Compress -Depth 6 if ($script:SPMStateFile) { Add-Content -LiteralPath $script:SPMStateFile -Value $json -Encoding utf8 } if ($script:SPMStateIndex) { $script:SPMStateIndex[$Key] = ($json | ConvertFrom-Json) } } function Get-SPMStateSummary { <# .SYNOPSIS Agrège l'état courant par statut. #> [CmdletBinding()] param() if (-not $script:SPMStateIndex) { return [pscustomobject]@{ Total = 0; Completed = 0; Failed = 0; Skipped = 0; Running = 0; Pending = 0 } } $byStatus = $script:SPMStateIndex.Values | Group-Object status -AsHashTable -AsString $count = { param($s) if ($byStatus -and $byStatus.ContainsKey($s)) { @($byStatus[$s]).Count } else { 0 } } [pscustomobject]@{ Total = $script:SPMStateIndex.Count Completed = & $count 'Completed' Failed = & $count 'Failed' Skipped = & $count 'Skipped' Running = & $count 'Running' Pending = & $count 'Pending' } } function Get-SPMFailedUnit { <# .SYNOPSIS Retourne les unités en échec, pour relance ciblée. #> [CmdletBinding()] param() if (-not $script:SPMStateIndex) { return @() } @($script:SPMStateIndex.Values | Where-Object { $_.status -eq 'Failed' }) } |