Logging centralisé sur SharePoint en PowerShell — Faitza_Log
Intro
Quand on gère une vingtaine de scripts d'automatisation qui tournent en parallèle — provisioning d'utilisateurs, synchronisation Entra, nettoyage de comptes — la question du logging centralisé se pose très vite. Les logs éparpillés dans des fichiers locaux sur différentes machines, c'est ingérable en audit.
Faitza_Log.ps1 résout ce problème en stockant toutes les entrées de log dans un fichier JSON unique sur SharePoint. N'importe quel script du parc peut y écrire, et les équipes opérationnelles peuvent le lire et le filtrer depuis un seul endroit.
- Écrire une entrée de log dans un fichier JSON stocké sur SharePoint
- Gérer les écritures concurrentes par boucle de retry sur conflit 409
- Lire et filtrer les logs par niveau, date, ou nom de script
- Maintenir une piste d'audit unifiée pour tous tes scripts d'automatisation
Architecture
Le principe est simple : un fichier automation_log.json vit dans une bibliothèque SharePoint dédiée. Chaque script qui veut logger télécharge le fichier, ajoute son entrée en mémoire, puis re-uploade le fichier modifié via l'API REST SharePoint / Graph.
Le format retenu est un tableau JSON (array d'objets), ce qui le rend facilement consommable : ConvertFrom-Json donne directement un tableau PowerShell filtrable.
Exemple de structure du fichier automation_log.json :
[
{
"timestamp": "2025-05-28T08:12:34.1234567Z",
"level": "Info",
"scriptname": "Provisioning_Utilisateur",
"message": "Compte créé pour [email protected]"
},
{
"timestamp": "2025-05-28T08:13:01.9876543Z",
"level": "Warning",
"scriptname": "Sync_Entra",
"message": "Attribut extensionAttribute1 vide pour user [email protected]"
},
{
"timestamp": "2025-05-28T08:15:22.4567890Z",
"level": "Error",
"scriptname": "Cleanup_Comptes",
"message": "Impossible de désactiver le compte — accès refusé (403)"
}
]
Le chemin SharePoint cible est configuré une seule fois dans le module sous forme de variables de scope ($script:LogSiteUrl, $script:LogDriveId, $script:LogItemPath). Tous les appels REST utilisent Microsoft Graph avec une Managed Identity pour s'authentifier sans secret stocké.
Write-Faitza_retour
C'est la fonction principale du module. Elle prend un message, un niveau (Info, Warning, Error) et le nom du script appelant, puis ajoute une entrée dans le fichier JSON sur SharePoint.
En interne, elle effectue trois opérations : récupérer le fichier JSON courant via Graph, désérialiser le tableau, ajouter l'entrée, sérialiser et re-uploader. La gestion du conflit concurrent (code HTTP 409 ou ETag mismatch) se fait par boucle de retry — détaillée dans la section suivante.
function Write-Faitza_retour {
[CmdletBinding()]
param (
[Parameter(Mandatory)][string]$message,
[ValidateSet("Info","Warning","Error")]
[string]$level = "Info",
[string]$scriptname = $MyInvocation.ScriptName,
[switch]$show,
[int]$maxRetries = 5,
[int]$retryDelayMs = 800
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> Write-Faitza_retour <<<$($PSStyle.Reset)" }
# Variables de configuration du module (à adapter à ton tenant)
$siteId = "faitza.sharepoint.com,abc123-site-id,abc123-web-id"
$driveId = "b!abc123DriveId"
$itemPath = "/Automatisation/Logs/automation_log.json"
$entry = [ordered]@{
timestamp = (Get-Date -Format "o") # ISO 8601
level = $level
scriptname = [System.IO.Path]::GetFileNameWithoutExtension($scriptname)
message = $message
}
}
process {
$attempt = 0
$success = $false
while (-not $success -and $attempt -lt $maxRetries) {
$attempt++
try {
$headers = Get-Faitza_Modul_Headers -resource "graph"
# 1. Télécharger le fichier JSON courant (avec l'ETag pour la détection de conflit)
$metaUri = "https://graph.microsoft.com/v1.0/drives/$driveId/root:$($itemPath):"
$meta = Invoke-RestMethod -Method Get -Uri $metaUri -Headers $headers
$eTag = $meta.'@microsoft.graph.downloadUrl' # on récupère l'ETag via les headers
$dlUri = $meta.'@microsoft.graph.downloadUrl'
$rawResponse = Invoke-WebRequest -Method Get -Uri $dlUri -UseBasicParsing
$currentETag = $rawResponse.Headers['ETag'] -replace '"',''
$logArray = $rawResponse.Content | ConvertFrom-Json
if (-not $logArray) { $logArray = @() }
# 2. Ajouter la nouvelle entrée
$logArray += $entry
# 3. Re-uploader avec vérification d'ETag (If-Match)
$uploadUri = "https://graph.microsoft.com/v1.0/drives/$driveId/root:$($itemPath):/content"
$uploadHeaders = $headers.Clone()
$uploadHeaders["If-Match"] = "`"$currentETag`""
$uploadHeaders["Content-Type"] = "application/json"
$body = $logArray | ConvertTo-Json -Depth 10 -Compress
Invoke-RestMethod -Method PUT -Uri $uploadUri -Headers $uploadHeaders -Body $body | Out-Null
$success = $true
if ($show) { Write-Host "Log écrit (tentative $attempt) — [$level] $message" -ForegroundColor Cyan }
}
catch {
$statusCode = $null
if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
# 409 Conflict ou 412 Precondition Failed = conflit d'écriture concurrent
if ($statusCode -in @(409, 412) -and $attempt -lt $maxRetries) {
if ($show) { Write-Warning "Conflit détecté (HTTP $statusCode) — retry $attempt/$maxRetries dans $retryDelayMs ms..." }
Start-Sleep -Milliseconds ($retryDelayMs * $attempt) # backoff linéaire
}
else {
throw [System.Exception]::new("Write-Faitza_retour échoué après $attempt tentative(s) : $_")
}
}
}
}
end {
if ($show) { Write-Host "$($PSStyle.Background.Blue)<<< Write-Faitza_retour >>>$($PSStyle.Reset)" }
}
}
L'ETag est un identifiant de version du fichier sur SharePoint/OneDrive. En envoyant If-Match: "etag" lors de l'upload, le serveur rejette la requête avec un 412 Precondition Failed si le fichier a été modifié entre le téléchargement et l'upload. C'est la façon propre de détecter un conflit sans avoir besoin d'un verrou côté serveur.
Read-Faitza_log_Json
La fonction de lecture télécharge le fichier JSON et le désérialise en objets PowerShell. Elle supporte le filtrage par -level, -scriptname, et une plage de dates (-from / -to).
function Read-Faitza_log_Json {
[CmdletBinding()]
param (
[ValidateSet("Info","Warning","Error")]
[string]$level,
[string]$scriptname,
[datetime]$from,
[datetime]$to,
[int]$last, # retourner seulement les N dernières entrées
[switch]$show
)
begin {
if ($show) { Write-Host "$($PSStyle.Background.Blue)>>> Read-Faitza_log_Json <<<$($PSStyle.Reset)" }
$driveId = "b!abc123DriveId"
$itemPath = "/Automatisation/Logs/automation_log.json"
}
process {
try {
$headers = Get-Faitza_Modul_Headers -resource "graph"
$metaUri = "https://graph.microsoft.com/v1.0/drives/$driveId/root:$($itemPath):"
$meta = Invoke-RestMethod -Method Get -Uri $metaUri -Headers $headers
$dlUri = $meta.'@microsoft.graph.downloadUrl'
$raw = Invoke-WebRequest -Method Get -Uri $dlUri -UseBasicParsing
[array]$entries = $raw.Content | ConvertFrom-Json
}
catch {
throw [System.Exception]::new("Impossible de lire le fichier de log : $_")
}
# Filtrage
if ($level) {
$entries = $entries | Where-Object { $_.level -eq $level }
}
if ($scriptname) {
$entries = $entries | Where-Object { $_.scriptname -like "*$scriptname*" }
}
if ($from) {
$entries = $entries | Where-Object { [datetime]$_.timestamp -ge $from }
}
if ($to) {
$entries = $entries | Where-Object { [datetime]$_.timestamp -le $to }
}
# Tri chronologique (le plus récent en premier)
$entries = $entries | Sort-Object { [datetime]$_.timestamp } -Descending
if ($last -and $last -gt 0) {
$entries = $entries | Select-Object -First $last
}
}
end {
if ($show) {
$entries | Format-Table timestamp, level, scriptname, message -AutoSize
Write-Host "$($PSStyle.Background.Blue)<<< Read-Faitza_log_Json >>>$($PSStyle.Reset)"
}
return $entries
}
}
Exemples de filtrage :
# Toutes les erreurs de la semaine
Read-Faitza_log_Json -level Error -from (Get-Date).AddDays(-7) -show
# Les 50 dernières entrées du script de provisioning
Read-Faitza_log_Json -scriptname "Provisioning_Utilisateur" -last 50
# Warnings d'aujourd'hui
Read-Faitza_log_Json -level Warning -from (Get-Date).Date -to (Get-Date)
Verrouillage & concurrence
SharePoint et OneDrive n'exposent pas de mécanisme de lock exclusif sur un fichier via l'API REST. La stratégie retenue ici est un optimistic locking basé sur l'ETag combiné à une boucle de retry avec backoff.
Le principe : si deux scripts tentent d'écrire simultanément, le second à uploader reçoit un 412. Il attend quelques centaines de millisecondes, re-télécharge la version fraîche (qui contient déjà l'entrée du premier), ajoute la sienne, et ré-essaie. Après $maxRetries tentatives infructueuses, une exception est levée.
# Boucle de retry avec backoff linéaire — extrait simplifié
$maxRetries = 5
$retryDelayMs = 800
$attempt = 0
while ($attempt -lt $maxRetries) {
$attempt++
try {
# Télécharger → modifier → uploader avec If-Match
$currentETag = Get-CurrentETag
$data = Download-And-Append -newEntry $entry
Upload-WithETag -data $data -eTag $currentETag
break # succès, sortir de la boucle
}
catch [System.Net.WebException] {
$code = [int]$_.Exception.Response.StatusCode
if ($code -in @(409, 412) -and $attempt -lt $maxRetries) {
# Conflit : attendre et réessayer. Le backoff augmente à chaque tentative.
$wait = $retryDelayMs * $attempt
Write-Warning "Conflit HTTP $code — retry $attempt/$maxRetries (attente ${wait}ms)"
Start-Sleep -Milliseconds $wait
}
else {
throw # erreur non récupérable ou trop de tentatives
}
}
}
Cette stratégie fonctionne bien pour des scripts qui ne s'exécutent pas en rafale (plusieurs dizaines de writes par seconde). Si ton volume de logs est très élevé, envisage une architecture différente : une Azure Function qui reçoit les logs via HTTP POST et écrit dans Cosmos DB ou Azure Table Storage — sans contention de fichier.
Si tous tes scripts tournent sur la même machine (ex. un serveur de runbooks on-prem), tu peux utiliser un System.Threading.Mutex nommé pour sérialiser les accès, sans passer par la mécanique ETag. Le mutex système garantit qu'un seul thread écrit à la fois.
Exemple complet
Voici comment intégrer Write-Faitza_retour dans un script d'automatisation classique, avec capture des erreurs et log systématique :
#Requires -Modules Faitza_Log, Faitza_Modul
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$upn
)
$scriptName = "Provisioning_Utilisateur"
try {
Write-Faitza_retour -message "Démarrage provisioning pour $upn" `
-level Info `
-scriptname $scriptName
# Créer le compte AD
New-ADUser -SamAccountName ($upn.Split("@")[0]) `
-UserPrincipalName $upn `
-Enabled $true `
-Path "OU=Utilisateurs,DC=faitza,DC=local"
Write-Faitza_retour -message "Compte AD créé : $upn" `
-level Info `
-scriptname $scriptName
# Assigner la licence M365
Set-MgUserLicense -UserId $upn `
-AddLicenses @{ SkuId = "c7df2760-2c81-4ef7-b578-5b5392b571df" } `
-RemoveLicenses @()
Write-Faitza_retour -message "Licence M365 Business Premium assignée à $upn" `
-level Info `
-scriptname $scriptName
}
catch {
Write-Faitza_retour -message "ERREUR provisioning $upn — $($_.Exception.Message)" `
-level Error `
-scriptname $scriptName
throw
}
finally {
Write-Faitza_retour -message "Fin du script pour $upn" `
-level Info `
-scriptname $scriptName
}
Relire le résultat une fois le script terminé :
# Voir les 20 dernières lignes du script de provisioning
$logs = Read-Faitza_log_Json -scriptname "Provisioning_Utilisateur" -last 20
$logs | Select-Object timestamp, level, message | Format-Table -AutoSize
# Résultat :
# timestamp level message
# --------- ----- -------
# 2025-05-28T09:14:55.123Z Info Démarrage provisioning pour [email protected]
# 2025-05-28T09:15:01.456Z Info Compte AD créé : [email protected]
# 2025-05-28T09:15:08.789Z Info Licence M365 Business Premium assignée à [email protected]
# 2025-05-28T09:15:09.012Z Info Fin du script pour [email protected]
Pièges & bonnes pratiques
Si tu fais un PUT sans If-Match, tu écrases silencieusement le fichier même si quelqu'un d'autre a écrit entre-temps. Les logs de l'autre script disparaissent. Toujours récupérer l'ETag au moment du GET et le fournir à l'upload.
Un tableau JSON unique qui grandit indéfiniment devient lent à télécharger et à parser. Mets en place une rotation : archive les entrées de plus de 90 jours dans un fichier versionné (automation_log_2025-Q1.json) et recrée un fichier courant allégé.
Le paramètre -scriptname utilise $MyInvocation.ScriptName comme valeur par défaut. Dans un module imbriqué, ce chemin peut être vide ou pointer vers le module lui-même. Passe toujours le nom explicitement depuis le script appelant pour garantir une piste d'audit lisible.
La Managed Identity n'a besoin que de Files.ReadWrite sur le site SharePoint cible (scope Graph délégué ou application). N'accorde pas Files.ReadWrite.All si tu peux l'éviter.
// Commentaires
Aucun commentaire pour l'instant.