Private/Collectors/Get-RemoteChef.ps1
|
function ConvertFrom-ChefClientRb { [CmdletBinding()] [OutputType([pscustomobject])] param([Parameter(Mandatory)][AllowEmptyString()][string]$Content) $out = [ordered]@{ NodeName = $null; ChefServerUrl = $null; Environment = $null ValidationClient = $null; LogLevel = $null; LogLocation = $null } $patterns = @( @{ Re = '^\s*node_name\s+["''](?<v>[^"'']+)["'']'; Field = 'NodeName' }, @{ Re = '^\s*chef_server_url\s+["''](?<v>[^"'']+)["'']'; Field = 'ChefServerUrl' }, @{ Re = '^\s*environment\s+["''](?<v>[^"'']+)["'']'; Field = 'Environment' }, @{ Re = '^\s*validation_client_name\s+["''](?<v>[^"'']+)["'']'; Field = 'ValidationClient' }, @{ Re = '^\s*log_level\s+:?(?<v>\w+)'; Field = 'LogLevel' }, @{ Re = '^\s*log_location\s+["''](?<v>[^"'']+)["'']'; Field = 'LogLocation' } ) foreach ($line in ($Content -split "(`r`n|`r|`n)")) { $trim = $line.Trim() if (-not $trim -or $trim.StartsWith('#')) { continue } foreach ($p in $patterns) { if ($trim -match $p.Re) { $out[$p.Field] = $matches['v']; break } } } [pscustomobject]$out } function ConvertFrom-ChefClientLog { # Parse a chef-client log tail for the most recent run-list line. The # client emits "Run List is [recipe[a], role[b], ...]" near the start of # every run; the cache JSONs only contain run lists when data-collector # reporting is enabled, so the log is the more reliable source on most # nodes. Returns the items split on entry boundaries, with surrounding # whitespace trimmed; returns @() if no match is found. [CmdletBinding()] [OutputType([string[]])] param([Parameter(Mandatory)][AllowEmptyString()][string]$Content) if ([string]::IsNullOrWhiteSpace($Content)) { return @() } $matchesAll = [regex]::Matches($Content, 'Run List is\s*\[(?<rl>[^\r\n]+?)\](?:\r|\n|$)') if ($matchesAll.Count -eq 0) { return @() } $inner = $matchesAll[$matchesAll.Count - 1].Groups['rl'].Value # Items themselves contain `[name]`, so we cannot split on commas naively. # Match the two known forms (recipe[…], role[…]) and any bare cookbook ids. $items = [regex]::Matches($inner, '(?:recipe|role)\[[^\]]+\]|[A-Za-z0-9_:\-\.]+') @($items | ForEach-Object { $_.Value.Trim() } | Where-Object { $_ }) } function ConvertFrom-ChefLastRunReport { [CmdletBinding()] [OutputType([pscustomobject])] param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path $Path)) { return $null } $rawJson = $null try { $rawJson = Get-Content -Raw -Path $Path -ErrorAction Stop $node = $rawJson | ConvertFrom-Json -ErrorAction Stop } catch { return $null } $keysOf = { param($obj) if ($null -eq $obj) { return @() } @($obj.PSObject.Properties.Name | Where-Object { $_ }) } # Extract time fields as raw strings from JSON before ConvertFrom-Json auto-parses them as DateTime. $endTime = $null foreach ($prop in 'end_time','EndTime','last_run','LastRun') { if ($rawJson -match ('"' + $prop + '"\s*:\s*"(?<v>[^"]+)"')) { $endTime = $matches['v']; break } } [pscustomobject]@{ RunList = @($node.run_list) AttributeKeys = [pscustomobject]@{ default = @(& $keysOf $node.default) normal = @(& $keysOf $node.normal) override = @(& $keysOf $node.override) automatic = @(& $keysOf $node.automatic) } LastSuccessfulRunUtc = $endTime } } function Get-RemoteChef { [CmdletBinding()] [OutputType([pscustomobject])] param([Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session) $probe = Invoke-Command -Session $Session -ScriptBlock { $registryHit = $false foreach ($root in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall') { if (Test-Path $root) { $registryHit = [bool](Get-ChildItem $root -ErrorAction SilentlyContinue | Get-ItemProperty -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like 'Chef Infra Client*' -or $_.DisplayName -like 'Chef Client*' }) if ($registryHit) { break } } } $clientRbPath = 'C:\chef\client.rb' $fileHit = Test-Path $clientRbPath if (-not $registryHit -and -not $fileHit) { return $null } $rbContent = if ($fileHit) { Get-Content -Raw -Path $clientRbPath -ErrorAction SilentlyContinue } else { $null } # The cache JSON is queried for AttributeKeys + LastSuccessfulRunUtc. # Priority order matters: # 1. nodes/<node>.json — the full cached node object (default/normal/ # override/automatic attribute trees, run_list, last run). Written # after every successful chef-client run; this is the canonical # local source. # 2. last_run_report.json — only present when a JSON report handler # is configured. Contains a run report; fields vary by handler. # 3. data_collector_metadata.json — only contains a node_uuid hash; # NO attributes, NO run_list. Skip — looking here was a bug. $cacheRoot = 'C:\chef\cache' $cachePath = $null $cacheSource = $null if (Test-Path $cacheRoot) { $node = Get-ChildItem (Join-Path $cacheRoot 'nodes') -Filter '*.json' -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if ($node) { $cachePath = $node.FullName $cacheSource = 'NodeCache' } else { $p = Join-Path $cacheRoot 'last_run_report.json' if (Test-Path $p) { $cachePath = $p $cacheSource = 'LastRunReport' } } } # Read the most recent client log so the worker can fall back to log # parsing when cache JSONs lack the run list (common when # data-collector reporting is off). The "Run List is [...]" line is # emitted at the START of every run, so we read from the beginning of # the file (capped at 4 MB) rather than tailing — busy nodes can write # 100s of KB of converge output per run, pushing the start-of-run # marker out of any small tail window. # # chef-client may hold the log file open for append. We open with # FileShare = ReadWrite|Delete (most permissive) so a running daemon # cannot block the read. Status is captured so the caller can surface # why a read wasn't produced when the run list ends up empty. $logTail = $null $logStatus = 'NoDir' $logPath = $null $logRoot = 'C:\chef\log' $maxBytes = 4MB if (Test-Path $logRoot) { $logFiles = @(Get-ChildItem $logRoot -Filter '*.log' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending) if ($logFiles.Count -eq 0) { $logStatus = 'NoFile' } else { # Walk newest → older until we either succeed at reading and # find a "Run List is" line, or exhaust candidates. Some sites # rotate to YYYYMMDD.log and start a fresh empty client.log; # the freshest mtime can be a near-empty file. foreach ($logFile in ($logFiles | Select-Object -First 4)) { $logPath = $logFile.FullName try { $share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete $fs = [System.IO.File]::Open($logFile.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $share) try { $len = $fs.Length if ($len -le 0) { $logStatus = 'Empty'; continue } # If the file is bigger than our cap, read the LAST # $maxBytes — late-run output is more useful than # nothing, and we still try older files below. $take = [Math]::Min($len, $maxBytes) if ($len -gt $maxBytes) { $fs.Seek($len - $maxBytes, [System.IO.SeekOrigin]::Begin) | Out-Null } $buf = New-Object byte[] $take $read = $fs.Read($buf, 0, $take) $content = [System.Text.Encoding]::UTF8.GetString($buf, 0, $read) } finally { $fs.Dispose() } if ($content -match 'Run List is\s*\[') { $logTail = $content; $logStatus = 'OK'; break } # File was readable but no run-list marker — keep the # content as a partial fallback and try older files. if (-not $logTail) { $logTail = $content; $logStatus = 'OK-NoRunList' } } catch { $logStatus = "Error: $($_.Exception.Message)" } } } } $version = $null $exe = Get-Command 'chef-client.exe' -ErrorAction SilentlyContinue if ($exe) { try { $job = Start-Job { & 'chef-client.exe' --version 2>$null } if (Wait-Job $job -Timeout 5) { $version = (Receive-Job $job) -join "`n" } Remove-Job $job -Force -ErrorAction SilentlyContinue } catch {} } [pscustomobject]@{ Detected = $true ClientRb = $rbContent CachePath = $cachePath CacheSource = $cacheSource LogPath = $logPath LogTail = $logTail LogStatus = $logStatus Version = $version } } if ($null -eq $probe) { return $null } $rb = if ($probe.ClientRb) { ConvertFrom-ChefClientRb -Content $probe.ClientRb } else { [pscustomobject]@{ NodeName=$null; ChefServerUrl=$null; Environment=$null; ValidationClient=$null } } $cacheJson = if ($probe.CachePath) { Invoke-Command -Session $Session -ArgumentList $probe.CachePath -ScriptBlock { param($p) Get-Content -Raw -Path $p -ErrorAction SilentlyContinue } } else { $null } $report = if ($cacheJson) { $tmp = New-TemporaryFile try { Set-Content -Path $tmp.FullName -Value $cacheJson -Encoding utf8 ConvertFrom-ChefLastRunReport -Path $tmp.FullName } finally { Remove-Item -Path $tmp.FullName -Force -ErrorAction SilentlyContinue } } else { $null } # Fall back to the client log if the cache report didn't yield a run list. # The client always emits "Run List is [...]" near the start of every run, # so this is reliable even when data-collector reporting is off. $cacheRunList = if ($report) { @($report.RunList | Where-Object { $_ }) } else { @() } $logRunList = if ($probe.LogTail) { @(ConvertFrom-ChefClientLog -Content $probe.LogTail) } else { @() } $runList = if ($cacheRunList.Count -gt 0) { $cacheRunList } else { $logRunList } $runListSrc = if ($cacheRunList.Count -gt 0) { 'CacheReport' } elseif ($logRunList.Count -gt 0) { 'ClientLog' } else { 'None' } # AttributeKeys: distinguish "no cache file" from "file present but empty" # so the report can say "(unavailable)" instead of misleading "(none)". $attrKeys = if ($report) { $report.AttributeKeys } else { [pscustomobject]@{ default=@(); normal=@(); override=@(); automatic=@() } } $attrStatus = if (-not $probe.CachePath) { 'NoCacheFile' } elseif (-not $report) { 'CacheUnreadable' } else { 'OK' } [pscustomobject]@{ Installed = $true Version = if ($probe.Version) { ($probe.Version | Out-String).Trim() } else { $null } NodeName = $rb.NodeName ChefServerUrl = $rb.ChefServerUrl Environment = $rb.Environment ValidationClient = $rb.ValidationClient RunList = $runList RunListSource = $runListSrc ClientLogPath = $probe.LogPath ClientLogStatus = $probe.LogStatus AttributeKeys = $attrKeys AttributeSource = $probe.CacheSource AttributeStatus = $attrStatus AttributeCachePath = $probe.CachePath LastSuccessfulRunUtc = if ($report) { $report.LastSuccessfulRunUtc } else { $null } } } |