Deployment Artikel

Copy-WithProgress fuer robuste Dateikopien im Deployment.

Dateien zu kopieren klingt simpel, wird im Deployment aber schnell heikel: Quelle und Ziel duerfen nicht ineinander liegen, unveraenderte Dateien sollten uebersprungen werden, Logs muessen sauber mitlaufen und am Ende braucht man ein belastbares Ergebnisobjekt fuer weitere Entscheidungen.

Warum die Funktion stark ist

Sie verbindet Validierung, Logging, Fortschrittsmarken, Vergleichslogik, Zeitstempelpflege und ein rueckgabefaehiges Ergebnisobjekt in einer einzigen Funktion. Genau das ist fuer Packaging und Deployment deutlich wertvoller als ein nacktes Copy-Item.

Wofuer sie sich eignet

Beispielsweise fuer das Verteilen von Konfigurationsdateien, Nachkopieren von Herstellerdateien, Caching lokaler Quellen, Nacharbeiten in PSADT oder das gezielte Kopieren von Zusatzdateien fuer Detection und Betrieb.

Kernidee

Was die Funktion fachlich sauber abdeckt

Pfad-Schutz

Quelle und Ziel werden auf Identitaet und geschachtelte Verzeichnisse geprueft. Das verhindert die typischen Katastrophenfaelle, in denen ein Ziel in die Quelle oder umgekehrt zeigt.

Kopierplan

Vor dem eigentlichen Kopieren wird ein Plan gebaut. Damit ist vorher klar, welche Dateien kopiert und welche uebersprungen werden sollen.

Vergleichslogik

Mit OnlyIfChanged und optionalem Hashvergleich lassen sich unnoetige Kopien vermeiden, ohne auf Logging oder Ergebnisobjekte zu verzichten.

Rueckgabeobjekt

Am Ende kommt nicht nur ein Erfolgscode, sondern eine strukturierte Zusammenfassung mit Bytes, Dauer, Warnungen und uebersprungenen Dateien. Das ist fuer Deployment-Entscheidungen Gold wert.

PowerShell
function Copy-WithProgress {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CopySource,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CopyDestination,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$LogFile,

        [switch]$OnlyIfChanged,

        [switch]$UseHashComparison,

        [ValidateSet('SHA256', 'SHA1', 'MD5')]
        [string]$HashAlgorithm = 'SHA256'
    )

    function Convert-BytesToReadable {
        param([Parameter(Mandatory)][Int64]$Bytes)

        if ($Bytes -ge 1TB) { return ('{0:N2} TB' -f ($Bytes / 1TB)) }
        elseif ($Bytes -ge 1GB) { return ('{0:N2} GB' -f ($Bytes / 1GB)) }
        elseif ($Bytes -ge 1MB) { return ('{0:N2} MB' -f ($Bytes / 1MB)) }
        elseif ($Bytes -ge 1KB) { return ('{0:N2} KB' -f ($Bytes / 1KB)) }
        else { return "$Bytes B" }
    }

    function Get-FileHashSafe {
        param(
            [Parameter(Mandatory)][string]$Path,
            [Parameter(Mandatory)][string]$Algorithm
        )

        try {
            return (Get-FileHash -LiteralPath $Path -Algorithm $Algorithm -ErrorAction Stop).Hash
        }
        catch {
            throw "Hash konnte fuer Datei '$Path' nicht berechnet werden. $($_.Exception.Message)"
        }
    }

    function Set-FileLastWriteTimeUtcSafe {
        param(
            [Parameter(Mandatory)][string]$Path,
            [Parameter(Mandatory)][datetime]$LastWriteTimeUtc
        )

        try {
            if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
                throw "Datei '$Path' wurde nicht gefunden."
            }

            $file = Get-Item -LiteralPath $Path -Force -ErrorAction Stop

            if ($file.IsReadOnly) {
                $file.IsReadOnly = $false
            }

            [System.IO.File]::SetLastWriteTimeUtc($Path, $LastWriteTimeUtc)
        }
        catch {
            throw "Zeitstempel fuer '$Path' konnte nicht gesetzt werden. $($_.Exception.Message)"
        }
    }

    function Test-PathsAreNested {
        param(
            [Parameter(Mandatory)][string]$ParentPath,
            [Parameter(Mandatory)][string]$ChildPath
        )

        return $ChildPath.StartsWith($ParentPath + '\', [System.StringComparison]::OrdinalIgnoreCase)
    }

    $component = 'Copy-WithProgress'
    $startTime = Get-Date
    $filesScanned = 0
    $filesCopied = 0
    $filesSkipped = 0
    $bytesTotal = 0L
    $bytesCopied = 0L
    $hashComparisons = 0
    $timestampSetFailures = 0
    $skippedFiles = @()
    $timestampWarnings = @()
    $progressThresholds = [System.Collections.Generic.List[int]]::new()
    @(10, 25, 50, 75, 90, 100) | ForEach-Object { [void]$progressThresholds.Add($_) }

    try {
        Write-Log -Path $LogFile -Message "Starte Kopiervorgang. Quelle='$CopySource' Ziel='$CopyDestination' OnlyIfChanged='$OnlyIfChanged' UseHashComparison='$UseHashComparison' HashAlgorithm='$HashAlgorithm'" -Component $component -Type Info

        if (-not (Test-Path -LiteralPath $CopySource -PathType Container)) {
            throw "Das Quellverzeichnis '$CopySource' wurde nicht gefunden."
        }

        if (-not (Test-Path -LiteralPath $CopyDestination)) {
            New-Item -Path $CopyDestination -ItemType Directory -Force -ErrorAction Stop | Out-Null
            Write-Log -Path $LogFile -Message "Zielverzeichnis wurde erstellt: '$CopyDestination'" -Component $component -Type Info
        }

        $sourceRoot = (Resolve-Path -LiteralPath $CopySource -ErrorAction Stop).Path.TrimEnd('\')
        $destRoot = (Resolve-Path -LiteralPath $CopyDestination -ErrorAction Stop).Path.TrimEnd('\')

        if ($sourceRoot -ieq $destRoot) {
            throw "Quell- und Zielverzeichnis duerfen nicht identisch sein."
        }

        if (Test-PathsAreNested -ParentPath $sourceRoot -ChildPath $destRoot) {
            throw "Das Zielverzeichnis darf nicht innerhalb des Quellverzeichnisses liegen."
        }

        if (Test-PathsAreNested -ParentPath $destRoot -ChildPath $sourceRoot) {
            throw "Das Quellverzeichnis darf nicht innerhalb des Zielverzeichnisses liegen."
        }

        $allFiles = Get-ChildItem -LiteralPath $sourceRoot -Recurse -File -Force -ErrorAction Stop
        $filesScanned = $allFiles.Count

        Write-Log -Path $LogFile -Message "Scan abgeschlossen. Gefundene Dateien: $filesScanned" -Component $component -Type Info

        $copyPlan = foreach ($file in $allFiles) {
            $relativePath = $file.FullName.Substring($sourceRoot.Length).TrimStart('\')
            $destinationPath = Join-Path -Path $destRoot -ChildPath $relativePath
            $shouldCopy = $true
            $skipReason = $null
            $sourceHash = $null
            $destinationHash = $null

            if ($OnlyIfChanged -and (Test-Path -LiteralPath $destinationPath -PathType Leaf)) {
                $destFile = Get-Item -LiteralPath $destinationPath -Force -ErrorAction Stop
                $sameLength = ($file.Length -eq $destFile.Length)
                $sameWriteTime = ($file.LastWriteTimeUtc -eq $destFile.LastWriteTimeUtc)

                if ($UseHashComparison) {
                    if ($sameLength) {
                        $sourceHash = Get-FileHashSafe -Path $file.FullName -Algorithm $HashAlgorithm
                        $destinationHash = Get-FileHashSafe -Path $destinationPath -Algorithm $HashAlgorithm
                        $hashComparisons++

                        if ($sourceHash -eq $destinationHash) {
                            $shouldCopy = $false
                            $skipReason = "Unveraendert (Hash identisch: $HashAlgorithm)"
                        }
                    }
                }
                else {
                    if ($sameLength -and $sameWriteTime) {
                        $shouldCopy = $false
                        $skipReason = 'Unveraendert (Groesse und LastWriteTimeUtc identisch)'
                    }
                }
            }

            [pscustomobject]@{
                SourcePath = $file.FullName
                RelativePath = $relativePath
                DestinationPath = $destinationPath
                Length = [int64]$file.Length
                LengthReadable = Convert-BytesToReadable -Bytes ([int64]$file.Length)
                LastWriteTimeUtc = $file.LastWriteTimeUtc
                SourceHash = $sourceHash
                DestinationHash = $destinationHash
                ShouldCopy = $shouldCopy
                SkipReason = $skipReason
            }
        }

        $plannedFiles = @($copyPlan | Where-Object { $_.ShouldCopy })
        $skippedFiles = @($copyPlan | Where-Object { -not $_.ShouldCopy })
        $filesSkipped = $skippedFiles.Count
        $bytesTotal = ($plannedFiles | Measure-Object -Property Length -Sum).Sum
        if (-not $bytesTotal) { $bytesTotal = 0L }

        Write-Log -Path $LogFile -Message "Kopierplan erstellt. Zu kopieren: $($plannedFiles.Count) Dateien / $((Convert-BytesToReadable -Bytes $bytesTotal)). Uebersprungen: $filesSkipped Dateien." -Component $component -Type Info

        foreach ($entry in $plannedFiles) {
            $destinationFolder = Split-Path -Path $entry.DestinationPath -Parent

            if (-not (Test-Path -LiteralPath $destinationFolder)) {
                New-Item -Path $destinationFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
            }

            $percentComplete = if ($bytesTotal -gt 0) {
                [math]::Min([math]::Round(($bytesCopied / $bytesTotal) * 100, 2), 100)
            } else {
                100
            }

            while ($progressThresholds.Count -gt 0 -and $percentComplete -ge $progressThresholds[0]) {
                $currentThreshold = $progressThresholds[0]
                Write-Log -Path $LogFile -Message "Kopiervorgang erreicht $currentThreshold Prozent." -Component $component -Type Info
                $progressThresholds.RemoveAt(0)
            }

            if ($PSCmdlet.ShouldProcess($entry.SourcePath, "Kopieren nach '$($entry.DestinationPath)'")) {
                Copy-Item -LiteralPath $entry.SourcePath -Destination $entry.DestinationPath -Force -ErrorAction Stop

                try {
                    Set-FileLastWriteTimeUtcSafe -Path $entry.DestinationPath -LastWriteTimeUtc $entry.LastWriteTimeUtc
                }
                catch {
                    $timestampSetFailures++
                    $timestampWarnings += [pscustomobject]@{
                        RelativePath = $entry.RelativePath
                        DestinationPath = $entry.DestinationPath
                        LastWriteTimeUtc = $entry.LastWriteTimeUtc
                        Warning = $_.Exception.Message
                    }

                    Write-Log -Path $LogFile -Message "Zeitstempel konnte nicht gesetzt werden fuer '$($entry.RelativePath)'. $($_.Exception.Message)" -Component $component -Type Warning
                }

                $filesCopied++
                $bytesCopied += $entry.Length
            }
        }

        $duration = (Get-Date) - $startTime

        return [pscustomobject]@{
            Source = $sourceRoot
            Destination = $destRoot
            OnlyIfChanged = [bool]$OnlyIfChanged
            UseHashComparison = [bool]$UseHashComparison
            HashAlgorithm = $HashAlgorithm
            HashComparisons = $hashComparisons
            FilesScanned = $filesScanned
            FilesCopied = $filesCopied
            FilesSkipped = $filesSkipped
            TotalBytesPlanned = [int64]$bytesTotal
            TotalBytesPlannedReadable = Convert-BytesToReadable -Bytes ([int64]$bytesTotal)
            BytesCopied = [int64]$bytesCopied
            BytesCopiedReadable = Convert-BytesToReadable -Bytes ([int64]$bytesCopied)
            DurationSeconds = [math]::Round($duration.TotalSeconds, 2)
            TimestampSetFailures = $timestampSetFailures
            TimestampWarnings = $timestampWarnings
            Success = $true
            Message = 'Kopiervorgang erfolgreich abgeschlossen.'
            SkippedFiles = $skippedFiles
        }
    }
    catch {
        $duration = (Get-Date) - $startTime

        Write-Log -Path $LogFile -Message "Fehler beim Kopiervorgang: $($_.Exception.Message)" -Component $component -Type Error

        return [pscustomobject]@{
            Source = $CopySource
            Destination = $CopyDestination
            OnlyIfChanged = [bool]$OnlyIfChanged
            UseHashComparison = [bool]$UseHashComparison
            HashAlgorithm = $HashAlgorithm
            HashComparisons = $hashComparisons
            FilesScanned = $filesScanned
            FilesCopied = $filesCopied
            FilesSkipped = $filesSkipped
            TotalBytesPlanned = [int64]$bytesTotal
            TotalBytesPlannedReadable = Convert-BytesToReadable -Bytes ([int64]$bytesTotal)
            BytesCopied = [int64]$bytesCopied
            BytesCopiedReadable = Convert-BytesToReadable -Bytes ([int64]$bytesCopied)
            DurationSeconds = [math]::Round($duration.TotalSeconds, 2)
            TimestampSetFailures = $timestampSetFailures
            TimestampWarnings = $timestampWarnings
            Success = $false
            Message = $_.Exception.Message
            SkippedFiles = $skippedFiles
        }
    }
}

$result = Copy-WithProgress `
  -CopySource 'C:\Temp\Payload' `
  -CopyDestination 'C:\ProgramData\Company\App' `
  -LogFile 'C:\Logs\Packaging\copy.log' `
  -OnlyIfChanged `
  -UseHashComparison `
  -HashAlgorithm SHA256

if (-not $result.Success) {
  throw $result.Message
}

Wichtige Voraussetzung

Die Funktion nutzt Write-Log als bestehende Loggingbasis. Wer nur diese Seite liest, sollte das nicht erraten muessen. Ohne Loggingfunktion fehlt der bewusst eingeplante Betriebsrahmen.

Write-Log Artikel lesen

Warum der Volltext hier sinnvoll ist

Diese Seite ist jetzt auch fuer Leser nutzbar, die direkt ueber Suche oder Verlinkung einsteigen. Sie sehen den kompletten Baustein, nicht nur einen isolierten Aufruf.

Besonders gut geloest

Die Funktion denkt nicht nur an den Kopiervorgang selbst, sondern auch an Fortschrittslogging, Zeitstempelpflege und die Rueckmeldung an den aufrufenden Deployment-Code.

Worauf Leser achten sollten

Die Funktion setzt auf eine vorhandene Write-Log-Basis auf. Damit passt sie besonders gut in eine Umgebung, in der Logging ohnehin strukturiert und konsistent behandelt wird.

Naechster Schritt

Wer die Funktion wirklich beurteilen will, sollte als Naechstes die Vergleichslogik und die Frage lesen, wann Zeitstempel reichen und wann Hashes sinnvoll sind.

Vergleichsartikel lesen

Deployment-Praxis

Danach lohnt sich der Blick auf den Einsatz in PSADT, MEM oder reinen Deploymentskripten mit sauberem Fehler- und Loggingfluss.

Deployment-Artikel lesen