Скрипт PowerShell под задачу
Пишет скрипт PowerShell с обработкой ошибок, логированием и режимом пробного прогона — так, чтобы его можно было запустить на рабочей машине и не бояться последствий.
Пример запуска
Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.
Значения переменных
- Задача
- Найти в каталоге лог-файлы (*.log) старше 30 дней, перенести их в архивную папку D:\AppLogs\Archive с подпапкой по дате запуска, а затем удалить из исходного каталога только те файлы, которые успешно скопированы в архив. В конце вывести отчёт: сколько файлов обработано, суммарный освобождённый объём в МБ, сколько файлов пропущено (заблокированы или младше 30 дней)
- Окружение
- Windows Server 2019, файловый сервер FS-PROD-01, целевой каталог D:\AppLogs\OrderService (логи внутреннего сервиса обработки заказов)
- Версия powershell
- Windows PowerShell 5.1
- Способ запуска
- По расписанию через Планировщик заданий Windows, ежедневно в 03:00; иногда запускается вручную администратором для внепланового прогона
- Права
- Сервисная учётная запись svc-logcleanup, локальный администратор на FS-PROD-01, без прав в домене и без доступа к другим серверам
- Ограничения
- Нельзя трогать подкаталог D:\AppLogs\OrderService\Audit — там хранятся аудиторские логи сроком 1 год по требованию службы безопасности. Нельзя удалять файлы младше 30 дней. Нельзя трогать файлы, которые в момент запуска заблокированы процессом (открыты на запись) — их нужно пропускать с пометкой в лог, а не завершать скрипт с ошибкой.
Архивация старых логов OrderService (PowerShell 5.1)
1. Как я понял задачу
Суть. В D:\AppLogs\OrderService найти *.log старше 30 дней и перенести их в D:\AppLogs\Archive\<дата запуска>\. Перенос идёт по схеме «копия, проверка, удаление оригинала». Оригинал удаляется только после того, как копия подтверждена. В конце скрипт выводит отчёт.
Неоднозначности и выбранные толкования:
| Вопрос | Решение | Почему |
|---|---|---|
| Смотреть ли подкаталоги? | Да, рекурсивно. Подкаталоги из ExcludePath (по умолчанию Audit) не обходятся вообще. |
Раз в постановке упомянут Audit, вложенные каталоги существуют. Структура подкаталогов сохраняется в архиве, иначе одноимённые файлы из разных папок столкнутся. |
| Что значит «старше 30 дней»? | LastWriteTime раньше, чем «момент запуска минус 30 суток». |
CreationTime ненадёжен: он меняется при копировании и восстановлении из бэкапа. Для лога важна дата последней записи. |
| Что считать «успешно скопирован»? | Размер и SHA256 копии совпадают с источником. | Само по себе отсутствие исключения при копировании не гарантирует целостность копии. |
| Подпапка по дате | yyyy-MM-dd, дата фиксируется в момент старта. |
Если прогон перейдёт через полночь, файлы не разъедутся по двум папкам. |
| Два запуска в один день (плановый и ручной) | Одна папка. При совпадении имени к копии добавляется суффикс, существующие файлы не перезаписываются. | Архив нельзя молча перезаписывать. |
| Что значит «пропущено»? | В отчёте отдельно: младше порога, заблокированы, только для чтения, прочее (исчезли, ссылки). | Одно общее число бесполезно для разбора инцидента. |
| Файлы с атрибутом «только чтение» | Пропускаются с предупреждением, а не удаляются через -Force. |
Атрибут часто ставят намеренно, чтобы защитить файл. |
Важное замечание про «освобождённый объём». Источник и архив лежат на одном томе D:. Поэтому перенос освобождает место в каталоге сервиса, но не на диске. В отчёте указан объём, ушедший из исходного каталога. Если цель — освободить диск, архив должен жить на другом томе или сжиматься. Это отдельная задача.
Крайние случаи:
- Входных данных нет. Если файлов нет или все свежие, это нормальная ситуация: код 0, отчёт с нулями.
- Исходного каталога нет. Это не «нет данных», а сломанная конфигурация: код 2, чтобы мониторинг заметил.
- Файлов неожиданно много (например, по ошибке указан
D:\). Стоит предохранитель-MaxFiles(по умолчанию 50 000): при превышении скрипт останавливается до любых действий. Корень диска какSourcePathзапрещён. - Не хватает места. Проверяется до начала работы. Если диск переполнится в процессе, работа прерывается, а недописанная копия удаляется.
- Файл заблокирован. Пропускается с
WARN, скрипт продолжает работу. Файл будет обработан в следующем прогоне. - Файл заблокирован уже после копирования, в момент удаления. Оригинал остаётся, копия в архиве тоже. Это безопасный дубликат.
- Параллельный запуск (ручной в 03:00 поверх планового). Работу блокирует именованный mutex, второй экземпляр выходит с кодом 3.
- Symlink или junction внутри каталога. Не обходятся: ссылка может вести куда угодно, в том числе в
Audit. - Особенность шаблона
*.logв Windows. Он совпадает и с*.log1,*.logold(наследие имён 8.3). Поэтому расширение сверяется точно.
2. Скрипт
<#
.SYNOPSIS
Переносит лог-файлы старше заданного срока в архив с проверкой копии и удаляет оригиналы.
.DESCRIPTION
Рекурсивно обходит SourcePath и находит файлы с расширением Extension (по умолчанию .log),
у которых LastWriteTime старше RetentionDays дней. Найденные файлы переносятся в
ArchiveRoot\<yyyy-MM-dd>\<относительный путь внутри SourcePath>.
Перенос = копирование -> проверка размера и SHA256 -> удаление оригинала.
Оригинал удаляется ТОЛЬКО если копия совпала с источником.
Каталоги из ExcludePath не обходятся вообще (по умолчанию ...\OrderService\Audit).
Файлы, открытые другим процессом на запись, пропускаются с предупреждением.
Файлы «только для чтения» и точки повторной обработки (symlink/junction) пропускаются.
ПО УМОЛЧАНИЮ ВЫПОЛНЯЕТСЯ ПРОБНЫЙ ПРОГОН: скрипт ничего не копирует и не удаляет,
только пишет в лог, что сделал бы. Реальные действия — только с ключом -Execute.
.PARAMETER SourcePath
Абсолютный путь к каталогу с логами. Корень диска запрещён.
.PARAMETER ArchiveRoot
Абсолютный путь к корню архива. Внутри создаётся подпапка с датой запуска.
.PARAMETER ExcludePath
Абсолютные пути каталогов, которые нельзя обходить и трогать.
.PARAMETER RetentionDays
Возраст файла в днях (по LastWriteTime), начиная с которого он переносится.
Минимум 30: ограничение зашито в валидацию, чтобы опечаткой нельзя было задеть свежие логи.
.PARAMETER Extension
Расширение файлов с точкой. Сравнивается точно, без учёта регистра.
.PARAMETER LogDirectory
Каталог для логов самого скрипта. Создаётся и в пробном прогоне.
.PARAMETER MaxFiles
Предохранитель: если кандидатов больше, скрипт останавливается, ничего не сделав.
.PARAMETER MinFreeSpaceMB
Сколько МБ должно остаться свободным на томе архива после копирования.
.PARAMETER Execute
Выполнить реальный перенос. Без этого ключа — только пробный прогон.
.EXAMPLE
.\Invoke-LogArchive.ps1
Пробный прогон с параметрами по умолчанию: показывает, что будет перенесено.
.EXAMPLE
.\Invoke-LogArchive.ps1 -Execute
Реальный перенос.
.EXAMPLE
.\Invoke-LogArchive.ps1 -SourcePath 'D:\Test\OrderService' -ArchiveRoot 'D:\Test\Archive' -ExcludePath 'D:\Test\OrderService\Audit' -Execute
Прогон на тестовой копии каталога.
.EXAMPLE
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-LogArchive.ps1" -Execute
Строка для Планировщика заданий. Используется именно -File: только так код завершения доходит до планировщика.
.NOTES
Коды завершения:
0 — успех (в т.ч. файлов нет, часть пропущена как свежие/заблокированные);
1 — частичный успех: ошибки по отдельным файлам или сбой записи в лог-файл;
2 — критическая ошибка, работа прервана;
3 — другой экземпляр уже обрабатывает этот каталог.
#>
#Requires -Version 5.1
[CmdletBinding()]
param(
[ValidateScript({ [System.IO.Path]::IsPathRooted($_) })]
[string]$SourcePath = 'D:\AppLogs\OrderService',
[ValidateScript({ [System.IO.Path]::IsPathRooted($_) })]
[string]$ArchiveRoot = 'D:\AppLogs\Archive',
[ValidateScript({ [System.IO.Path]::IsPathRooted($_) })]
[string[]]$ExcludePath = @('D:\AppLogs\OrderService\Audit'),
# Нижняя граница 30 — это требование, а не значение по умолчанию.
[ValidateRange(30, 36500)]
[int]$RetentionDays = 30,
[ValidatePattern('^\.[A-Za-z0-9_]+$')]
[string]$Extension = '.log',
[ValidateScript({ [System.IO.Path]::IsPathRooted($_) })]
[string]$LogDirectory = 'D:\AppLogs\Archive\_ScriptLogs',
[ValidateRange(1, 1000000)]
[int]$MaxFiles = 50000,
[ValidateRange(0, 1048576)]
[int]$MinFreeSpaceMB = 1024,
[switch]$Execute
)
# Stop делает любую ошибку командлета прерывающей: каждая ошибка либо осознанно
# обработана в try/catch, либо останавливает скрипт. Молча пролететь она не может.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$EXIT_OK = 0
$EXIT_PARTIAL = 1
$EXIT_FATAL = 2
$EXIT_ALREADY_RUNNING = 3
# Время старта фиксируется один раз: от него считаются порог возраста и имя папки архива.
$script:RunStart = Get-Date
$script:ExitCode = $EXIT_OK
$script:LogFile = $null
$script:LogWriteFailed = $false
$script:Mutex = $null
$script:MutexAcquired = $false
$script:Stats = @{
Scanned = 0
Candidates = 0
Processed = 0
FreedBytes = [long]0
SkippedYoung = 0
SkippedLocked = 0
SkippedReadOnly = 0
SkippedOther = 0
Errors = 0
}
#region Логирование
function Write-Log {
param(
[Parameter(Mandatory)][string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR', 'PLAN')][string]$Level = 'INFO'
)
$line = '{0} [{1,-5}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Level, $Message
$color = switch ($Level) { 'WARN' { 'Yellow' } 'ERROR' { 'Red' } 'PLAN' { 'Cyan' } default { 'Gray' } }
Write-Host $line -ForegroundColor $color
if ($script:LogFile) {
try {
Add-Content -LiteralPath $script:LogFile -Value $line -Encoding UTF8
}
catch {
# Сбой записи лога посреди работы не повод бросать файлы на полпути,
# но итоговый код уже не может быть «чистым» нулём: лог неполный.
if (-not $script:LogWriteFailed) {
Write-Host "Не удалось записать в лог-файл '$($script:LogFile)': $($_.Exception.Message)" -ForegroundColor Red
}
$script:LogWriteFailed = $true
}
}
}
function Initialize-Log {
$mode = if ($Execute) { 'EXECUTE' } else { 'DRYRUN' }
$logDir = [System.IO.Path]::GetFullPath($LogDirectory).TrimEnd('\')
[System.IO.Directory]::CreateDirectory($logDir) | Out-Null # идемпотентно, без подстановочных знаков
$file = Join-Path $logDir ('LogArchive_{0:yyyyMMdd_HHmmss}_{1}.log' -f $script:RunStart, $mode)
# Пробная запись НЕ перехватывается: запуск по расписанию без лога «слепой»,
# поэтому лучше упасть сразу с кодом 2, чем работать без следа.
Add-Content -LiteralPath $file -Value "# Лог запуска $($script:RunStart.ToString('yyyy-MM-dd HH:mm:ss'))" -Encoding UTF8
$script:LogFile = $file
}
#endregion
#region Классификация ошибок
# Исключения .NET-методов PowerShell заворачивает в MethodInvocationException.
# GetBaseException() достаёт исходное исключение, а младшие 16 бит HResult дают код Win32.
function Get-Win32Code {
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
return ($ErrorRecord.Exception.GetBaseException().HResult -band 0xFFFF)
}
function Test-IsSharingViolation {
# 32 = ERROR_SHARING_VIOLATION, 33 = ERROR_LOCK_VIOLATION: файл занят другим процессом.
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$base = $ErrorRecord.Exception.GetBaseException()
return (($base -is [System.IO.IOException]) -and ((Get-Win32Code $ErrorRecord) -in 32, 33))
}
function Test-IsDiskFull {
# 112 = ERROR_DISK_FULL, 39 = ERROR_HANDLE_DISK_FULL.
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$base = $ErrorRecord.Exception.GetBaseException()
return (($base -is [System.IO.IOException]) -and ((Get-Win32Code $ErrorRecord) -in 112, 39))
}
function Test-IsNotFound {
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$base = $ErrorRecord.Exception.GetBaseException()
return (($base -is [System.IO.FileNotFoundException]) -or ($base -is [System.IO.DirectoryNotFoundException]))
}
#endregion
#region Работа с путями и файлами
function Test-IsUnderPath {
# Сравнение по префиксу с разделителем: иначе 'D:\Logs\Audit2' считался бы внутри 'D:\Logs\Audit'.
param([string]$Path, [string]$Parent)
return ($Path.Equals($Parent, [System.StringComparison]::OrdinalIgnoreCase) -or
$Path.StartsWith($Parent + '\', [System.StringComparison]::OrdinalIgnoreCase))
}
function Test-IsExcluded {
param([string]$Path, [string[]]$Excluded)
foreach ($ex in $Excluded) {
if (Test-IsUnderPath -Path $Path -Parent $ex) { return $true }
}
return $false
}
function Get-LogFileTree {
# Собственный обход вместо Get-ChildItem -Recurse:
# - в исключённые каталоги (Audit) мы даже не заходим;
# - junction/symlink не проходим: в PS 5.1 -Recurse может пойти по ссылке,
# а ссылка может вести куда угодно, включая Audit;
# - ошибку доступа к одному подкаталогу логируем и идём дальше, а не теряем весь обход.
param([string]$Root, [string[]]$Excluded, [string]$Ext)
$stack = New-Object 'System.Collections.Generic.Stack[string]'
$stack.Push($Root)
while ($stack.Count -gt 0) {
$dir = $stack.Pop()
try {
$items = @(Get-ChildItem -LiteralPath $dir -Force)
}
catch {
Write-Log -Level ERROR -Message "Не удалось прочитать каталог '$dir': $($_.Exception.Message)"
$script:Stats.Errors += 1
continue
}
foreach ($item in $items) {
if ($item.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint)) {
Write-Log -Level WARN -Message "Пропуск ссылки (symlink/junction): '$($item.FullName)'"
if (-not $item.PSIsContainer) { $script:Stats.SkippedOther += 1 }
continue
}
if ($item.PSIsContainer) {
if (Test-IsExcluded -Path $item.FullName -Excluded $Excluded) {
Write-Log -Message "Каталог исключён, не обходится: '$($item.FullName)'"
continue
}
$stack.Push($item.FullName)
}
elseif ($item.Extension -eq $Ext) {
# Точное сравнение расширения: -Filter '*.log' в Windows совпадает и с '*.log1'.
$item
}
}
}
}
function Get-UniqueDestination {
# В архиве ничего не перезаписываем: при совпадении имени добавляем суффикс.
param([string]$Path, [datetime]$Stamp)
if (-not (Test-Path -LiteralPath $Path)) { return $Path }
$dir = [System.IO.Path]::GetDirectoryName($Path)
$base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
$ext = [System.IO.Path]::GetExtension($Path)
$i = 1
do {
$candidate = Join-Path $dir ('{0}_{1:HHmmss}_{2}{3}' -f $base, $Stamp, $i, $ext)
$i++
} while (Test-Path -LiteralPath $candidate)
return $candidate
}
function Test-FileLock {
# Для пробного прогона: открываем на чтение с FileShare.Read, то есть запрещаем чужую запись.
# Если кто-то уже держит файл открытым на запись, получим sharing violation.
# Операция только читающая, файл не меняется.
param([string]$Path)
$fs = $null
try {
$fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
return 'Free'
}
catch {
if (Test-IsSharingViolation $_) { return 'Locked' }
if (Test-IsNotFound $_) { return 'Gone' }
throw
}
finally {
if ($fs) { $fs.Dispose() }
}
}
function Remove-PartialCopy {
param([string]$Path)
try {
if (Test-Path -LiteralPath $Path) { [System.IO.File]::Delete($Path) }
}
catch {
Write-Log -Level WARN -Message "Не удалось удалить неполную копию '$Path' — удалите вручную: $($_.Exception.Message)"
}
}
function Copy-FileVerified {
# Возвращает 'Copied' | 'Locked' | 'Gone'. Прочие проблемы — исключение.
# Источник открывается с FileShare.Read и держится открытым на всё время копирования
# и хеширования. Никто не сможет дописать в него посередине, и хеш источника
# гарантированно относится к тем же байтам, что ушли в копию.
param([System.IO.FileInfo]$Source, [string]$Destination)
$in = $null; $out = $null
$destCreated = $false
$srcHash = $null; $srcLength = [long]-1
try {
try {
$in = [System.IO.File]::Open($Source.FullName, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
}
catch {
if (Test-IsSharingViolation $_) { return 'Locked' }
if (Test-IsNotFound $_) { return 'Gone' }
throw
}
# CreateNew: если файл с таким именем внезапно появился, получим ошибку, а не перезапись.
$out = [System.IO.File]::Open($Destination, [System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
$destCreated = $true
$in.CopyTo($out)
$out.Flush($true) # true = сбросить буферы ОС на диск до проверки
$out.Dispose(); $out = $null
$in.Position = 0
$sha = [System.Security.Cryptography.SHA256]::Create()
try { $srcHash = [System.BitConverter]::ToString($sha.ComputeHash($in)) -replace '-', '' }
finally { $sha.Dispose() }
$srcLength = $in.Length
}
catch {
if ($out) { $out.Dispose(); $out = $null }
if ($destCreated) { Remove-PartialCopy -Path $Destination }
throw
}
finally {
if ($out) { $out.Dispose() }
if ($in) { $in.Dispose() }
}
# Копию читаем заново с диска, а не доверяем тому, что только что записали.
$destLength = (New-Object System.IO.FileInfo($Destination)).Length
$destHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash
if ($destLength -ne $srcLength -or $destHash -ne $srcHash) {
Remove-PartialCopy -Path $Destination
throw "Проверка копии не прошла (размер $srcLength/$destLength, SHA256 $srcHash/$destHash)"
}
# Потоковое копирование не переносит даты. Восстанавливаем их: по LastWriteTime
# потом ищут нужный период и работает будущая очистка архива.
[System.IO.File]::SetCreationTime($Destination, $Source.CreationTime)
[System.IO.File]::SetLastWriteTime($Destination, $Source.LastWriteTime)
return 'Copied'
}
#endregion
#region Основная логика
function Invoke-Main {
$mode = if ($Execute) { 'РЕАЛЬНОЕ ВЫПОЛНЕНИЕ' } else { 'ПРОБНЫЙ ПРОГОН (ничего не меняется)' }
Write-Log -Message "=== Старт. Режим: $mode ==="
Write-Log -Message ("Учётная запись: {0}; хост: {1}; PID: {2}" -f
[System.Security.Principal.WindowsIdentity]::GetCurrent().Name, $env:COMPUTERNAME, $PID)
# --- Валидация путей: до любых действий с файлами ---
$srcRaw = [System.IO.Path]::GetFullPath($SourcePath)
if (-not [System.IO.Path]::GetDirectoryName($srcRaw)) {
throw "SourcePath '$srcRaw' — корень диска. Это почти наверняка ошибка конфигурации."
}
$src = $srcRaw.TrimEnd('\')
if (-not (Test-Path -LiteralPath $src -PathType Container)) {
# Отсутствие каталога — не «нет данных», а сломанная конфигурация или окружение.
throw "Исходный каталог '$src' не найден или недоступен."
}
$archiveRoot = [System.IO.Path]::GetFullPath($ArchiveRoot).TrimEnd('\')
if (Test-IsUnderPath -Path $src -Parent $archiveRoot) {
throw "Исходный каталог '$src' находится внутри архива '$archiveRoot'."
}
$excluded = New-Object 'System.Collections.Generic.List[string]'
foreach ($p in $ExcludePath) {
$full = [System.IO.Path]::GetFullPath($p).TrimEnd('\')
$excluded.Add($full)
if ((Test-IsUnderPath -Path $full -Parent $src) -and -not (Test-Path -LiteralPath $full -PathType Container)) {
# Не останавливаемся: исключение по пути работает и без каталога.
# Но если Audit переименовали, его файлы стали бы кандидатами — об этом надо кричать.
Write-Log -Level WARN -Message "Исключаемый каталог '$full' не существует. Проверьте, не переименован ли он!"
}
}
# Архив и логи скрипта, если вдруг лежат внутри источника, тоже исключаем:
# иначе скрипт начнёт архивировать собственный архив и свои логи.
if (Test-IsUnderPath -Path $archiveRoot -Parent $src) { $excluded.Add($archiveRoot) }
$logDir = Split-Path -Path $script:LogFile -Parent
if (Test-IsUnderPath -Path $logDir -Parent $src) { $excluded.Add($logDir) }
$cutoff = $script:RunStart.AddDays(-$RetentionDays)
$dateFolder = Join-Path $archiveRoot $script:RunStart.ToString('yyyy-MM-dd')
Write-Log -Message "Источник: '$src'"
Write-Log -Message "Архив: '$dateFolder'"
Write-Log -Message ("Исключения: {0}" -f (($excluded | ForEach-Object { "'$_'" }) -join ', '))
Write-Log -Message ("Порог: {0}; переносятся файлы {1} с LastWriteTime раньше {2:yyyy-MM-dd HH:mm:ss}" -f
"$RetentionDays дн.", $Extension, $cutoff)
# --- Защита от параллельного запуска (плановый + ручной) ---
$mutexName = 'Global\LogArchive_' + ($src -replace '[^A-Za-z0-9]', '_')
$script:Mutex = New-Object System.Threading.Mutex -ArgumentList $false, $mutexName
try {
$script:MutexAcquired = $script:Mutex.WaitOne(0)
}
catch {
# AbandonedMutexException: предыдущий экземпляр умер, не освободив mutex.
# Владение при этом всё равно получено, продолжать можно.
if ($_.Exception.GetBaseException() -is [System.Threading.AbandonedMutexException]) {
$script:MutexAcquired = $true
Write-Log -Level WARN -Message 'Предыдущий запуск завершился аварийно (брошенный mutex). Продолжаем.'
}
else { throw }
}
if (-not $script:MutexAcquired) {
Write-Log -Level ERROR -Message 'Другой экземпляр уже обрабатывает этот каталог. Выход без действий.'
$script:ExitCode = $EXIT_ALREADY_RUNNING
return
}
# --- Поиск ---
$allFiles = @(Get-LogFileTree -Root $src -Excluded $excluded.ToArray() -Ext $Extension)
$candidates = @($allFiles | Where-Object { $_.LastWriteTime -lt $cutoff })
$script:Stats.Scanned = $allFiles.Count
$script:Stats.Candidates = $candidates.Count
# Свежие файлы считаем, но не логируем поштучно: их большинство, лог утонул бы в шуме.
$script:Stats.SkippedYoung = $allFiles.Count - $candidates.Count
Write-Log -Message ("Найдено файлов {0}: {1}; старше порога: {2}" -f $Extension, $allFiles.Count, $candidates.Count)
if ($candidates.Count -eq 0) {
Write-Log -Message 'Переносить нечего.'
return
}
if ($candidates.Count -gt $MaxFiles) {
throw ("Кандидатов {0} — больше предохранителя MaxFiles={1}. Проверьте SourcePath; " +
"если объём ожидаемый, запустите с увеличенным -MaxFiles.") -f $candidates.Count, $MaxFiles
}
# --- Проверка места ---
$measure = $candidates | Measure-Object -Property Length -Sum -Maximum
$totalBytes = [long]$measure.Sum
$maxBytes = [long]$measure.Maximum
$srcRoot = [System.IO.Path]::GetPathRoot($src)
$arcRoot = [System.IO.Path]::GetPathRoot($archiveRoot)
# На одном томе файлы удаляются по ходу работы, и одновременно требуется место
# только под самый большой файл. На другом томе нужен весь объём.
$needBytes = $(if ($srcRoot -eq $arcRoot) { $maxBytes } else { $totalBytes }) + [long]$MinFreeSpaceMB * 1MB
$freeBytes = (New-Object System.IO.DriveInfo($arcRoot)).AvailableFreeSpace
Write-Log -Message ("Объём кандидатов: {0:N2} МБ; нужно свободного места: {1:N2} МБ; доступно на {2}: {3:N2} МБ" -f
($totalBytes / 1MB), ($needBytes / 1MB), $arcRoot, ($freeBytes / 1MB))
if ($freeBytes -lt $needBytes) {
if ($Execute) { throw 'Недостаточно свободного места на томе архива. Ничего не перенесено.' }
Write-Log -Level WARN -Message 'Недостаточно места: реальный запуск будет остановлен на этой проверке.'
}
if ($Execute) {
[System.IO.Directory]::CreateDirectory($dateFolder) | Out-Null
}
# --- Обработка ---
$excludedArr = $excluded.ToArray()
$i = 0
foreach ($file in $candidates) {
$i++
if ($i % 500 -eq 0) { Write-Log -Message "Прогресс: $i из $($candidates.Count)" }
# Повторная защитная проверка прямо перед действием: даже если в обходе
# когда-нибудь появится ошибка, файл из Audit или вне источника тронут не будет.
if ((Test-IsExcluded -Path $file.FullName -Excluded $excludedArr) -or
-not (Test-IsUnderPath -Path $file.FullName -Parent $src)) {
Write-Log -Level ERROR -Message "ЗАЩИТА: файл вне разрешённой области, пропущен: '$($file.FullName)'"
$script:Stats.Errors += 1
continue
}
# С момента обхода файл мог измениться или исчезнуть: перечитываем атрибуты.
$file.Refresh()
if (-not $file.Exists) {
Write-Log -Message "Файл исчез до обработки: '$($file.FullName)'"
$script:Stats.SkippedOther += 1
continue
}
if ($file.LastWriteTime -ge $cutoff) {
Write-Log -Level WARN -Message "Файл изменился после обхода и теперь младше порога, пропущен: '$($file.FullName)'"
$script:Stats.SkippedYoung += 1
continue
}
if ($file.Attributes.HasFlag([System.IO.FileAttributes]::ReadOnly)) {
Write-Log -Level WARN -Message "Атрибут «только чтение», пропущен как защищённый: '$($file.FullName)'"
$script:Stats.SkippedReadOnly += 1
continue
}
$relative = $file.FullName.Substring($src.Length).TrimStart('\')
$destPath = Get-UniqueDestination -Path (Join-Path $dateFolder $relative) -Stamp $script:RunStart
$sizeMB = [math]::Round($file.Length / 1MB, 2)
# --- Пробный прогон: только чтение ---
if (-not $Execute) {
try {
$state = Test-FileLock -Path $file.FullName
}
catch {
Write-Log -Level ERROR -Message "Не удалось проверить '$($file.FullName)': $($_.Exception.Message)"
$script:Stats.Errors += 1
continue
}
if ($state -eq 'Locked') {
Write-Log -Level WARN -Message "Заблокирован процессом, был бы пропущен: '$($file.FullName)'"
$script:Stats.SkippedLocked += 1
}
elseif ($state -eq 'Gone') {
Write-Log -Message "Файл исчез: '$($file.FullName)'"
$script:Stats.SkippedOther += 1
}
else {
Write-Log -Level PLAN -Message "Был бы перенесён ($sizeMB МБ): '$($file.FullName)' -> '$destPath'"
$script:Stats.Processed += 1
$script:Stats.FreedBytes += $file.Length
}
continue
}
# --- Реальное выполнение: шаг 1, копирование с проверкой ---
try {
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($destPath)) | Out-Null
$result = Copy-FileVerified -Source $file -Destination $destPath
}
catch {
if (Test-IsDiskFull $_) {
# Нет места — это уже не проблема одного файла: следующие упадут так же.
throw "Диск переполнен при копировании '$($file.FullName)'. Работа остановлена: $($_.Exception.Message)"
}
Write-Log -Level ERROR -Message "Ошибка копирования '$($file.FullName)': $($_.Exception.Message). Оригинал не тронут."
$script:Stats.Errors += 1
continue
}
if ($result -eq 'Locked') {
Write-Log -Level WARN -Message "Заблокирован процессом, пропущен: '$($file.FullName)'"
$script:Stats.SkippedLocked += 1
continue
}
if ($result -eq 'Gone') {
Write-Log -Message "Файл исчез до копирования: '$($file.FullName)'"
$script:Stats.SkippedOther += 1
continue
}
# --- Шаг 2: удаление оригинала, только после подтверждённой копии ---
try {
# [IO.File]::Delete вместо Remove-Item: предсказуемые типы исключений и HResult,
# и он не удаляет read-only файлы (в отличие от Remove-Item -Force).
[System.IO.File]::Delete($file.FullName)
$script:Stats.Processed += 1
$script:Stats.FreedBytes += $file.Length
Write-Log -Message "Перенесён ($sizeMB МБ): '$($file.FullName)' -> '$destPath'"
}
catch {
if (Test-IsSharingViolation $_) {
Write-Log -Level WARN -Message "Скопирован, но при удалении оказался заблокирован. Оригинал оставлен, копия в архиве: '$destPath'"
$script:Stats.SkippedLocked += 1
}
else {
Write-Log -Level ERROR -Message "Не удалось удалить оригинал '$($file.FullName)' (копия в архиве: '$destPath'): $($_.Exception.Message)"
$script:Stats.Errors += 1
}
}
}
}
function Write-Report {
$s = $script:Stats
$skipped = $s.SkippedYoung + $s.SkippedLocked + $s.SkippedReadOnly + $s.SkippedOther
$duration = ((Get-Date) - $script:RunStart).ToString('hh\:mm\:ss')
$prefix = if ($Execute) { '' } else { '[ПРОБНЫЙ ПРОГОН] ' }
$doneWord = if ($Execute) { 'Перенесено в архив' } else { 'Было бы перенесено' }
$freeWord = if ($Execute) { 'Освобождено в исходном каталоге' } else { 'Освободилось бы в исходном каталоге' }
Write-Log -Message "==================== ${prefix}ОТЧЁТ ===================="
Write-Log -Message ("Найдено файлов (вне исключений): {0}" -f $s.Scanned)
Write-Log -Message (" из них старше порога: {0}" -f $s.Candidates)
Write-Log -Message ("{0,-37} {1}" -f "${doneWord}:", $s.Processed)
Write-Log -Message ("{0,-37} {1:N2} МБ" -f "${freeWord}:", ($s.FreedBytes / 1MB))
Write-Log -Message ("Пропущено всего: {0}" -f $skipped)
Write-Log -Message (" младше порога: {0}" -f $s.SkippedYoung)
Write-Log -Message (" заблокированы процессом: {0}" -f $s.SkippedLocked)
Write-Log -Message (" только для чтения: {0}" -f $s.SkippedReadOnly)
Write-Log -Message (" прочее (исчезли, ссылки): {0}" -f $s.SkippedOther)
Write-Log -Message ("Ошибок: {0}" -f $s.Errors)
Write-Log -Message ("Длительность: {0}; код завершения: {1}" -f $duration, $script:ExitCode)
if ($script:LogFile) { Write-Log -Message "Лог: $($script:LogFile)" }
}
#endregion
#region Точка входа
try {
Initialize-Log
Invoke-Main
}
catch {
$script:ExitCode = $EXIT_FATAL
Write-Log -Level ERROR -Message "КРИТИЧЕСКАЯ ОШИБКА, работа прервана: $($_.Exception.Message)"
Write-Log -Level ERROR -Message "Место: $($_.InvocationInfo.PositionMessage)"
}
finally {
if ($script:MutexAcquired) { $script:Mutex.ReleaseMutex() }
if ($script:Mutex) { $script:Mutex.Dispose() }
if ($script:ExitCode -eq $EXIT_OK -and ($script:Stats.Errors -gt 0 -or $script:LogWriteFailed)) {
$script:ExitCode = $EXIT_PARTIAL
}
Write-Report
}
exit $script:ExitCode
#endregion
3. Ключевые решения
Пробный прогон по умолчанию через -Execute, а не через -WhatIf. У SupportsShouldProcess поведение по умолчанию — действовать, а -WhatIf нужно не забыть указать. Здесь наоборот: чтобы что-то удалить, нужно явно написать -Execute. Пробный прогон при этом не просто перечисляет файлы, он проверяет блокировку. Отчёт получается близким к реальному.
Копирование через потоки, а не через Copy-Item. Источник открыт с FileShare.Read на всё время копирования и хеширования. У этого три следствия:
- Если сервис держит файл открытым на запись, открытие сразу падает с sharing violation. Это и есть детектор «заблокирован». Отдельная проверка «открыть и закрыть» оставляла бы окно гонки между проверкой и копированием.
- Пока идёт копирование, в файл никто не может дописать.
- Хеш источника считается по тем же байтам, что ушли в копию.
Цена такого подхода: даты и атрибуты не переносятся сами. Даты восстанавливаются явно, ACL копии наследуется от папки архива (см. предупреждения).
Три класса ситуаций:
- Ожидаемые, обработка продолжается, код остаётся 0. Файл заблокирован, файл исчез, файл свежий, файл только для чтения, файлов нет вообще.
- Ошибки отдельного файла, обработка продолжается, код 1. Нет доступа, копия не прошла проверку, не удалось удалить оригинал. Оригинал в этих случаях не трогается.
- Критические сбои, остановка, код 2. Нет исходного каталога, некуда писать лог, не хватает места, диск переполнился в процессе, путь похож на ошибку конфигурации, кандидатов больше
MaxFiles. Здесь продолжать бессмысленно или опасно.
Классификация идёт по коду Win32 из HResult, а не по тексту сообщения. Текст на локализованной ОС будет русским и может поменяться.
Защита Audit в три слоя. Первый: обход в этот каталог не заходит. Второй: перед каждым действием повторно проверяется, что файл не в исключениях и лежит внутри источника. Третий: если каталог Audit пропал, в лог пишется громкое предупреждение. Если его переименуют, например в Audit_2026, его файлы станут кандидатами. От этого скрипт защитить не может, отсюда и предупреждение.
Защита от «младше 30 дней» в три слоя. ValidateRange(30, …) не даёт передать меньший порог. Сравнение идёт с моментом старта. Непосредственно перед действием выполняется Refresh() и повторная проверка даты.
[IO.File]::Delete вместо Remove-Item. Он даёт предсказуемые исключения с HResult. Кроме того, я не уверен, что тип и HResult исключения Remove-Item при блокировке одинаковы во всех сценариях провайдера FileSystem. Поэтому я выбрал путь, в поведении которого уверен.
Логирование. Write-Host плюс Add-Content, временная метка с миллисекундами, отдельный файл на каждый запуск, режим в имени файла. Так пробный и реальный прогоны не перепутать. Свежие файлы не логируются поштучно, иначе лог утонет в шуме.
Коды завершения: 0, 1, 2, 3. Их различает Планировщик («Результат последнего запуска») и любая система мониторинга.
4. Предупреждения и чек-лист
Что делается необратимо
- Удаление оригиналов из
D:\AppLogs\OrderService. Удаляются только файлы с подтверждённой по SHA256 копией в архиве, поэтому восстановить их можно, скопировав обратно. Но исходный каталог после этого уже не такой, каким был. - Копии в архиве получают ACL папки архива, а не ACL оригинала. Логи обработки заказов могут содержать персональные данные. Права на
D:\AppLogs\Archiveдолжны быть не шире, чем на исходный каталог. - Альтернативные потоки NTFS и атрибуты не переносятся, переносятся только содержимое и даты. Для логов это обычно неважно. Если на файлах есть
Zone.Identifierили свои метаданные, учтите это. - Архив и логи скрипта никто не чистит. Они будут расти бесконечно. Ротацию архива нужно решать отдельно.
Какие права нужны и почему
D:\AppLogs\OrderService: чтение и листинг (обход), удаление файлов (перенос).D:\AppLogs\Archive: создание папок и запись файлов, изменение атрибутов (восстановление дат).LogDirectory: создание папки и запись.- Сеть и домен не нужны.
Локальный администратор — это больше, чем требуется. Достаточно права Modify на три каталога. Если ACL каталогов даёт доступ только группе Administrators, задачу нужно запускать с флагом «Выполнить с наивысшими правами». Без повышения токен администратора урезан UAC, и скрипт получит «доступ запрещён».
Учётной записи svc-logcleanup нужно право «Вход в качестве пакетного задания», его обычно выдаёт сам Планировщик при сохранении задачи.
Чек-лист перед первым запуском на FS-PROD-01
- Пробный прогон от имени сервисной учётки, а не от своей:
runas /user:<домен_или_хост>\svc-logcleanup powershell.exe, затем.\Invoke-LogArchive.ps1. Так проверяются реальные права. - В логе пробного прогона проверьте:
- есть строка «Каталог исключён, не обходится: '...\Audit'»;
- ни одна строка
PLANне содержит\Audit\; - число кандидатов правдоподобно;
- нет строк
ERROR.
- Проверьте детектор блокировок. Возьмите текущий лог, который пишет сервис (он свежий, поэтому скрипт его не тронет), и проверьте вручную:
[IO.File]::Open('<путь>', 'Open', 'Read', 'Read')должен упасть с «used by another process». Если сервис открывает файл на запись только на время записи строки, блокировка видна не всегда. Для файлов старше 30 дней это не критично, но знать стоит. - Реальный прогон сначала на копии:
-SourcePath D:\Test\OrderService -ArchiveRoot D:\Test\Archive -ExcludePath D:\Test\OrderService\Audit -Execute. Затем сверьте содержимое, даты иGet-FileHashнескольких файлов. - Убедитесь, что на
D:\AppLogs\OrderServiceнет файлов с атрибутом «только чтение», которые на самом деле нужно архивировать. Иначе они будут пропускаться каждый день. - Задача в Планировщике:
- Программа:
powershell.exe. - Аргументы:
-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-LogArchive.ps1" -Execute. Именно-File: с-Commandкод завершения теряется. Если политика требует подписи, подпишите скрипт вместоBypass. - Включите «Выполнять вне зависимости от регистрации пользователя» и, при необходимости, «С наивысшими правами».
- В параметрах укажите «Не запускать новый экземпляр» (в дополнение к mutex) и разумное ограничение времени выполнения.
- Программа:
- Положите скрипт в каталог, куда
svc-logcleanupи обычные пользователи не могут писать. Скрипт, который выполняется с правами администратора и доступен на запись, — готовая дыра. - Настройте алерт мониторинга на ненулевой код задачи. Код 1 означает, что нужно посмотреть лог. Код 2 или 3 — что нужно разбираться.
- Пути длиннее 260 символов. PowerShell 5.1 на Server 2019 без включённого
LongPathsEnabledна них упадёт. Такие файлы попадут вERROR, а их оригиналы не будут тронуты. Проверьте, нет ли в OrderService глубокой вложенности. - Антивирус. Если он сканирует файл в момент копирования, файл будет помечен как заблокированный и обработан на следующий день. Это нормально, но при систематических пропусках стоит проверить исключения AV для
D:\AppLogs.
Похожие промпты
Ревью и рефакторинг PowerShell-скрипта
Разбирает существующий скрипт PowerShell: что сломается на реальных данных, где ошибки глушатся вместо обработки и что переписать в первую очередь, а что оставить.
Диагностика проблем VPN-подключения
Методично диагностирует проблемы VPN: соединение не устанавливается, скорость падает, утекает DNS — по одному шагу, с командами под вашу ОС.
Помощник по настройке WireGuard и OpenVPN
Пошагово разворачивает собственный VPN-сервер на WireGuard или OpenVPN: готовые команды и конфиги, firewall, клиентские профили и проверка утечек.
Консультант по выбору VPN-решения
Помогает выбрать VPN под задачу: сравнивает коммерческие сервисы и self-hosted решения с учётом страны, устройств, модели угроз и бюджета.