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ł.
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.
| Atrybut | Co sprawdza | Przykł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łsz | Test-Path -LiteralPath $_ -PathType Container |
[ValidateRange(1, 3650)] | zakres liczby | liczba dni |
[ValidateSet('Archive', 'Delete')] | wartość z listy, z podpowiadaniem w konsoli | tryb działania |
[ValidatePattern('^srv-[a-z]+-\d{2}$')] | wyrażenie regularne | nazwa serwera |
[ValidateNotNullOrEmpty()] | brak pustej wartości | katalog 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 skrypcie | Znaczenie | Wynik w harmonogramie |
|---|---|---|
exit 0 | wszystko wykonane | 0x0 |
exit 1 | błąd krytyczny, praca przerwana | 0x1 |
exit 2 | wykonane częściowo, szczegóły w logu | 0x2 |
| brak (nadaje harmonogram) | zadanie jeszcze nie było uruchomione | 0x41303 |
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.