9 min czytania

Skrypt PowerShell, który przeżyje autora: parametry, tryb próbny, logi i kody wyjścia

Skrypt uruchamiany co noc z Harmonogramu zadań jest usługą produkcyjną, nawet jeśli ma sto linii. Jak go napisać, żeby następca wiedział, co robi, a harmonogram wiedział, czy się udał.

Rys. 1. Skrypt sprawdza dane, zanim cokolwiek zmieni, potrafi pokazać zmiany bez ich wykonania, a każdy wynik, także błąd, kończy się wpisem w logu i kodem wyjścia.

Na serwerze plików od trzech lat działa zadanie harmonogramu, które co noc usuwa stare pliki eksportu. Autor skryptu dawno przeszedł do innego działu. Pewnej nocy skrypt nie znajduje katalogu, bo po migracji zmieniła się litera dysku, wypisuje błąd, którego nikt nie widzi, i kończy pracę. Harmonogram pokazuje wynik 0x0, czyli sukces. Po dwóch miesiącach dysk z eksportami się zapełnia, a nikt nie wie, od kiedy skrypt nie działa ani z jakimi parametrami był uruchamiany.

Skrypt uruchamiany bez nadzoru jest usługą produkcyjną, nawet jeśli ma sto linii. Potrzebuje tego samego co usługa: zadeklarowanych wymagań, sprawdzonych danych wejściowych, trybu próbnego, logu, czytelnego wyniku i braku haseł w kodzie. Większość z tego PowerShell daje bez dodatkowej pracy, trzeba tylko z tego skorzystać.

Szkielet skryptu

Poniższy skrypt usuwa pliki starsze niż podana liczba dni. Logika jest prosta celowo; ważna jest obudowa, którą można skopiować do każdego skryptu uruchamianego z harmonogramu.

#Requires -Version 5.1
<#
.SYNOPSIS
    Usuwa pliki eksportu starsze niż podana liczba dni.
.DESCRIPTION
    Uruchamiany z Harmonogramu zadań. Kody wyjścia: 0 sukces,
    1 błąd krytyczny, 2 część plików nie została usunięta.
.EXAMPLE
    .\Remove-OldExports.ps1 -Path D:\Export -OlderThanDays 30 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$Path,

    [ValidateRange(1, 3650)]
    [int]$OlderThanDays = 30,

    [ValidateNotNullOrEmpty()]
    [string]$LogDirectory = 'C:\ProgramData\Scripts\Logs'
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# Log zapisuje się także w trybie -WhatIf
New-Item -ItemType Directory -Path $LogDirectory -Force -WhatIf:$false | Out-Null
$logFile = Join-Path $LogDirectory ('Remove-OldExports_{0:yyyyMMdd}.log' -f (Get-Date))

function Write-Log {
    param(
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR')][string]$Level = 'INFO'
    )
    $line = '{0:yyyy-MM-dd HH:mm:ss} [{1}] {2}' -f (Get-Date), $Level, $Message
    Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 -WhatIf:$false
    Write-Verbose $line
}

$exitCode = 0
try {
    Write-Log "Start: $Path, starsze niż $OlderThanDays dni, WhatIf: $WhatIfPreference"
    $cutoff = (Get-Date).AddDays(-$OlderThanDays)
    $files = @(Get-ChildItem -LiteralPath $Path -File | Where-Object LastWriteTime -lt $cutoff)
    Write-Log "Plików do usunięcia: $($files.Count)"

    foreach ($file in $files) {
        if ($PSCmdlet.ShouldProcess($file.FullName, 'Usuń plik')) {
            try {
                Remove-Item -LiteralPath $file.FullName
                Write-Log "Usunięto: $($file.FullName)"
            }
            catch {
                Write-Log "Nie usunięto: $($file.FullName): $($_.Exception.Message)" -Level WARN
                $exitCode = 2
            }
        }
    }
}
catch {
    Write-Log "Błąd: $($_.Exception.Message)" -Level ERROR
    $exitCode = 1
}
finally {
    Write-Log "Koniec, kod wyjścia: $exitCode"
}
exit $exitCode

Plik z polskimi znakami zapisuj jako UTF-8 z BOM. Windows PowerShell 5.1 czyta plik bez BOM w stronie kodowej systemu i zamienia znaki diakrytyczne w komunikatach na przypadkowe symbole.

Nagłówek: wymagania i pomoc

#Requires sprawdza warunki, zanim wykona się pierwsza linia: -Version 5.1 blokuje uruchomienie na starszej wersji, -Modules ActiveDirectory na maszynie bez modułu, a -RunAsAdministrator w sesji bez podniesionych uprawnień. Skrypt kończy się wtedy jednym czytelnym komunikatem zamiast błędem w połowie pracy.

Blok pomocy z .SYNOPSIS, .DESCRIPTION i .EXAMPLE jest dokumentacją, która nie odkleja się od kodu. Get-Help .\Remove-OldExports.ps1 -Full wyświetla ją razem z listą parametrów, a opis kodów wyjścia odpowiada na pierwsze pytanie osoby, która widzi w harmonogramie wynik 0x2.

Parametry z walidacją

Atrybuty walidacji działają, zanim skrypt cokolwiek zrobi. Błędna wartość przerywa uruchomienie z komunikatem, który mówi, którego parametru dotyczy problem.

AtrybutCo sprawdzaPrzykład użycia
[Parameter(Mandatory)]parametr musi być podany; w trybie -NonInteractive brak kończy się błędemścieżka katalogu
[ValidateScript({ … })]dowolny warunek zwracający prawdę lub fałszTest-Path -LiteralPath $_ -PathType Container
[ValidateRange(1, 3650)]zakres liczbyliczba dni
[ValidateSet('Archive', 'Delete')]wartość z listy, z podpowiadaniem w konsolitryb działania
[ValidatePattern('^srv-[a-z]+-\d{2}$')]wyrażenie regularnenazwa serwera
[ValidateNotNullOrEmpty()]brak pustej wartościkatalog logów

Tryb -WhatIf: SupportsShouldProcess

[CmdletBinding(SupportsShouldProcess)] dodaje do skryptu parametry -WhatIf i -Confirm. Każda zmiana jest opakowana w $PSCmdlet.ShouldProcess(cel, operacja), który w trybie -WhatIf zwraca fałsz i wypisuje, co zostałoby zrobione:

What if: Performing the operation "Usuń plik" on target "D:\Export\raport_2026-08-01.csv".

Dzięki temu następca może bezpiecznie sprawdzić, co skrypt zrobi na produkcji, zanim pozwoli mu to zrobić.

Błędy: Stop, try, catch, finally

Domyślna wartość $ErrorActionPreference to Continue. Większość błędów w poleceniach, na przykład Get-ChildItem na nieistniejącej ścieżce, jest wtedy niekończąca: PowerShell wypisuje komunikat i wykonuje kolejne linie, a blok catch się nie uruchamia. Tak powstaje scenariusz z początku tekstu. $ErrorActionPreference = 'Stop' zamienia każdy błąd w wyjątek, który trafia do catch.

Set-StrictMode -Version Latest dodaje drugi bezpiecznik: odwołanie do nieistniejącej zmiennej jest błędem, więc literówka w nazwie nie zamienia się po cichu w pustą wartość. Blok finally wykonuje się zawsze, także po błędzie, więc tam trafia zamknięcie logu, Stop-Transcript albo zwolnienie zasobów.

Log: Start-Transcript albo własna funkcja

Start-Transcript -Path <plik> -Append zapisuje wszystko, co pojawia się w konsoli, łącznie z błędami. Jest to najprostsza droga dla istniejącego skryptu, ale log jest rozwlekły i trudny do przeszukiwania. Własna funkcja, taka jak Write-Log w szkielecie, daje jedną linię na zdarzenie z datą i poziomem. Taki plik łatwo przeszukać, porównać między dniami i wysłać do centralnego systemu logów.

Write-Verbose zostaje dla szczegółów: przy ręcznym uruchomieniu z -Verbose widać każdy krok, przy uruchomieniu z harmonogramu nic nie zaśmieca wyjścia. Logi też trzeba sprzątać, na przykład jedną linią na końcu skryptu: Get-ChildItem $LogDirectory -Filter *.log | Where-Object LastWriteTime -lt (Get-Date).AddDays(-90) | Remove-Item -WhatIf:$false, która usuwa logi starsze niż 90 dni.

Kody wyjścia dla harmonogramu

Harmonogram zadań nie czyta logu. Widzi tylko kod, z którym zakończył się proces powershell.exe, i pokazuje go w kolumnie „Wynik ostatniego uruchomienia” w zapisie szesnastkowym.

Kod w skrypcieZnaczenieWynik w harmonogramie
exit 0wszystko wykonane0x0
exit 1błąd krytyczny, praca przerwana0x1
exit 2wykonane częściowo, szczegóły w logu0x2
brak (nadaje harmonogram)zadanie jeszcze nie było uruchomione0x41303

Kod dociera do harmonogramu, gdy skrypt jest uruchamiany przez -File. Przy -Command kod wyjścia skryptu może zostać spłaszczony do 0 albo 1. Rejestracja zadania wygląda tak; poniższe polecenia tworzą zadanie, które od następnej nocy o 2:00 będzie usuwać pliki:

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -NonInteractive -File "D:\Scripts\Remove-OldExports.ps1" -Path "D:\Export" -OlderThanDays 30'
$trigger = New-ScheduledTaskTrigger -Daily -At '02:00'
$principal = New-ScheduledTaskPrincipal -UserId 'CORP\gmsa-scripts$' -LogonType Password
Register-ScheduledTask -TaskName 'Remove-OldExports' -TaskPath '\Scripts\' `
    -Action $action -Trigger $trigger -Principal $principal

-NonInteractive sprawia, że każde pytanie o brakujący parametr lub potwierdzenie kończy się błędem zamiast zawieszenia zadania na zawsze.

Bez haseł w skrypcie

Uruchomienie ręczne: Get-Credential pyta o poświadczenia w chwili uruchomienia i przekazuje je jako obiekt do parametrów -Credential.

Uruchomienie z harmonogramu: konto usługi zarządzanej przez grupę (gMSA). Zadanie z poprzedniej sekcji działa na koncie gmsa-scripts$, które samo ma uprawnienia do udziału lub obiektów w katalogu. Hasła nie zna nikt, a domena zmienia je automatycznie, domyślnie co 30 dni. Konto potrzebuje prawa „Logowanie w trybie wsadowym” (Log on as a batch job) na serwerze, a serwer musi być wpisany w PrincipalsAllowedToRetrieveManagedPassword konta.

Tokeny i klucze do zewnętrznych API: moduł Microsoft.PowerShell.SecretManagement z rozszerzeniem magazynu, na przykład Microsoft.PowerShell.SecretStore albo magazynem w chmurze. W skrypcie zostaje tylko odwołanie: Get-Secret -Name 'ExportApiToken' -Vault 'Automation' -AsPlainText. Magazyn SecretStore jest przypisany do użytkownika, więc musi być skonfigurowany w kontekście konta, które uruchamia zadanie.

Jakość: PSScriptAnalyzer i Pester

PSScriptAnalyzer to analiza statyczna, która wychwytuje typowe problemy, zanim skrypt trafi na serwer:

Install-Module PSScriptAnalyzer -Scope CurrentUser   # jednorazowa instalacja modułu
Invoke-ScriptAnalyzer -Path .\Remove-OldExports.ps1 -Severity Warning, Error

Wykrywa między innymi aliasy zamiast pełnych nazw poleceń (PSAvoidUsingCmdletAliases), parametry z hasłem jako zwykłym tekstem (PSAvoidUsingPlainTextForPassword) oraz funkcje zmieniające stan bez obsługi ShouldProcess (PSUseShouldProcessForStateChangingFunctions). Logikę, która coś wylicza, na przykład wybór plików do usunięcia, warto wydzielić do funkcji i pokryć testami Pester (Invoke-Pester). Jeśli skrypty leżą w repozytorium, analiza i testy uruchamiają się w CI przy każdej zmianie.

Podpis i polityka wykonywania

Get-ExecutionPolicy -List pokazuje politykę w każdym zakresie: MachinePolicy i UserPolicy pochodzą z GPO i mają pierwszeństwo przed LocalMachine i CurrentUser. Polityka wykonywania nie jest granicą bezpieczeństwa, bo można ją obejść parametrem -ExecutionPolicy Bypass. Przy AllSigned podpis działa jednak jako kontrola zmian: każda edycja pliku na serwerze unieważnia podpis i skrypt przestaje się uruchamiać, więc poprawki muszą przejść przez repozytorium i ponowne podpisanie.

$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
Set-AuthenticodeSignature -FilePath .\Remove-OldExports.ps1 -Certificate $cert `
    -TimestampServer 'http://timestamp.digicert.com' -HashAlgorithm SHA256

Znacznik czasu sprawia, że podpis pozostaje ważny po wygaśnięciu certyfikatu. Certyfikat wystawcy musi być w magazynie zaufanych wydawców na serwerach, na przykład dystrybuowany przez GPO; inaczej przy AllSigned PowerShell pyta o zgodę, a w trybie -NonInteractive kończy się błędem.

Sprawdź u siebie

Jak zakończyło się ostatnie uruchomienie zadań spoza katalogu Microsoft:

Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*' |
    Get-ScheduledTaskInfo |
    Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime

LastTaskResult równe 0 oznacza sukces, 267011 (0x41303) zadanie, które jeszcze się nie uruchomiło, a 267009 (0x41301) zadanie działające w tej chwili. Każda inna wartość to błąd do wyjaśnienia, a stare LastRunTime przy codziennym zadaniu oznacza, że harmonogram go nie uruchamia.

Na jakich kontach działają zadania:

Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*' |
    Select-Object TaskPath, TaskName,
        @{ n = 'Konto'; e = { $_.Principal.UserId } },
        @{ n = 'Logowanie'; e = { $_.Principal.LogonType } }

Dobry wynik to SYSTEM albo konta gMSA, rozpoznawalne po znaku $ na końcu. Konto osobowe z typem logowania Password oznacza hasło zapisane w zadaniu, które przestanie działać po zmianie hasła lub odejściu tej osoby.

Czy w skryptach są hasła wpisane w kod:

Get-ChildItem D:\Scripts -Filter *.ps1 -Recurse |
    Select-String -Pattern 'ConvertTo-SecureString\s.*-AsPlainText', '\$\w*(password|passwd|pwd|haslo)\w*\s*=\s*[''"]' |
    Select-Object Path, LineNumber, Line

Pusty wynik jest dobry. Każde trafienie to sekret do przeniesienia do gMSA lub magazynu sekretów i do zmiany, bo był już zapisany jawnie.

Czy skrypty są podpisane i jaka polityka obowiązuje na serwerze:

Get-AuthenticodeSignature -FilePath (Get-ChildItem D:\Scripts -Filter *.ps1 -Recurse).FullName |
    Where-Object Status -ne 'Valid' | Select-Object Path, Status

Get-ExecutionPolicy -List

Pierwsze polecenie nie powinno nic zwrócić. HashMismatch oznacza plik zmieniony po podpisaniu, NotSigned brak podpisu. W drugim najlepiej, gdy MachinePolicy ma wartość AllSigned lub RemoteSigned ustawioną przez GPO; Unrestricted lub Bypass w zakresie LocalMachine to sygnał do poprawy.