Public/Invoke-sqmTsmConfiguration.ps1

<#
.SYNOPSIS
    Configures the IBM Spectrum Protect (TSM) client options file dsm.opt
    for use with SQL Server backup directories.
 
.DESCRIPTION
    Reads the existing dsm.opt, adds or replaces the relevant entries,
    and writes the file back. Before each change a backup copy (dsm.opt.bak)
    is automatically created.
 
    Configured sections (within the managed block):
    - EXCLUDE patterns (default: SQL Server *.mdf/*.ndf/*.ldf; override via -ExcludePatterns)
    - INCLUDE directories with per-path management class (default: User-db/Sys-db;
      override via -IncludeRule for arbitrary path -> management class mappings)
 
    Target file: by default the dsm.opt itself. Real environments often outsource
    INCLUDE/EXCLUDE statements into a separate file referenced via the INCLEXCL option
    (e.g. ie_dsm.opt), processed BEFORE the dsm.opt. Use -UseInclExclFile to auto-resolve
    that file from the dsm.opt's INCLEXCL option, or -InclExclPath to target it explicitly.
    The target file is created if it does not yet exist (for INCLEXCL files).
 
    When -UseDiff is set, the management class is forced to
    MC_B_NL.NL_42.42.NA (42-day retention).
 
    The managed block is delimited by the markers
    '* --- dtcSqlTools BEGIN ---' and '* --- dtcSqlTools END ---'.
    Manual entries outside this block are preserved. NOTE: if the vendor's TSM
    configurator regenerates the file, our block may be overwritten - re-run as needed.
 
.PARAMETER ComputerName
    Target computer (TSM client). Default: current computer name.
 
.PARAMETER SqlInstance
    SQL Server instance used to determine the backup directory.
    Default: $ComputerName.
 
.PARAMETER DsmOptPath
    Full path to the dsm.opt on the target computer.
    Determined automatically when not specified.
 
.PARAMETER BackupDirectory
    Base backup directory. The subdirectories \User-db and \Sys-db
    are added as INCLUDE entries.
    Default: read from the SQL instance (BackupDirectory property).
 
.PARAMETER AdditionalIncludePaths
    Additional directories to be added as INCLUDE entries.
 
.PARAMETER ManagementClass
    Default TSM management class for the backup includes (used when an include
    rule does not specify its own class). Accepts any MC_* name (validated by
    pattern, not a fixed set) so real-world classes such as MC_B_2.2_15.15.NA_IMG
    or MC_B_NL_NL_365.365.NA are allowed.
    Default: MC_B_NL.NL_42.42.NA.
 
.PARAMETER ExcludePatterns
    Custom EXCLUDE patterns (without the EXCLUDE keyword/quotes), e.g.
    @('S:\...\*', '*:\...\*.ldf'). When omitted, defaults to the three SQL
    database file types (*.ldf, *.mdf, *.ndf).
 
.PARAMETER IncludeRule
    Array of hashtables @{ Path = '...'; ManagementClass = '...' } to bind a
    management class to a specific include path. Path is taken verbatim (include
    the pattern, e.g. 'F:\Daten\SQL\Backup\...\*'). ManagementClass is optional
    and falls back to -ManagementClass. When omitted, the classic User-db/Sys-db
    model (plus -AdditionalIncludePaths) is used with the default class.
 
.PARAMETER UseInclExclFile
    Resolve the INCLEXCL option from the dsm.opt and write the managed block into
    that referenced include/exclude file instead of the dsm.opt itself.
 
.PARAMETER InclExclPath
    Explicit path to the include/exclude file to write into (overrides INCLEXCL
    resolution and the dsm.opt target).
 
.PARAMETER UseDiff
    When set, forces the management class to MC_B_NL.NL_42.42.NA
    (required for diff backup strategy).
 
.PARAMETER SqlCredential
    PSCredential for the SQL connection (to read the backup directory).
 
.PARAMETER Credential
    PSCredential for remote file access (Copy-Item, Test-Path) on the target computer.
 
.PARAMETER OutputPath
    Output directory for the configuration report.
    Default: Get-sqmDefaultOutputPath.
 
.PARAMETER ContinueOnError
    Continue on error (not applicable here as there is no loop).
 
.PARAMETER EnableException
    Throw exceptions immediately (instead of silent error objects).
 
.PARAMETER Confirm
    Request confirmation before writing the dsm.opt.
 
.PARAMETER WhatIf
    Shows what would happen without making any changes.
 
.EXAMPLE
    Invoke-sqmTsmConfiguration -ManagementClass MC_B_NL.NL_42.42.NA
 
.EXAMPLE
    Invoke-sqmTsmConfiguration -ComputerName "SQL01" -UseDiff
 
.EXAMPLE
    Invoke-sqmTsmConfiguration -ComputerName "SQL01" -AdditionalIncludePaths "E:\Archive"
 
.EXAMPLE
    # Write into the INCLEXCL-referenced ie file, with custom excludes and per-path classes
    Invoke-sqmTsmConfiguration -ComputerName "SQL01" -UseInclExclFile `
        -ExcludePatterns 'S:\...\*', '*:\...\*.ldf', '*:\...\*.mdf', '*:\...\*.ndf' `
        -IncludeRule @(
            @{ Path = 'F:\Daten\SQL\Backup\...\*'; ManagementClass = 'MC_B_NL.NL_35.35.NA' },
            @{ Path = 'F:\Daten\SQL\Backup\01Year\*'; ManagementClass = 'MC_B_NL_NL_365.365.NA' }
        )
 
.OUTPUTS
    PSCustomObject with ComputerName, DsmOptPath, TargetFile, BackupDirectory,
    ManagementClass, UseDiff, ExcludesWritten, IncludesWritten,
    BackupCreated, Status, Message, ReportPath.
#>

function Invoke-sqmTsmConfiguration
{
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $false)]
        [string]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory = $false)]
        [string]$SqlInstance,
        [Parameter(Mandatory = $false)]
        [string]$DsmOptPath,
        [Parameter(Mandatory = $false)]
        [string]$BackupDirectory,
        [Parameter(Mandatory = $false)]
        [string[]]$AdditionalIncludePaths = @(),
        [Parameter(Mandatory = $false)]
        # Hinweis: ValidateSet wurde durch ValidatePattern ersetzt, da reale Umgebungen
        # weitere Klassen nutzen (z. B. MC_B_2.2_15.15.NA_IMG, MC_B_NL_NL_365.365.NA),
        # die das frühere 6-Werte-Set abgelehnt hätte. Alte gültige Aufrufe bleiben gültig.
        [ValidatePattern('^MC_[A-Za-z0-9._]+$')]
        [string]$ManagementClass = 'MC_B_NL.NL_42.42.NA',
        [Parameter(Mandatory = $false)]
        [string[]]$ExcludePatterns,
        [Parameter(Mandatory = $false)]
        [hashtable[]]$IncludeRule = @(),
        [Parameter(Mandatory = $false)]
        [switch]$UseInclExclFile,
        [Parameter(Mandatory = $false)]
        [string]$InclExclPath,
        [Parameter(Mandatory = $false)]
        [switch]$UseDiff,
        [Parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$SqlCredential,
        [Parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$Credential,
        [Parameter(Mandatory = $false)]
        [string]$OutputPath = (Get-sqmDefaultOutputPath),
        [Parameter(Mandatory = $false)]
        [switch]$ContinueOnError,
        [Parameter(Mandatory = $false)]
        [switch]$EnableException
    )
    
    begin
    {
        $functionName = $MyInvocation.MyCommand.Name
        if (-not (Get-Module -ListAvailable -Name dbatools))
        {
            $errMsg = "dbatools-Modul nicht gefunden."
            Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR"
            throw $errMsg
        }
        if (-not $SqlInstance) { $SqlInstance = $ComputerName }
        Invoke-sqmLogging -Message "Starte $functionName auf $ComputerName (SQL-Instanz: $SqlInstance)" -FunctionName $functionName -Level "INFO"
    }
    
    process
    {
        $result = [PSCustomObject]@{
            ComputerName    = $ComputerName
            DsmOptPath        = $null
            TargetFile        = $null
            BackupDirectory = $null
            ManagementClass = $null
            UseDiff            = [bool]$UseDiff
            ExcludesWritten = 0
            IncludesWritten = 0
            BackupCreated   = $false
            Status            = 'Unknown'
            Message            = $null
            ReportPath        = $null
        }
        
        try
        {
            # --- Diff-Validierung ---
            if ($UseDiff)
            {
                if ($ManagementClass -and $ManagementClass -ne 'MC_B_NL.NL_42.42.NA')
                {
                    $msg = "Bei -UseDiff ist MC_B_NL.NL_42.42.NA Pflicht. Angegebene Klasse '$ManagementClass' ist nicht zulaessig."
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'ValidationFailed'
                    $result.Message = $msg
                    return $result
                }
                $ManagementClass = 'MC_B_NL.NL_42.42.NA'
                Invoke-sqmLogging -Message "UseDiff: Management-Klasse auf $ManagementClass gesetzt." -FunctionName $functionName -Level "INFO"
            }
            $result.ManagementClass = $ManagementClass
            
            # --- Backup-Verzeichnis ermitteln ---
            $effBackupDir = $BackupDirectory
            if (-not $effBackupDir)
            {
                try
                {
                    $connParams = @{ SqlInstance = $SqlInstance }
                    if ($SqlCredential) { $connParams['SqlCredential'] = $SqlCredential }
                    $regResult = Invoke-DbaQuery @connParams -Query @"
DECLARE @BackupDirectory NVARCHAR(4000);
EXEC master.dbo.xp_instance_regread
    N'HKEY_LOCAL_MACHINE',
    N'SOFTWARE\Microsoft\MSSQLServer\MSSQLServer',
    N'BackupDirectory',
    @BackupDirectory OUTPUT;
SELECT @BackupDirectory AS BackupDirectory;
"@
 -ErrorAction Stop
                    $effBackupDir = $regResult.BackupDirectory
                }
                catch
                {
                    Invoke-sqmLogging -Message "SQL-Registry-Abfrage fehlgeschlagen: $($_.Exception.Message)" -FunctionName $functionName -Level "VERBOSE"
                }
                if (-not $effBackupDir)
                {
                    try
                    {
                        $srv = Connect-DbaInstance @connParams -ErrorAction SilentlyContinue
                        $effBackupDir = $srv.BackupDirectory
                    }
                    catch { }
                }
            }
            if (-not $effBackupDir)
            {
                $effBackupDir = 'C:\Program Files\Microsoft SQL Server\MSSQL\Backup'
                Invoke-sqmLogging -Message "Kein Backup-Verzeichnis ermittelbar - verwende Standard: $effBackupDir" -FunctionName $functionName -Level "WARNING"
            }
            $result.BackupDirectory = $effBackupDir
            Invoke-sqmLogging -Message "Backup-Verzeichnis: $effBackupDir" -FunctionName $functionName -Level "INFO"
            
            # --- dsm.opt-Pfad ermitteln (für Record + INCLEXCL-Auflösung) ---
            $isLocal = $ComputerName -in @($env:COMPUTERNAME, 'localhost', '127.0.0.1', '.')
            $localDsmOpt = $DsmOptPath
            if (-not $localDsmOpt)
            {
                $localDsmOpt = _FindDsmOptPath -ComputerName $ComputerName -IsLocal $isLocal -Credential $Credential
            }
            $result.DsmOptPath = $localDsmOpt

            # --- Zieldatei bestimmen: dsm.opt ODER ausgelagerte Include/Exclude-Datei ---
            # Reale Umgebungen lagern INCLUDE/EXCLUDE über die INCLEXCL-Option in eine separate
            # Datei (z. B. ie_dsm.opt) aus, die VOR der dsm.opt verarbeitet wird. Mit -InclExclPath
            # oder -UseInclExclFile schreiben wir dorthin statt in die dsm.opt.
            $targetLocal = $null
            $allowCreate = $false
            if ($InclExclPath)
            {
                $targetLocal = $InclExclPath
                $allowCreate = $true
                Invoke-sqmLogging -Message "Zieldatei explizit (InclExclPath): $targetLocal" -FunctionName $functionName -Level "INFO"
            }
            elseif ($UseInclExclFile)
            {
                if (-not $localDsmOpt)
                {
                    $msg = "dsm.opt nicht gefunden - INCLEXCL-Verweis nicht auflösbar. Bitte -DsmOptPath oder -InclExclPath angeben."
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'DsmOptNotFound'
                    $result.Message = $msg
                    return $result
                }
                $dsmAccess = if ($isLocal) { $localDsmOpt }
                else { _ToUncPath -ComputerName $ComputerName -LocalPath $localDsmOpt }
                $targetLocal = _ResolveInclExclPath -DsmOptAccessPath $dsmAccess
                if (-not $targetLocal)
                {
                    $msg = "Keine INCLEXCL-Option in dsm.opt gefunden ($dsmAccess). Bitte -InclExclPath explizit angeben."
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'InclExclNotFound'
                    $result.Message = $msg
                    return $result
                }
                $allowCreate = $true
                Invoke-sqmLogging -Message "INCLEXCL-Verweis aufgelöst: $targetLocal" -FunctionName $functionName -Level "INFO"
            }
            else
            {
                if (-not $localDsmOpt)
                {
                    $msg = "dsm.opt konnte nicht gefunden werden. Bitte -DsmOptPath explizit angeben."
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'DsmOptNotFound'
                    $result.Message = $msg
                    return $result
                }
                $targetLocal = $localDsmOpt
            }
            $result.TargetFile = $targetLocal

            $accessPath = if ($isLocal) { $targetLocal }
            else { _ToUncPath -ComputerName $ComputerName -LocalPath $targetLocal }
            Invoke-sqmLogging -Message "Zieldatei Zugriffspfad: $accessPath" -FunctionName $functionName -Level "VERBOSE"
            
            # --- Zieldatei lesen (bei ausgelagerter ie-Datei ggf. neu anlegen) ---
            $existingLines = [System.Collections.Generic.List[string]]::new()
            $targetExists = Test-Path -Path $accessPath -ErrorAction SilentlyContinue
            if ($targetExists)
            {
                try
                {
                    $rawLines = Get-Content -Path $accessPath -Encoding UTF8 -ErrorAction Stop
                    foreach ($l in $rawLines) { $existingLines.Add($l) }
                    Invoke-sqmLogging -Message "Zieldatei gelesen: $($existingLines.Count) Zeilen" -FunctionName $functionName -Level "INFO"
                }
                catch
                {
                    $msg = "Zieldatei konnte nicht gelesen werden: $($_.Exception.Message)"
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'ReadFailed'
                    $result.Message = $msg
                    return $result
                }
            }
            elseif ($allowCreate)
            {
                Invoke-sqmLogging -Message "Zieldatei existiert noch nicht - wird neu angelegt: $accessPath" -FunctionName $functionName -Level "INFO"
            }
            else
            {
                $msg = "dsm.opt nicht gefunden: $accessPath"
                Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                if ($EnableException) { throw $msg }
                $result.Status = 'DsmOptNotFound'
                $result.Message = $msg
                return $result
            }

            # --- Backup der Zieldatei (nur wenn vorhanden) ---
            if ($targetExists)
            {
                $bakPath = $accessPath + '.bak'
                try
                {
                    Copy-Item -Path $accessPath -Destination $bakPath -Force -ErrorAction Stop
                    $result.BackupCreated = $true
                    Invoke-sqmLogging -Message "Backup angelegt: $bakPath" -FunctionName $functionName -Level "INFO"
                }
                catch
                {
                    $msg = "Backup der Zieldatei fehlgeschlagen: $($_.Exception.Message)"
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'BackupFailed'
                    $result.Message = $msg
                    return $result
                }
            }
            
            # --- Exclude-Patterns vorbereiten ---
            # Default: die drei SQL-Datenbankdatei-Typen. Mit -ExcludePatterns überschreibbar
            # (eigene Patterns, z. B. 'S:\...\*'). Patterns ohne EXCLUDE-Schlüsselwort/Quotes.
            $effExcludePatterns = if ($PSBoundParameters.ContainsKey('ExcludePatterns') -and $ExcludePatterns)
            {
                $ExcludePatterns
            }
            else
            {
                @('*:\...\*.ldf', '*:\...\*.mdf', '*:\...\*.ndf')
            }

            # --- Include-Regeln vorbereiten ---
            # Mit -IncludeRule (@{ Path = '...'; ManagementClass = '...' }) lassen sich pro Pfad
            # eigene Managementklassen setzen (z. B. 365-Tage-Klasse für ein 01Year-Verzeichnis).
            # Path wird verbatim übernommen (inkl. Pattern wie \...\* ). Ohne -IncludeRule gilt
            # das klassische User-db/Sys-db-Modell mit der Default-$ManagementClass.
            $effIncludeRules = [System.Collections.Generic.List[object]]::new()
            if ($IncludeRule -and $IncludeRule.Count -gt 0)
            {
                foreach ($rule in $IncludeRule)
                {
                    $rPath = $rule['Path']
                    if (-not $rPath -or -not "$rPath".Trim()) { continue }
                    $rMc = $rule['ManagementClass']
                    if (-not $rMc -or -not "$rMc".Trim()) { $rMc = $ManagementClass }
                    $effIncludeRules.Add([PSCustomObject]@{ Path = "$rPath".Trim(); ManagementClass = "$rMc".Trim() })
                }
            }
            else
            {
                $effIncludeRules.Add([PSCustomObject]@{ Path = "$effBackupDir\User-db\*"; ManagementClass = $ManagementClass })
                $effIncludeRules.Add([PSCustomObject]@{ Path = "$effBackupDir\Sys-db\*"; ManagementClass = $ManagementClass })
                foreach ($p in $AdditionalIncludePaths)
                {
                    if ($p -and $p.Trim()) { $effIncludeRules.Add([PSCustomObject]@{ Path = ($p.TrimEnd('\') + '\*'); ManagementClass = $ManagementClass }) }
                }
            }

            # --- Verwaltungsblock erstellen ---
            $blockLines = [System.Collections.Generic.List[string]]::new()
            $blockLines.Add('')
            $blockLines.Add('* --- dtcSqlTools BEGIN ---')
            $blockLines.Add("* Konfiguriert von sqmSQLTool Invoke-sqmTsmConfiguration")
            $blockLines.Add("* Zeitpunkt: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
            $blockLines.Add("* Server: $ComputerName | SQL-Backup: $effBackupDir")
            $blockLines.Add("* UseDiff: $UseDiff | Default-ManagementClass: $ManagementClass")
            $blockLines.Add('*')
            $blockLines.Add('* Vom TSM-Backup ausschliessen:')
            foreach ($excl in $effExcludePatterns)
            {
                $p = "$excl".Trim()
                if (-not $p) { continue }
                $blockLines.Add("EXCLUDE `"$p`"")
                $result.ExcludesWritten++
            }
            $blockLines.Add('*')
            $blockLines.Add('* Backup-Verzeichnisse einschliessen (mit Management-Klasse):')
            foreach ($rule in $effIncludeRules)
            {
                $blockLines.Add("INCLUDE `"$($rule.Path)`" $($rule.ManagementClass)")
                $result.IncludesWritten++
            }
            $blockLines.Add('*')
            $blockLines.Add('* --- dtcSqlTools END ---')
            $blockLines.Add('')
            
            # --- Vorhandenen Block entfernen / ersetzen ---
            $beginMarker = '* --- dtcSqlTools BEGIN ---'
            $endMarker = '* --- dtcSqlTools END ---'
            $beginIdx = -1; $endIdx = -1
            for ($i = 0; $i -lt $existingLines.Count; $i++)
            {
                if ($existingLines[$i].Trim() -eq $beginMarker) { $beginIdx = $i }
                if ($existingLines[$i].Trim() -eq $endMarker) { $endIdx = $i }
            }
            
            $newLines = [System.Collections.Generic.List[string]]::new()
            if ($beginIdx -ge 0 -and $endIdx -gt $beginIdx)
            {
                for ($i = 0; $i -lt $beginIdx; $i++)
                {
                    if ($i -eq $beginIdx - 1 -and $existingLines[$i].Trim() -eq '') { continue }
                    $newLines.Add($existingLines[$i])
                }
                foreach ($l in $blockLines) { $newLines.Add($l) }
                for ($i = $endIdx + 1; $i -lt $existingLines.Count; $i++) { $newLines.Add($existingLines[$i]) }
                Invoke-sqmLogging -Message "Vorhandenen dtcSqlTools-Block ersetzt." -FunctionName $functionName -Level "INFO"
            }
            else
            {
                foreach ($l in $existingLines) { $newLines.Add($l) }
                foreach ($l in $blockLines) { $newLines.Add($l) }
                Invoke-sqmLogging -Message "dtcSqlTools-Block am Ende der Zieldatei eingefuegt." -FunctionName $functionName -Level "INFO"
            }
            
            # --- Schreiben ---
            if ($PSCmdlet.ShouldProcess($accessPath, "Zieldatei schreiben"))
            {
                try
                {
                    $newLines | Out-File -FilePath $accessPath -Encoding UTF8 -Force -ErrorAction Stop
                    Invoke-sqmLogging -Message "Zieldatei geschrieben: $($newLines.Count) Zeilen" -FunctionName $functionName -Level "INFO"
                    $result.Status = 'Success'
                    $result.Message = "Konfiguriert ($accessPath): $($result.ExcludesWritten) EXCLUDE(s), $($result.IncludesWritten) INCLUDE(s), Default-MgmtClass: $ManagementClass"
                }
                catch
                {
                    $msg = "Zieldatei konnte nicht geschrieben werden: $($_.Exception.Message)"
                    Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR"
                    if ($EnableException) { throw $msg }
                    $result.Status = 'WriteFailed'
                    $result.Message = $msg
                    return $result
                }
            }
            else
            {
                $result.Status = 'WhatIf'
                $result.Message = "WhatIf: Zieldatei wuerde geschrieben werden ($($newLines.Count) Zeilen)."
            }
            
            # --- Bericht schreiben (optional) ---
            if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }
            $datestamp = Get-Date -Format 'yyyy-MM-dd'
            $safeComp = $ComputerName -replace '[\\/:*?"<>|]', '_'
            $reportFile = Join-Path $OutputPath "TsmConfiguration_${safeComp}_${datestamp}.txt"
            $result.ReportPath = $reportFile
            $exclDisplay = ($effExcludePatterns | ForEach-Object { "EXCLUDE `"$_`"" }) -join "`n "
            $incDisplay = ($effIncludeRules | ForEach-Object { "INCLUDE `"$($_.Path)`" $($_.ManagementClass)" }) -join "`n "
            @"
# ================================================================
# sqmSQLTool - TSM Konfigurationsbericht
# Computer : $ComputerName
# Datum : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
# dsm.opt : $($result.DsmOptPath)
# Zieldatei : $accessPath
# Backup-Pfad : $effBackupDir
# Default-MgmtCls: $ManagementClass
# UseDiff : $UseDiff
# Status : $($result.Status)
# ================================================================
 
EXCLUDEs:
  $exclDisplay
 
INCLUDEs (mit ManagementClass):
  $incDisplay
"@
 | Out-File -FilePath $reportFile -Encoding UTF8 -Force
            Copy-sqmToCentralPath -Path $reportFile
            Invoke-sqmLogging -Message "Bericht erstellt: $reportFile" -FunctionName $functionName -Level "INFO"
            
        }
        catch
        {
            $errMsg = $_.Exception.Message
            Invoke-sqmLogging -Message "Allgemeiner Fehler: $errMsg" -FunctionName $functionName -Level "ERROR"
            if ($EnableException) { throw }
            $result.Status = 'Failed'
            $result.Message = $errMsg
        }
        return $result
    }
    
    end
    {
        Invoke-sqmLogging -Message "$functionName abgeschlossen." -FunctionName $functionName -Level "INFO"
    }
}

# --- Private Hilfsfunktionen (nicht exportiert) ---
function _FindDsmOptPath
{
    param (
        [string]$ComputerName = "localhost",
        [bool]$IsLocal = $true,
        [System.Management.Automation.PSCredential]$Credential
    )
    
    $candidates = [System.Collections.Generic.List[string]]::new()
    
    if ($IsLocal)
    {
        # Umgebungsvariablen pruefen
        if ($env:DSM_DIR) { $candidates.Add((Join-Path $env:DSM_DIR 'dsm.opt')) }
        if ($env:DSM_CONFIG) { $candidates.Add($env:DSM_CONFIG) }
        
        # Lokale Registry (64-Bit & 32-Bit)
        $regPaths = @(
            'HKLM:\SOFTWARE\IBM\ADSM\CurrentVersion\BackupClient',
            'HKLM:\SOFTWARE\WOW6432Node\IBM\ADSM\CurrentVersion\BackupClient'
        )
        foreach ($rp in $regPaths)
        {
            $val = (Get-ItemProperty $rp -Name 'DSM_DIR' -ErrorAction SilentlyContinue).DSM_DIR
            if ($val) { $candidates.Add((Join-Path $val 'dsm.opt')) }
        }
    }
    else
    {
        try
        {
            $hklm = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $ComputerName)
            $regKeys = @(
                'SOFTWARE\IBM\ADSM\CurrentVersion\BackupClient',
                'SOFTWARE\WOW6432Node\IBM\ADSM\CurrentVersion\BackupClient'
            )
            foreach ($keyPath in $regKeys)
            {
                $tsmKey = $hklm.OpenSubKey($keyPath)
                if ($tsmKey)
                {
                    $regDsmDir = $tsmKey.GetValue('DSM_DIR', '')
                    if ($regDsmDir) { $candidates.Add((Join-Path $regDsmDir 'dsm.opt')) }
                    $tsmKey.Close()
                }
            }
            $hklm.Close()
        }
        catch
        {
            Write-Warning "Remote Registry Zugriff auf $ComputerName fehlgeschlagen: $($_.Exception.Message)"
        }
    }
    
    # Standardpfade hinzufuegen
    $candidates.Add('C:\Program Files\Tivoli\TSM\baclient\dsm.opt')
    $candidates.Add('C:\Program Files\IBM\TSM\baclient\dsm.opt')
    $candidates.Add('C:\Program Files\IBM\SpectrumProtect\baclient\dsm.opt')
    
    foreach ($c in ($candidates | Select-Object -Unique))
    {
        if (-not $c) { continue }
        
        $testPath = if ($IsLocal) { $c }
        else { _ToUncPath -ComputerName $ComputerName -LocalPath $c }
        
        # Test-Path mit New-PSDrive kombinieren, falls Credentials noetig sind
        if (Test-Path -Path $testPath -ErrorAction SilentlyContinue)
        {
            return $c
        }
    }
    return $null
}

function _ToUncPath
{
    param ([string]$ComputerName,
        [string]$LocalPath)
    # Entfernt Backslashes am Anfang fuer Join-Path Stabilitaet
    if ($LocalPath -match '^([A-Za-z]):\\(.*)$')
    {
        return "\\$ComputerName\$($Matches[1])`$\$($Matches[2])"
    }
    return "\\$ComputerName\$($LocalPath.Replace(':', '$'))"
}

function _ResolveInclExclPath
{
    # Liest die dsm.opt und gibt den über die INCLEXCL-Option referenzierten lokalen
    # Pfad zur ausgelagerten Include/Exclude-Datei zurück (z. B. ie_dsm.opt).
    # Unterstützt INCLEXCL und INCLEXCL.WINNT, ignoriert Kommentare (*), nimmt den
    # letzten Treffer. Gibt $null zurück, wenn keine INCLEXCL-Option vorhanden ist.
    param (
        [Parameter(Mandatory = $true)]
        [string]$DsmOptAccessPath
    )

    if (-not (Test-Path -Path $DsmOptAccessPath -ErrorAction SilentlyContinue)) { return $null }

    $resolved = $null
    try
    {
        $lines = Get-Content -Path $DsmOptAccessPath -Encoding UTF8 -ErrorAction Stop
    }
    catch { return $null }

    foreach ($line in $lines)
    {
        $trimmed = $line.Trim()
        if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('*')) { continue }
        if ($trimmed -match '^(?i)INCLEXCL(\.\w+)?\s+(.+)$')
        {
            $resolved = $Matches[2].Trim().Trim('"').Trim("'")
        }
    }
    return $resolved
}