Tutoriels gratuits et ouverts à toutes et à tous · conçus au Lycée Chevrollier — AngersÀ propos

Série « Parcours PowerShell » · partie 5 / 8

PowerShell niveau 4 — Scripts et fonctions

Passer de la commande au script réutilisable : paramètres, fonctions, erreurs et journalisation : niveau 4 du parcours PowerShell, avec exercices corrigés et QCM.

Systèmes✎ B. Jousseaume📅 17 octobre 2026⏱ 6 h📶 Intermédiaire

1. Le premier script

Un script est un fichier texte d’extension .ps1. On l’écrit dans Visual Studio Code avec l’extension PowerShell (ISE reste disponible mais n’évolue plus).

# Fichier : Get-EspaceDisque.ps1
$disques = Get-PSDrive -PSProvider FileSystem
foreach ($d in $disques) {
    [PSCustomObject]@{
        Lecteur = $d.Name
        LibreGo = [math]::Round($d.Free / 1GB, 1)
        UtiliseGo = [math]::Round($d.Used / 1GB, 1)
    }
}
PS> .\Get-EspaceDisque.ps1                     # depuis le dossier du script
PS> & "C:\Scripts\Get-EspaceDisque.ps1"        # avec un chemin complet

Le .\ est obligatoire : PowerShell n’exécute pas un script du dossier courant sans chemin explicite, afin d’éviter qu’un fichier malveillant déposé dans un dossier ne soit lancé à la place d’une commande attendue.

La stratégie d’exécution

PS> Get-ExecutionPolicy -List
PS> Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
ValeurEffet
RestrictedAucun script ne s’exécute (valeur par défaut sur les postes clients)
RemoteSignedLes scripts locaux s’exécutent ; ceux venant d’Internet doivent être signés
AllSignedTous les scripts doivent être signés numériquement
BypassAucune restriction — à réserver à des cas précis et maîtrisés

2. Des paramètres plutôt que des valeurs en dur

param() doit être la première instruction du script (commentaires exceptés).

param(
    [Parameter(Mandatory)]
    [string]$Dossier,

    [ValidateRange(1, 365)]
    [int]$JoursAnciennete = 30,

    [ValidateSet("Log", "Tmp", "Tous")]
    [string]$Type = "Tous",

    [switch]$Supprimer
)

$limite = (Get-Date).AddDays(-$JoursAnciennete)
$fichiers = Get-ChildItem $Dossier -File -Recurse |
    Where-Object LastWriteTime -lt $limite

if ($Supprimer) {
    $fichiers | Remove-Item -WhatIf
} else {
    $fichiers | Select-Object FullName, LastWriteTime
}
PS> .\Clean-Dossier.ps1 -Dossier C:\Temp -JoursAnciennete 90
PS> .\Clean-Dossier.ps1 -Dossier C:\Temp -Supprimer
AttributRôle
[Parameter(Mandatory)]PowerShell réclame la valeur si elle manque
[ValidateSet(...)]Limite aux valeurs listées, avec complétion par Tab
[ValidateRange(a,b)]Borne une valeur numérique
[ValidateNotNullOrEmpty()]Refuse une valeur vide
[switch]Option présente ou absente, sans valeur

3. Les fonctions

function Get-EspaceDisque {
    [CmdletBinding()]
    param(
        [string]$Lettre = "C"
    )

    Write-Verbose "Lecture du lecteur $Lettre"
    $d = Get-PSDrive -Name $Lettre -ErrorAction Stop

    [PSCustomObject]@{
        Lecteur     = $Lettre
        LibreGo     = [math]::Round($d.Free / 1GB, 1)
        PourcentLibre = [math]::Round($d.Free / ($d.Free + $d.Used) * 100, 1)
    }
}

Get-EspaceDisque -Lettre C -Verbose
  • [CmdletBinding()] transforme la fonction en fonction avancée : elle accepte alors -Verbose, -ErrorAction, -WhatIf… gratuitement.
  • Nomme tes fonctions Verbe-Nom avec un verbe approuvé (Get-Verb), et un préfixe maison pour le nom : Get-LycEspaceDisque.
  • Une fonction renvoie des objets : tout ce qui n’est pas capturé sort de la fonction. Utilise Write-Host seulement pour un affichage destiné à l’humain.

Les canaux de sortie

CommandeUsageVisible par défaut
Write-OutputDonnées exploitables (c’est la sortie du pipeline)Oui
Write-HostMessage décoratif pour l’utilisateurOui, mais non capturable
Write-VerboseDétail de fonctionnementAvec -Verbose
Write-WarningAvertissementOui
Write-ErrorErreur non bloquanteOui
throwErreur bloquanteOui

Rendre une fonction compatible -WhatIf

function Remove-FichierAncien {
    [CmdletBinding(SupportsShouldProcess)]
    param([string]$Chemin)

    if ($PSCmdlet.ShouldProcess($Chemin, "Suppression")) {
        Remove-Item $Chemin -Force
    }
}

Remove-FichierAncien -Chemin C:\Temp\vieux.log -WhatIf

4. Gérer les erreurs

PowerShell distingue les erreurs non bloquantes (le script continue) des erreurs bloquantes (il s’arrête). -ErrorAction Stop transforme la première en seconde, ce qui permet de l’attraper.

try {
    $service = Get-Service -Name "ServiceInexistant" -ErrorAction Stop
    $service | Stop-Service -ErrorAction Stop
}
catch [Microsoft.PowerShell.Commands.ServiceCommandException] {
    Write-Warning "Service introuvable : $($_.Exception.Message)"
}
catch {
    Write-Error "Erreur inattendue : $($_.Exception.Message)"
}
finally {
    Write-Verbose "Fin du traitement"
}
  • $_ dans un catch représente l’erreur ; $_.Exception.Message en donne le texte.
  • Plusieurs catch peuvent se succéder, du plus précis au plus général.
  • $ErrorActionPreference = "Stop" en tête de script rend toutes les erreurs bloquantes : utile pour un script d’administration qui ne doit pas continuer à l’aveugle.
  • Ne fais jamais catch { } vide : une erreur avalée est une panne invisible.

5. Journaliser

Un script d’administration doit laisser une trace : ce qui a été fait, quand, par qui, avec quel résultat.

function Write-Journal {
    param(
        [Parameter(Mandatory)][string]$Message,
        [ValidateSet("INFO", "ALERTE", "ERREUR")][string]$Niveau = "INFO",
        [string]$Fichier = "$PSScriptRoot\journal.log"
    )
    $ligne = "{0} [{1}] {2} - {3}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Niveau, $env:USERNAME, $Message
    Add-Content -Path $Fichier -Value $ligne -Encoding utf8
}

Write-Journal "Démarrage du nettoyage"
Write-Journal "Espace insuffisant sur D:" -Niveau ALERTE

L’alternative intégrée est Start-Transcript / Stop-Transcript, qui enregistre toute la session : pratique en dépannage, trop verbeux pour un script régulier.

6. Structurer un script d’administration

<#
.SYNOPSIS
    Nettoie les fichiers temporaires d'un dossier.
.DESCRIPTION
    Supprime les fichiers plus anciens qu'un nombre de jours donné, avec journalisation.
.PARAMETER Dossier
    Dossier à nettoyer.
.EXAMPLE
    .\Clean-Temp.ps1 -Dossier C:\Temp -JoursAnciennete 60 -WhatIf
.NOTES
    Auteur : Bruno Jousseaume — Lycée Chevrollier
#>
[CmdletBinding(SupportsShouldProcess)]
param(...)

#region Fonctions
function Write-Journal { ... }
#endregion

#region Traitement
try { ... }
catch { ... }
#endregion

Ce bloc de commentaire n’est pas décoratif : Get-Help .\Clean-Temp.ps1 -Examples l’affiche comme pour une cmdlet native.

7. Mini-projet : le script de nettoyage

8. Bilan

Exercices corrigés

Un script bien écrit se reconnaît à trois signes : ses paramètres sont validés, ses erreurs sont gérées, et il laisse une trace. Compare ton travail sur ces trois points avant la syntaxe.

Exercice 4.1 — Un script paramétré

Voir le corrigé
<#
.SYNOPSIS
    Produit la fiche d'identité de la machine locale.
.EXAMPLE
    .\Get-FicheMachine.ps1 -CheminExport C:\Temp\fiche.json -Format JSON
#>
param(
    [string]$CheminExport,
    [ValidateSet("CSV", "JSON")][string]$Format = "CSV"
)

$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem

$fiche = [PSCustomObject]@{
    Machine       = $env:COMPUTERNAME
    Utilisateur   = $env:USERNAME
    Systeme       = $os.Caption
    PowerShell    = $PSVersionTable.PSVersion.ToString()
    MemoireGo     = [math]::Round($cs.TotalPhysicalMemory / 1GB, 1)
    DisqueCLibreGo = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
    DateReleve    = Get-Date
}

if ($CheminExport) {
    if ($Format -eq "CSV") {
        $fiche | Export-Csv $CheminExport -Delimiter ";" -Encoding UTF8 -NoTypeInformation
    } else {
        $fiche | ConvertTo-Json | Out-File $CheminExport -Encoding utf8
    }
}
$fiche

Explication

Le script renvoie un objet, ce qui permet de l’enchaîner : .\Get-FicheMachine.ps1 | Export-Csv ... fonctionne même sans le paramètre d’export. C’est la bonne pratique : une fonction produit des données, l’appelant décide de leur destination.

Exercice 4.2 — Robustesse

Voir le corrigé
function Test-Service {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Nom)

    Write-Verbose "Recherche du service $Nom"
    $service = Get-Service -Name $Nom -ErrorAction SilentlyContinue

    [PSCustomObject]@{
        Nom     = $Nom
        Existe  = [bool]$service
        Etat    = if ($service) { $service.Status } else { "N/A" }
    }
}

function Backup-Fichier {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][string]$Chemin,
        [string]$Destination = "$env:USERPROFILE\Sauvegardes"
    )

    try {
        $fichier = Get-Item $Chemin -ErrorAction Stop
        if (-not (Test-Path $Destination)) {
            New-Item $Destination -ItemType Directory -Force | Out-Null
        }
        $nouveau = "{0}_{1}{2}" -f $fichier.BaseName, (Get-Date -Format "yyyyMMdd-HHmm"), $fichier.Extension
        $cible = Join-Path $Destination $nouveau

        if ($PSCmdlet.ShouldProcess($cible, "Copie de sauvegarde")) {
            Copy-Item $fichier.FullName $cible -ErrorAction Stop
            Write-Verbose "Copié vers $cible"
        }
    }
    catch {
        Write-Error "Sauvegarde impossible : $($_.Exception.Message)"
    }
}

Explication

Test-Service ne plante jamais car elle utilise -ErrorAction SilentlyContinue puis teste la variable. [bool]$service convertit l’objet (ou $null) en vrai/faux. Backup-Fichier, elle, a besoin de -ErrorAction Stop pour que try/catch intercepte l’erreur.

Pour aller plus loin

# Le même test sur plusieurs services, grâce au pipeline
"Spooler", "BITS", "Inexistant" | ForEach-Object { Test-Service -Nom $_ } | Format-Table -AutoSize

Mini-projet

Voir le corrigé
<#
.SYNOPSIS
    Supprime les fichiers temporaires anciens d'un dossier, avec journalisation.
.PARAMETER Dossier
    Dossier à nettoyer (obligatoire).
.PARAMETER JoursAnciennete
    Âge minimal des fichiers supprimés, en jours (1 à 365, défaut 30).
.EXAMPLE
    .\Clean-Temp.ps1 -Dossier C:\Temp -JoursAnciennete 60 -WhatIf
.NOTES
    Auteur : Bruno Jousseaume
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][string]$Dossier,
    [ValidateRange(1, 365)][int]$JoursAnciennete = 30,
    [string[]]$Extension = @("*.tmp", "*.log"),
    [string]$Journal = "$PSScriptRoot\clean-temp.log"
)

function Write-Journal {
    param([string]$Message, [ValidateSet("INFO", "ALERTE", "ERREUR")][string]$Niveau = "INFO")
    "{0} [{1}] {2} - {3}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Niveau, $env:USERNAME, $Message |
        Add-Content -Path $Journal -Encoding utf8
}

# Garde-fous
$interdits = @("C:\", "C:\Windows", "C:\Windows\System32", "C:\Program Files", $env:USERPROFILE)
$chemin = (Resolve-Path $Dossier -ErrorAction SilentlyContinue).Path
if (-not $chemin) {
    Write-Journal "Dossier introuvable : $Dossier" -Niveau ERREUR
    throw "Dossier introuvable : $Dossier"
}
if ($interdits -contains $chemin.TrimEnd("\")) {
    Write-Journal "Refus de nettoyer un dossier protégé : $chemin" -Niveau ALERTE
    throw "Dossier protégé, nettoyage refusé."
}

$limite = (Get-Date).AddDays(-$JoursAnciennete)
$cibles = Get-ChildItem -Path $chemin -File -Recurse -Include $Extension -ErrorAction SilentlyContinue |
    Where-Object LastWriteTime -lt $limite

$octets = ($cibles | Measure-Object Length -Sum).Sum
$supprimes = 0

foreach ($f in $cibles) {
    if ($PSCmdlet.ShouldProcess($f.FullName, "Suppression")) {
        try {
            Remove-Item $f.FullName -Force -ErrorAction Stop
            Write-Journal "Supprimé : $($f.FullName)"
            $supprimes++
        }
        catch {
            Write-Journal "Échec sur $($f.FullName) : $($_.Exception.Message)" -Niveau ERREUR
        }
    }
}

$resume = [PSCustomObject]@{
    Dossier        = $chemin
    FichiersTrouves = $cibles.Count
    FichiersSupprimes = $supprimes
    EspaceLibereMo = [math]::Round($octets / 1MB, 1)
    Date           = Get-Date
}
Write-Journal "Bilan : $supprimes fichier(s), $($resume.EspaceLibereMo) Mo"
$resume

Explication

Le script suit l’ordre : valider les entrées, refuser les cas dangereux, agir, journaliser, renvoyer un résumé. Resolve-Path transforme un chemin relatif en chemin absolu, ce qui rend la comparaison avec la liste des dossiers interdits fiable.

Pour aller plus loin

Amélioration possible : un paramètre -Corbeille qui déplace au lieu de supprimer, le temps de gagner en confiance.

🧠 Testez vos connaissances

5 questions. Répondez puis cliquez sur « Corriger ». Rien n’est enregistré : c’est de l’auto-évaluation.

1. Pourquoi faut-il écrire .\monscript.ps1 et non monscript.ps1 ?

💬 Cela évite qu’un fichier déposé dans un dossier ne soit lancé à la place d’une commande attendue.

2. La stratégie d’exécution (ExecutionPolicy) est…

💬 Les vraies mesures sont la signature de code, le contrôle applicatif, la journalisation et le moindre privilège.

3. Que fait [CmdletBinding()] en tête d’une fonction ?

💬 Avec SupportsShouldProcess, la fonction gagne aussi -WhatIf et -Confirm.

4. Pourquoi ajouter -ErrorAction Stop dans un bloc try ?

💬 Sans cela, une erreur non bloquante passe à travers le try/catch sans être traitée.

5. Que ne doit jamais contenir le journal d’un script ?

💬 Le journal enregistre l’action et son résultat. L’auteur et l’heure sont au contraire indispensables pour reconstituer les faits.

© 2026 Bruno Jousseaume. Ce cours est publié sous licence CC BY-NC-SA 4.0 : vous pouvez le partager et l’adapter en citant l’auteur et un lien vers cette page, sans usage commercial, et en partageant vos adaptations sous la même licence.

Pour aller plus loin

Bastion Guacamole : dépanner les connexions RDP

Une démarche méthodique pour diagnostiquer une connexion RDP qui échoue à travers le bastion : journal de guacd, tableau de diagnostic, NLA et NTLM, réseau, fiche de suivi d’incident.

Avancé⏱ 1 h 30 · 8 octobre 2026