faitza.com
faitza.com
> blog_tech

Logging centralisé sur SharePoint en PowerShell — Faitza_Log

Calcul... #powershell #logging #sharepoint #automation #audit

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.

Ce que tu vas obtenir
  • É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)" }
    }
}
ETag vs If-Match

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
        }
    }
}
Limites de l'optimistic locking

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.

Mutex PowerShell (alternative locale)

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

Ne jamais ignorer l'ETag

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.

Fichier JSON qui gonfle

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é.

Nom de script automatique

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.

Droit minimum requis

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.