From c3f0ee292c1c5b8d57995b0558ce6a541d5bc85b Mon Sep 17 00:00:00 2001 From: buiaca Date: Tue, 28 Jul 2026 18:32:26 +0200 Subject: [PATCH] feat(Hermes): Get-SCCMAppDeployment.ps1 - SCCM Deployment-Status einer App auslesen Liest per CIM (CCM_Application) + CCM-Logs (AppDiscovery, AppEnforce, CAS) den Status einer per Namen angegebenen Anwendung auf einem Client: - Content angekommen? Cache-Pfad (CacheInfoEx + CAS.log) - Detection-Methode + Ergebnis - Installation gestartet/fertig, Fehlercode + Begruendung Inkl. EvalState-/InstallState-/Fehlercode-Uebersetzungstabellen. --- Hermes/Get-SCCMAppDeployment.ps1 | 325 +++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 Hermes/Get-SCCMAppDeployment.ps1 diff --git a/Hermes/Get-SCCMAppDeployment.ps1 b/Hermes/Get-SCCMAppDeployment.ps1 new file mode 100644 index 0000000..b130543 --- /dev/null +++ b/Hermes/Get-SCCMAppDeployment.ps1 @@ -0,0 +1,325 @@ +<# +.SYNOPSIS + Liest den SCCM/MECM-Deployment-Status einer Anwendung auf einem entfernten Client aus. +.DESCRIPTION + Das Skript verbindet sich mit einem Ziel-Client und liefert zu einer angegebenen + Anwendung folgende Informationen: + - Ob der Content bereits "angekommen" ist (im Cache / Download abgeschlossen) + - In welchem Cache-Ordner der Content liegt + - Welche Erkennungsmethode (Detection) greift und deren Ergebnis + - Ob die Installation bereits begonnen hat, erfolgreich war oder warum nicht + (Fehlercode + begruendende Log-Zeilen) + + Es kombiniert eine WMI/CIM-Abfrage (sauberer Gesamtstatus) mit dem Parsen der + relevanten CCM-Logs (AppDiscovery, AppEnforce, CAS) fuer die Details. +.PARAMETER ComputerName + Name oder IP des Ziel-Clients. Der SCCM-Client muss erreichbar sein (WinRM + Admin-Rechte). +.PARAMETER ApplicationName + (Teil-)Name der Anwendung, nach der gesucht wird (case-insensitiver Teilstring). +.PARAMETER Credential + Optional: Credential fuer den Zugriff auf den Client (Admin-Rechte noetig). +.PARAMETER CCMPath + Optional: Pfad zu CCM auf dem Ziel (Default C:\Windows\CCM). +.PARAMETER Detailed + Schaltet zusaetzliche Roh-Logauszuege ein. +.EXAMPLE + .\Get-SCCMAppDeployment.ps1 -ComputerName CLIENT01 -ApplicationName "Adobe Reader" +.EXAMPLE + .\Get-SCCMAppDeployment.ps1 -ComputerName CLIENT01 -ApplicationName "7-Zip" -Detailed -Credential (Get-Credential) +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ComputerName, + + [Parameter(Mandatory)] + [string]$ApplicationName, + + [pscredential]$Credential, + + [string]$CCMPath = "C:\Windows\CCM", + + [switch]$Detailed +) + +#region Nachschlagetabellen --------------------------------------------------- + +$EvalStates = @{ + 0 = 'Kein Status (None)'; 1 = 'Verfuegbar (Available)' + 2 = 'Uebermittelt (Submitted)'; 3 = 'Erkennung laeuft (Detecting)' + 4 = 'Vor-Download (PreDownload)'; 5 = 'Download laeuft (Downloading)' + 6 = 'Wartet auf Installation'; 7 = 'Installation laeuft (Installing)' + 8 = 'Neustart ausstehend'; 9 = 'Wird ausgefuehrt (Running)' + 10 = 'Wiederholung (Retrying)'; 11 = 'Wartet auf Servicefenster' + 12 = 'Wartet auf Benutzersitzung'; 13 = 'Wartet auf Benutzer-Abmeldung' + 14 = 'Wartet auf Benutzer-Anmeldung'; 15 = 'Wartet (ADU)' + 16 = 'Wartet (ADU)'; 17 = 'Wartet auf Orchestrierung' + 18 = 'Wartet auf Bereitstellung'; 19 = 'Wartet auf Verbindung' + 20 = 'Reserviert'; 21 = 'Wartet (Energie/Modus)' + 22 = 'Wartet auf Strom' +} + +$InstallStates = @{ + 0 = 'Nicht installiert' + 1 = 'Fehler' + 2 = 'Erfolgreich' + 3 = 'In Bearbeitung' + 4 = 'Verfuegbar' + 5 = 'Erfolgreich (Neustart noetig)' +} + +# Hauefige SCCM-/MSI-Rueckgabecodes (dezimal); unbekannte werden als Hex angezeigt. +$CommonErrors = @{ + 0 = 'Erfolg' + 1707 = 'Installation erfolgreich' + 3010 = 'Erfolg, Neustart erforderlich' + 1602 = 'Abbruch durch Benutzer' + 1603 = 'Schwerwiegender Installationsfehler' + 1618 = 'Andere Installation laeuft bereits (MSI-Sperre)' + 1619 = 'MSI-Quellpfad nicht erreichbar' + 1633 = 'Plattform wird nicht unterstuetzt' + 1635 = 'MSI-Paket beschÃĪdigt/nicht gefunden' +} + +#endregion + +#region Hilfsfunktionen ------------------------------------------------------- + +function ConvertTo-EvalStateText { + param([int]$Code) + if ($EvalStates.ContainsKey($Code)) { return $EvalStates[$Code] } + return "Unbekannter Status ($Code)" +} + +function ConvertTo-InstallStateText { + param([int]$Code) + if ($InstallStates.ContainsKey($Code)) { return $InstallStates[$Code] } + return "Unbekannter InstallState ($Code)" +} + +function Get-ErrorText { + param([int]$Code) + if ($CommonErrors.ContainsKey($Code)) { return $CommonErrors[$Code] } + $hex = '0x{0:X8}' -f $Code + return "Code $Code ($hex)" +} + +function Parse-SCCMLog { + [CmdletBinding()] + param([Parameter(Mandatory)][string[]]$Content) + # SCCM-Logformat: ]LOG]!> + $re = '^.*?)\]LOG\]!>[^"]*)" date="(?[^"]*)" component="(?[^"]*)" context="[^"]*" type="(?\d)" thread="[^"]*" file="[^"]*">' + foreach ($line in $Content) { + if ($line -match $re) { + [PSCustomObject]@{ + Time = "$($Matches.date) $($Matches.time)" + Component = $Matches.comp + Type = [int]$Matches.type + Message = $Matches.msg + } + } + } +} + +function Get-RemoteCCMLog { + [CmdletBinding()] + param( + [string]$ComputerName, + [string]$LogName, + [string]$LogRoot, + [string]$ApplicationName + ) + $path = Join-Path $LogRoot "Logs\$LogName" + if (-not (Test-Path $path)) { + Write-Warning "Log nicht vorhanden: $path" + return @() + } + try { + $raw = Get-Content -Path $path -Encoding UTF8 -ErrorAction Stop + } + catch { + Write-Warning "Log $LogName nicht lesbar: $_" + return @() + } + $entries = Parse-SCCMLog -Content $raw + # Auf die Anwendung einschraenken (case-insensitive Teilstring) + $entries | Where-Object { $_.Message -match [regex]::Escape($ApplicationName) } +} + +#endregion + +#region Verbindung aufbauen --------------------------------------------------- + +$cimParams = @{ ComputerName = $ComputerName; ErrorAction = 'Stop' } +if ($Credential) { $cimParams['Credential'] = $Credential } + +try { + $session = New-CimSession @cimParams +} +catch { + Write-Warning "CIM-Sitzung zu $ComputerName fehlgeschlagen (WinRM?): $_" + $session = $null +} + +# Admin-Freigabe fuer die Logs mounten (Credential wird hier sauber uebergeben) +$shareUnc = "\\$ComputerName\$(($CCMPath -replace ':','$'))" +$logRoot = $null +try { + $driveName = "SCCMLOG_$ComputerName" -replace '[^\w]', '' + $drive = New-PSDrive -Name $driveName -PSProvider FileSystem -Root $shareUnc -Credential $Credential -ErrorAction Stop + $logRoot = $drive.Root +} +catch { + Write-Warning "Admin-Freigabe $shareUnc nicht per Credential mountbar, versuche aktuellen Kontext: $_" + if (Test-Path $shareUnc) { $logRoot = $shareUnc } else { $logRoot = $null } +} + +if (-not $logRoot) { + Write-Error "Weder CIM noch Log-Zugriff moeglich. Abbruch." + if ($session) { Remove-CimSession $session } + return +} + +#endregion + +#region WMI: App-Status -------------------------------------------------------- + +$app = $null +if ($session) { + try { + $allApps = Get-CimInstance -CimSession $session -Namespace 'root\ccm\clientsdk' -ClassName CCM_Application -ErrorAction Stop + $app = $allApps | Where-Object { $_.Name -like "*$ApplicationName*" } | Select-Object -First 1 + } + catch { + Write-Warning "CCM_Application-Abfrage fehlgeschlagen: $_" + } +} + +#endregion + +#region Logs parsen ------------------------------------------------------------ + +$appDisc = if ($logRoot) { Get-RemoteCCMLog -ComputerName $ComputerName -LogName 'AppDiscovery.log' -LogRoot $logRoot -ApplicationName $ApplicationName } else { @() } +$appEnf = if ($logRoot) { Get-RemoteCCMLog -ComputerName $ComputerName -LogName 'AppEnforce.log' -LogRoot $logRoot -ApplicationName $ApplicationName } else { @() } +$casAll = if ($logRoot -and (Test-Path (Join-Path $logRoot 'Logs\CAS.log'))) { + Parse-SCCMLog -Content (Get-Content -Path (Join-Path $logRoot "Logs\CAS.log") -Encoding UTF8 -ErrorAction SilentlyContinue) +} else { @() } + +# Content-IDs aus AppEnforce extrahieren, um den Cache-Pfad zuzuordnen +$contentIds = @() +foreach ($e in $appEnf) { + if ($e.Message -match 'content(?:ID| with ID)?[ =:]+([A-Fa-f0-9\-]{8,})') { + $contentIds += $Matches[1] + } + elseif ($e.Message -match 'ContentId ([A-Fa-f0-9\-]{8,})') { + $contentIds += $Matches[1] + } +} +$contentIds = $contentIds | Sort-Object -Unique + +# Cache-Pfade aus CAS.log zu diesen Content-IDs +$cachePaths = @() +foreach ($cid in $contentIds) { + $casAll | Where-Object { $_.Message -match [regex]::Escape($cid) -and $_.Message -match 'location = (.+)' } | ForEach-Object { + if ($_ -match 'location = (.+)') { $cachePaths += $Matches[1].Trim() } + } +} +$cachePaths = $cachePaths | Sort-Object -Unique + +# CacheInfoEx ergaenzt (falls WMI verfuegbar) +$cacheEx = @() +if ($session) { + try { + $cacheEx = Get-CimInstance -CimSession $session -Namespace 'root\ccm\SoftMgmtAgent' -ClassName CacheInfoEx -ErrorAction SilentlyContinue | + Where-Object { $contentIds -contains $_.ContentID } + } + catch { /* kein Zugriff - egal */ } +} + +#endregion + +#region Auswertung ------------------------------------------------------------- + +$arrivedInCache = ($cachePaths.Count -gt 0) -or ($cacheEx.Count -gt 0) +$cacheFolder = if ($cachePaths) { $cachePaths -join "`n" } else { ($cacheEx | ForEach-Object { $_.Location }) -join "`n" } + +# Detection +$detectionLines = $appDisc | Where-Object { $_.Message -match 'detection method|Detected|did not detect|not detected' } | Select-Object -ExpandProperty Message +$detectionResult = if ($detectionLines) { + if ($detectionLines -match 'did not detect|not detected') { 'NICHT erkannt' } else { 'erkannt / ok' } +} else { 'kein Detection-Eintrag gefunden' } + +# Installation +$installStarted = ($appEnf | Where-Object { $_.Message -match 'Starting Install enforcement|Starting enforcement' }).Count -gt 0 +$installDone = ($appEnf | Where-Object { $_.Message -match 'Enforcement completed|enforcement completed' }).Count -gt 0 +$installErrors = $appEnf | Where-Object { $_.Type -in @(2, 3) } | Select-Object Time, Message + +# Begruendung, warum (noch) nicht installiert +$reason = '' +if ($app) { + if ($app.ErrorCode -ne 0) { $reason = "Fehlercode $($app.ErrorCode): $(Get-ErrorText -Code $app.ErrorCode)" } + elseif ($app.EvaluationState -in @(11, 12, 13, 14, 17, 19, 21, 22)) { $reason = "Wartet: $(ConvertTo-EvalStateText -Code $app.EvaluationState)" } + elseif ($app.InstallState -eq 2) { $reason = 'Bereits erfolgreich installiert' } + elseif (-not $arrivedInCache) { $reason = 'Content noch nicht im Cache (Download laeuft oder nicht verteilt)' } + elseif ($installStarted -and -not $installDone) { $reason = 'Installation laeuft' } + elseif (-not $installStarted) { $reason = 'Noch nicht mit Installation begonnen' } + else { $reason = 'Status unklar - Details pruefen' } +} + +$result = [PSCustomObject]@{ + ComputerName = $ComputerName + Application = if ($app) { $app.Name } else { "(WMI: keine Uebereinstimmung fuer '$ApplicationName')" } + InstallState = if ($app) { ConvertTo-InstallStateText -Code $app.InstallState } else { 'nicht ermittelbar' } + EvaluationState = if ($app) { ConvertTo-EvalStateText -Code $app.EvaluationState } else { 'nicht ermittelbar' } + ErrorCode = if ($app) { $app.ErrorCode } else { $null } + ErrorText = if ($app -and $app.ErrorCode -ne 0) { Get-ErrorText -Code $app.ErrorCode } else { '' } + Angekommen = $arrivedInCache + CachePfad = if ($cacheFolder) { $cacheFolder } else { '(kein Cache-Eintrag)' } + Detection = if ($detectionLines) { ($detectionLines -join "`n") } else { '(keine Detection-Logs)' } + DetectionErgebnis = $detectionResult + InstallStarted = $installStarted + InstallAbgeschlossen = $installDone + Grund = $reason + InstallFehler = if ($installErrors) { ($installErrors | ForEach-Object { "$($_.Time): $($_.Message)" }) -join "`n" } else { '(keine)' } +} + +#endregion + +#region Ausgabe ---------------------------------------------------------------- + +Write-Host "`n===== SCCM-Deployment-Status: $ApplicationName @ $ComputerName =====" -ForegroundColor Cyan +Write-Host ("Anwendung : {0}" -f $result.Application) +Write-Host ("InstallState : {0}" -f $result.InstallState) +Write-Host ("EvaluationState : {0}" -f $result.EvaluationState) +if ($result.ErrorCode -ne $null -and $result.ErrorCode -ne 0) { + Write-Host ("Fehler : {0}" -f $result.ErrorText) -ForegroundColor Red +} +Write-Host ("Angekommen/Cache : {0}" -f $(if ($result.Angekommen) { 'JA' } else { 'NEIN' })) -ForegroundColor $(if ($result.Angekommen) { 'Green' } else { 'Yellow' }) +Write-Host ("Cache-Pfad : {0}" -f $result.CachePfad) +Write-Host ("Detection : {0}" -f $result.DetectionErgebnis) +Write-Host ("Install gestartet : {0}" -f $(if ($result.InstallStarted) { 'JA' } else { 'NEIN' })) +Write-Host ("Install fertig : {0}" -f $(if ($result.InstallAbgeschlossen) { 'JA' } else { 'NEIN' })) +Write-Host ("Grund/Status : {0}" -f $result.Grund) -ForegroundColor $(if ($result.Grund -match 'erfolgreich|erkannt') { 'Green' } else { 'Yellow' }) +if ($Detailed) { + Write-Host "`n--- Detection-Logs (AppDiscovery) ---" -ForegroundColor DarkGray + $detectionLines | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } + Write-Host "`n--- Install-Fehler (AppEnforce) ---" -ForegroundColor DarkGray + if ($installErrors) { $installErrors | ForEach-Object { Write-Host (" {0}: {1}" -f $_.Time, $_.Message) -ForegroundColor Red } } + else { Write-Host " (keine)" -ForegroundColor Gray } +} +Write-Host "" + +# Als Objekt zurueckgeben (fuer Weiterverarbeitung / Piping) +$result + +#endregion + +#region Aufraeumen ------------------------------------------------------------- + +if ($session) { Remove-CimSession $session } +if ($logRoot -and (Get-PSDrive -Name $driveName -ErrorAction SilentlyContinue)) { + Remove-PSDrive -Name $driveName -ErrorAction SilentlyContinue +} + +#endregion