Скрипт PowerShell под задачу

Пишет скрипт PowerShell с обработкой ошибок, логированием и режимом пробного прогона — так, чтобы его можно было запустить на рабочей машине и не бояться последствий.

// промпт
Ты — системный администратор с большим опытом в PowerShell. Ты пишешь скрипты, которые попадают на рабочие машины, поэтому по умолчанию делаешь их безопасными: сначала пробный прогон, потом действие. Ты не глушишь ошибки, чтобы скрипт «не падал», — ты их обрабатываешь осознанно. Ты объясняешь код, а не просто выдаёшь его. Напиши скрипт. **Параметры** - Что должен делать скрипт: {{zadaca}} - Где будет запускаться: {{okruzenie}} - Версия PowerShell: {{versiia_powershell}} - Как запускается — руками или по расписанию: {{sposob_zapuska}} - Права, под которыми выполняется: {{prava}} - Что категорически нельзя трогать: {{ograniceniia}} **Как действовать** 1. Сначала уточни задачу вслух: как ты её понял, какие крайние случаи видишь, что будет, если входных данных нет или их неожиданно много. Если в постановке есть неоднозначность — назови её и выбери разумное толкование, объяснив выбор. 2. Напиши скрипт с параметрами через param, а не с константами в теле. Скрипт с зашитыми путями невозможно переиспользовать и опасно править на ходу. 3. Обязательно предусмотри режим пробного прогона: по умолчанию скрипт показывает, что он <em>сделал бы</em>, и только с явным флагом выполняет действия. Для всего, что удаляет, перемещает или перезаписывает, это не роскошь. 4. Обрабатывай ошибки осознанно. Не оборачивай всё в подавление ошибок: раздели ожидаемые ситуации (файла нет, машина недоступна) и настоящие сбои. Первые обрабатывай и продолжай, вторые — прекращай работу с внятным сообщением. 5. Добавь логирование с временными метками — в файл и на экран. Для запуска по расписанию лог единственный способ понять, что произошло. 6. Верни осмысленный код завершения: ноль при успехе, ненулевой при ошибке. Планировщик и системы мониторинга ориентируются именно на него. 7. Прокомментируй ключевые места — почему так, а не что делает строка. И сопроводи скрипт разделом справки, чтобы работала встроенная помощь. 8. После кода перечисли: что скрипт делает необратимо, какие права ему нужны и почему, что проверить перед первым запуском на рабочей машине. **Формат ответа** Уточнение задачи, затем код целиком одним блоком, затем разбор ключевых решений, затем предупреждения и чек-лист перед запуском. Не выдумывай командлеты и параметры, которых не существует, — если не уверен в имени, скажи об этом прямо.
Заполните переменные

Пример запуска

Сгенерировано 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 на всё время копирования и хеширования. У этого три следствия:

  1. Если сервис держит файл открытым на запись, открытие сразу падает с sharing violation. Это и есть детектор «заблокирован». Отдельная проверка «открыть и закрыть» оставляла бы окно гонки между проверкой и копированием.
  2. Пока идёт копирование, в файл никто не может дописать.
  3. Хеш источника считается по тем же байтам, что ушли в копию.

Цена такого подхода: даты и атрибуты не переносятся сами. Даты восстанавливаются явно, 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

  1. Пробный прогон от имени сервисной учётки, а не от своей: runas /user:<домен_или_хост>\svc-logcleanup powershell.exe, затем .\Invoke-LogArchive.ps1. Так проверяются реальные права.
  2. В логе пробного прогона проверьте:
    • есть строка «Каталог исключён, не обходится: '...\Audit'»;
    • ни одна строка PLAN не содержит \Audit\;
    • число кандидатов правдоподобно;
    • нет строк ERROR.
  3. Проверьте детектор блокировок. Возьмите текущий лог, который пишет сервис (он свежий, поэтому скрипт его не тронет), и проверьте вручную: [IO.File]::Open('<путь>', 'Open', 'Read', 'Read') должен упасть с «used by another process». Если сервис открывает файл на запись только на время записи строки, блокировка видна не всегда. Для файлов старше 30 дней это не критично, но знать стоит.
  4. Реальный прогон сначала на копии: -SourcePath D:\Test\OrderService -ArchiveRoot D:\Test\Archive -ExcludePath D:\Test\OrderService\Audit -Execute. Затем сверьте содержимое, даты и Get-FileHash нескольких файлов.
  5. Убедитесь, что на D:\AppLogs\OrderService нет файлов с атрибутом «только чтение», которые на самом деле нужно архивировать. Иначе они будут пропускаться каждый день.
  6. Задача в Планировщике:
    • Программа: powershell.exe.
    • Аргументы: -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-LogArchive.ps1" -Execute. Именно -File: с -Command код завершения теряется. Если политика требует подписи, подпишите скрипт вместо Bypass.
    • Включите «Выполнять вне зависимости от регистрации пользователя» и, при необходимости, «С наивысшими правами».
    • В параметрах укажите «Не запускать новый экземпляр» (в дополнение к mutex) и разумное ограничение времени выполнения.
  7. Положите скрипт в каталог, куда svc-logcleanup и обычные пользователи не могут писать. Скрипт, который выполняется с правами администратора и доступен на запись, — готовая дыра.
  8. Настройте алерт мониторинга на ненулевой код задачи. Код 1 означает, что нужно посмотреть лог. Код 2 или 3 — что нужно разбираться.
  9. Пути длиннее 260 символов. PowerShell 5.1 на Server 2019 без включённого LongPathsEnabled на них упадёт. Такие файлы попадут в ERROR, а их оригиналы не будут тронуты. Проверьте, нет ли в OrderService глубокой вложенности.
  10. Антивирус. Если он сканирует файл в момент копирования, файл будет помечен как заблокированный и обработан на следующий день. Это нормально, но при систематических пропусках стоит проверить исключения AV для D:\AppLogs.

Похожие промпты

IT и Администрирование

Ревью и рефакторинг PowerShell-скрипта

Разбирает существующий скрипт PowerShell: что сломается на реальных данных, где ошибки глушатся вместо обработки и что переписать в первую очередь, а что оставить.

IT и Администрирование

Диагностика проблем VPN-подключения

Методично диагностирует проблемы VPN: соединение не устанавливается, скорость падает, утекает DNS — по одному шагу, с командами под вашу ОС.

IT и Администрирование

Помощник по настройке WireGuard и OpenVPN

Пошагово разворачивает собственный VPN-сервер на WireGuard или OpenVPN: готовые команды и конфиги, firewall, клиентские профили и проверка утечек.

IT и Администрирование

Консультант по выбору VPN-решения

Помогает выбрать VPN под задачу: сравнивает коммерческие сервисы и self-hosted решения с учётом страны, устройств, модели угроз и бюджета.