mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 10:09:05 +02:00
process: read the IIS application pool from the w3wp command line (#2638)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e58e4763e2
commit
4ca7dc8957
@@ -33,7 +33,8 @@ metrics.
|
||||
|
||||
### `--collector.process.iis`
|
||||
|
||||
Enables IIS process name queries. IIS process names are combined with their app pool name to form the `process` label.
|
||||
Appends the IIS application pool name to the process name of IIS worker processes (`w3wp`) to form the `process` label.
|
||||
See [IIS worker processes](#iis-worker-processes).
|
||||
|
||||
Disabled by default, and can be enabled with `--collector.process.iis`. NOTE: Just plain parameter without `true`.
|
||||
|
||||
@@ -63,11 +64,21 @@ To specify multiple names, use the pipe `|` character:
|
||||
```
|
||||
This will match all processes named `firefox`, `FIREFOX` or `chrome` .
|
||||
|
||||
## IIS Worker processes
|
||||
## IIS worker processes
|
||||
|
||||
The process collector also queries the `root\\WebAdministration` WMI namespace to check for running IIS workers. If it successfully retrieves a list from this namespace, it will append the name of the worker's application pool to the corresponding process. include/exclude matching occurs before this name is appended, so you don't have to take this name in consideration when writing your expression.
|
||||
With `--collector.process.iis`, the collector appends the application pool name to the name of each IIS worker process (`w3wp`).
|
||||
Include and exclude matching happens before the name is appended, so your expressions don't need to account for it.
|
||||
|
||||
Note that this specific feature **only works** if the [IIS Management Scripts and Tools](https://learn.microsoft.com/en-us/iis/manage/scripting/managing-sites-with-the-iis-wmi-provider) are installed. If they are not installed then all worker processes return as just `w3wp`.
|
||||
The collector reads the application pool from the `-ap` argument of the worker process command line.
|
||||
The Windows Process Activation Service (WAS) starts every worker process with this argument.
|
||||
Reading the command line needs Windows Server 2012 R2 or later, and access to the worker process.
|
||||
The exporter running as `LocalSystem` has that access.
|
||||
|
||||
If the command line can't be read or has no `-ap` argument, the collector falls back to the `WorkerProcess` class in the `root\WebAdministration` WMI namespace.
|
||||
That namespace needs the optional [IIS Management Scripts and Tools](https://learn.microsoft.com/iis/manage/scripting/managing-sites-with-the-iis-wmi-provider) feature (`Web-Scripting-Tools`).
|
||||
The collector doesn't query WMI while every command line has an application pool.
|
||||
|
||||
A worker process whose application pool isn't known from either source keeps the name `w3wp`.
|
||||
|
||||
### Example
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# process collector
|
||||
|
||||
IIS application pool names (`--collector.process.iis`) come from two sources.
|
||||
|
||||
- Primary: the `-ap "<pool>"` argument of each `w3wp` command line, read with
|
||||
`NtQueryInformationProcess(ProcessCommandLineInformation)`. It needs only
|
||||
`PROCESS_QUERY_LIMITED_INFORMATION` and Windows 8.1 or Server 2012 R2.
|
||||
- Fallback: `WorkerProcess` in `root\WebAdministration`, used only when it
|
||||
answered during `Build` and a command line can't be read or has no `-ap`.
|
||||
It needs the optional IIS Management Scripts and Tools feature
|
||||
(`Web-Scripting-Tools`), and asks WAS through RSCA on every query.
|
||||
- `splitCommandLine` follows `CommandLineToArgvW`. `FuzzSplitCommandLine`
|
||||
compares it with `windows.DecomposeCommandLine`; run it after parser changes
|
||||
with `go test -run '^$' -fuzz FuzzSplitCommandLine ./internal/collector/process/`.
|
||||
- Without IIS, `TestCollectorWorkerProcessCommandLine` starts a suspended copy
|
||||
of the test binary named `w3wp.exe` to check the published label. The IIS
|
||||
tests compare against WMI when available and against the
|
||||
`IIS APPPOOL\<pool>` process owner.
|
||||
- Don't create or change IIS application pools on a development machine to
|
||||
test edge cases; use suspended processes with crafted command lines.
|
||||
@@ -0,0 +1,276 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Copyright The Prometheus Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// workerProcessName is the perflib instance name of IIS worker processes (w3wp.exe).
|
||||
const workerProcessName = "w3wp"
|
||||
|
||||
// errNoAppPoolArgument reports a w3wp command line without a non-empty -ap argument.
|
||||
var errNoAppPoolArgument = errors.New("command line has no -ap argument")
|
||||
|
||||
// isWorkerProcess reports whether the perflib process name (without instance suffix) is an IIS worker process.
|
||||
func isWorkerProcess(name string) bool {
|
||||
return strings.EqualFold(name, workerProcessName)
|
||||
}
|
||||
|
||||
// workerProcessAppPool returns the application pool of the IIS worker process pid.
|
||||
//
|
||||
// The Windows Process Activation Service (WAS) starts every worker process as
|
||||
// `w3wp.exe -ap "<pool>" ...`. The pool name is read from the command line of
|
||||
// the process. It opens the process with PROCESS_QUERY_LIMITED_INFORMATION only.
|
||||
func workerProcessAppPool(pid uint32) (string, error) {
|
||||
cmdLine, err := queryProcessCommandLine(pid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pool, ok := appPoolFromCommandLine(cmdLine)
|
||||
if !ok {
|
||||
return "", errNoAppPoolArgument
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// queryProcessCommandLine returns the command line of process pid.
|
||||
//
|
||||
// It uses NtQueryInformationProcess(ProcessCommandLineInformation), which is
|
||||
// available since Windows 8.1 and Windows Server 2012 R2. Earlier versions
|
||||
// return STATUS_INVALID_INFO_CLASS. Unlike reading the PEB, it needs only
|
||||
// PROCESS_QUERY_LIMITED_INFORMATION, not PROCESS_VM_READ.
|
||||
func queryProcessCommandLine(pid uint32) (string, error) {
|
||||
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("OpenProcess: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = windows.CloseHandle(hProcess)
|
||||
}()
|
||||
|
||||
return processCommandLine(hProcess)
|
||||
}
|
||||
|
||||
// unicodeStringHeader mirrors UNICODE_STRING. Buffer is kept as an address
|
||||
// because it points into the buffer returned by the kernel.
|
||||
type unicodeStringHeader struct {
|
||||
Length uint16
|
||||
MaximumLength uint16
|
||||
Buffer uintptr
|
||||
}
|
||||
|
||||
func processCommandLine(hProcess windows.Handle) (string, error) {
|
||||
// UNICODE_STRING header plus a typical w3wp command line.
|
||||
size := uint32(1024)
|
||||
|
||||
var err error
|
||||
|
||||
for range 4 {
|
||||
// []uint64 keeps the UNICODE_STRING header pointer-aligned.
|
||||
buf := make([]uint64, (size+7)/8)
|
||||
bufSize := uint32(len(buf) * 8)
|
||||
retLen := uint32(0)
|
||||
|
||||
err = windows.NtQueryInformationProcess(hProcess, windows.ProcessCommandLineInformation, unsafe.Pointer(&buf[0]), bufSize, &retLen)
|
||||
if err == nil {
|
||||
return decodeUnicodeString(buf)
|
||||
}
|
||||
|
||||
if !errors.Is(err, windows.STATUS_INFO_LENGTH_MISMATCH) &&
|
||||
!errors.Is(err, windows.STATUS_BUFFER_TOO_SMALL) &&
|
||||
!errors.Is(err, windows.STATUS_BUFFER_OVERFLOW) {
|
||||
break
|
||||
}
|
||||
|
||||
if retLen <= bufSize {
|
||||
break
|
||||
}
|
||||
|
||||
size = retLen
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("NtQueryInformationProcess(ProcessCommandLineInformation): %w", err)
|
||||
}
|
||||
|
||||
// decodeUnicodeString decodes the UNICODE_STRING that the kernel placed at the
|
||||
// start of buf. Its Buffer must point into buf.
|
||||
func decodeUnicodeString(buf []uint64) (string, error) {
|
||||
base := unsafe.Pointer(&buf[0])
|
||||
header := (*unicodeStringHeader)(base)
|
||||
|
||||
if header.Length == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
bufSize := uintptr(len(buf)) * 8
|
||||
offset := header.Buffer - uintptr(base)
|
||||
|
||||
if header.Buffer < uintptr(base) || offset < unsafe.Sizeof(*header) || offset > bufSize ||
|
||||
uintptr(header.Length) > bufSize-offset || header.Length%2 != 0 {
|
||||
return "", fmt.Errorf("UNICODE_STRING with length %d at offset %d does not fit the %d byte buffer", header.Length, offset, bufSize)
|
||||
}
|
||||
|
||||
data := unsafe.Slice((*uint16)(unsafe.Add(base, offset)), header.Length/2)
|
||||
|
||||
return string(utf16.Decode(data)), nil
|
||||
}
|
||||
|
||||
// appPoolFromCommandLine returns the value of the first -ap argument of a w3wp
|
||||
// command line. Arguments are split with the CommandLineToArgvW rules.
|
||||
//
|
||||
// Application pool names can't contain '"' or '\', so the quoting WAS applies
|
||||
// is unambiguous for every valid pool name.
|
||||
func appPoolFromCommandLine(cmdLine string) (string, bool) {
|
||||
args := splitCommandLine(cmdLine)
|
||||
|
||||
// args[0] is the program name.
|
||||
for i := 1; i < len(args)-1; i++ {
|
||||
if strings.EqualFold(args[i], "-ap") {
|
||||
return args[i+1], args[i+1] != ""
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isCommandLineSpace(c byte) bool {
|
||||
return c == ' ' || c == '\t'
|
||||
}
|
||||
|
||||
// splitCommandLine splits a command line like CommandLineToArgvW does.
|
||||
//
|
||||
// The program name ends at the next space or control character, which is
|
||||
// consumed, or, if it starts with a double quote, at the next double quote;
|
||||
// backslashes are literal in it. In the other arguments:
|
||||
// - spaces and tabs outside double quotes separate arguments,
|
||||
// - 2n backslashes followed by a double quote produce n backslashes, and the
|
||||
// double quote starts or ends a quoted part,
|
||||
// - 2n+1 backslashes followed by a double quote produce n backslashes and a
|
||||
// literal double quote,
|
||||
// - backslashes not followed by a double quote are literal,
|
||||
// - inside a quoted part, every third consecutive double quote is literal.
|
||||
//
|
||||
// The rules only treat ASCII bytes specially, so the input is processed as
|
||||
// UTF-8 bytes without decoding runes.
|
||||
func splitCommandLine(cmdLine string) []string {
|
||||
if cmdLine == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
args []string
|
||||
i int
|
||||
)
|
||||
|
||||
if cmdLine[0] == '"' {
|
||||
end := strings.IndexByte(cmdLine[1:], '"')
|
||||
if end < 0 {
|
||||
return []string{cmdLine[1:]}
|
||||
}
|
||||
|
||||
args = append(args, cmdLine[1:end+1])
|
||||
i = end + 2
|
||||
} else {
|
||||
// Any control character or space ends an unquoted program name, and is consumed.
|
||||
for i < len(cmdLine) && cmdLine[i] > ' ' {
|
||||
i++
|
||||
}
|
||||
|
||||
args = append(args, cmdLine[:i])
|
||||
i++
|
||||
}
|
||||
|
||||
for i < len(cmdLine) && isCommandLineSpace(cmdLine[i]) {
|
||||
i++
|
||||
}
|
||||
|
||||
var (
|
||||
arg []byte
|
||||
inArg bool
|
||||
quotes int
|
||||
backslashes int
|
||||
)
|
||||
|
||||
for i < len(cmdLine) {
|
||||
c := cmdLine[i]
|
||||
|
||||
switch {
|
||||
case isCommandLineSpace(c) && quotes == 0:
|
||||
args = append(args, string(arg))
|
||||
arg = arg[:0]
|
||||
inArg = false
|
||||
backslashes = 0
|
||||
|
||||
for i < len(cmdLine) && isCommandLineSpace(cmdLine[i]) {
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
case c == '\\':
|
||||
arg = append(arg, c)
|
||||
backslashes++
|
||||
case c == '"':
|
||||
if backslashes%2 == 0 {
|
||||
// 2n backslashes and a quote: n backslashes, the quote toggles quoting.
|
||||
arg = arg[:len(arg)-backslashes/2]
|
||||
quotes++
|
||||
} else {
|
||||
// 2n+1 backslashes and a quote: n backslashes and a literal quote.
|
||||
arg = append(arg[:len(arg)-backslashes/2-1], '"')
|
||||
}
|
||||
|
||||
backslashes = 0
|
||||
|
||||
for i+1 < len(cmdLine) && cmdLine[i+1] == '"' {
|
||||
i++
|
||||
quotes++
|
||||
|
||||
if quotes == 3 {
|
||||
arg = append(arg, '"')
|
||||
quotes = 0
|
||||
}
|
||||
}
|
||||
|
||||
if quotes == 2 {
|
||||
quotes = 0
|
||||
}
|
||||
default:
|
||||
arg = append(arg, c)
|
||||
backslashes = 0
|
||||
}
|
||||
|
||||
inArg = true
|
||||
i++
|
||||
}
|
||||
|
||||
if inArg {
|
||||
args = append(args, string(arg))
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Copyright The Prometheus Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// A command line as WAS starts IIS 10 worker processes.
|
||||
const wasCommandLine = `c:\windows\system32\inetsrv\w3wp.exe -ap "DefaultAppPool" -v "v4.0" -l "webengine4.dll" -a \\.\pipe\iisipm5f5e2b1c-8d6a-4b2e-9a0c-3d1b2f4e6a7c -h "C:\inetpub\temp\apppools\DefaultAppPool\DefaultAppPool.config" -w "" -m 0 -t 20 -ta 0`
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var commandLineTests = []struct {
|
||||
name string
|
||||
cmdLine string
|
||||
args []string
|
||||
}{
|
||||
{"empty", ``, nil},
|
||||
{"program only", `w3wp.exe`, []string{`w3wp.exe`}},
|
||||
{"leading space", ` a b`, []string{``, `a`, `b`}},
|
||||
{"whitespace only", " \t ", []string{``}},
|
||||
{"trailing space", `a b `, []string{`a`, `b`}},
|
||||
{"tabs", "a\tb\t\tc", []string{`a`, `b`, `c`}},
|
||||
{"quoted program", `"C:\Program Files\w3wp.exe" -ap x`, []string{`C:\Program Files\w3wp.exe`, `-ap`, `x`}},
|
||||
{"quoted program backslash", `"C:\dir\" x`, []string{`C:\dir\`, `x`}},
|
||||
{"quoted program unterminated", `"C:\dir x`, []string{`C:\dir x`}},
|
||||
{"quoted program followed by text", `"a"b c`, []string{`a`, `b`, `c`}},
|
||||
{"program backslash quote", `a\"b c`, []string{`a\"b`, `c`}},
|
||||
{"empty quoted argument", `a "" b`, []string{`a`, ``, `b`}},
|
||||
{"trailing empty quoted argument", `a ""`, []string{`a`, ``}},
|
||||
{"quoted spaces", `a "b c" d`, []string{`a`, `b c`, `d`}},
|
||||
{"embedded quotes", `a b"c d"e`, []string{`a`, `bc de`}},
|
||||
{"unterminated quote", `a "b c`, []string{`a`, `b c`}},
|
||||
{"escaped quote", `a b\"c`, []string{`a`, `b"c`}},
|
||||
{"escaped backslash before quote", `a "b\\" c`, []string{`a`, `b\`, `c`}},
|
||||
{"three backslashes before quote", `a b\\\"c`, []string{`a`, `b\"c`}},
|
||||
{"backslashes without quote", `a \\server\share\ b`, []string{`a`, `\\server\share\`, `b`}},
|
||||
{"double quote in quoted part", `a "b""c" d`, []string{`a`, `b"c d`}},
|
||||
{"three quotes in quoted part", `a "b"""c d`, []string{`a`, `b"c d`}},
|
||||
{"control character ends program", "a\vb c", []string{`a`, `b`, `c`}},
|
||||
{"control character after program", "a \vb", []string{`a`, "\vb"}},
|
||||
{"control character after quoted program", "\"a\"\vb", []string{`a`, "\vb"}},
|
||||
{"control character in argument", "a b\vc\nd", []string{`a`, "b\vc\nd"}},
|
||||
{"unicode spaces", "a b c d", []string{"a b", "c d"}},
|
||||
{"three quotes", `a """b c`, []string{`a`, `"b`, `c`}},
|
||||
{"unicode", `a "Café Пул ✓ Ωμέγα" b`, []string{`a`, `Café Пул ✓ Ωμέγα`, `b`}},
|
||||
{"was command line", wasCommandLine, []string{
|
||||
`c:\windows\system32\inetsrv\w3wp.exe`, `-ap`, `DefaultAppPool`, `-v`, `v4.0`, `-l`, `webengine4.dll`,
|
||||
`-a`, `\\.\pipe\iisipm5f5e2b1c-8d6a-4b2e-9a0c-3d1b2f4e6a7c`, `-h`, `C:\inetpub\temp\apppools\DefaultAppPool\DefaultAppPool.config`,
|
||||
`-w`, ``, `-m`, `0`, `-t`, `20`, `-ta`, `0`,
|
||||
}},
|
||||
}
|
||||
|
||||
func TestSplitCommandLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range commandLineTests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tc.args, splitCommandLine(tc.cmdLine))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// splitCommandLine must split like CommandLineToArgvW, which is the reference implementation.
|
||||
func TestSplitCommandLineMatchesCommandLineToArgvW(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range commandLineTests {
|
||||
if tc.cmdLine == "" {
|
||||
// CommandLineToArgvW returns the path of the current executable for an empty command line.
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requireCommandLineToArgvW(t, tc.cmdLine)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppPoolFromCommandLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmdLine string
|
||||
pool string
|
||||
ok bool
|
||||
}{
|
||||
{"was command line", wasCommandLine, "DefaultAppPool", true},
|
||||
{"spaces", `w3wp.exe -ap "Classic .NET AppPool" -v "v2.0"`, "Classic .NET AppPool", true},
|
||||
{"unicode", `w3wp.exe -ap "Café Пул ✓ Ωμέγα 🚀" -v "v4.0"`, "Café Пул ✓ Ωμέγα 🚀", true},
|
||||
{"unquoted", `w3wp.exe -ap DefaultAppPool`, "DefaultAppPool", true},
|
||||
{"tab separated", "w3wp.exe\t-ap\t\"Pool\"", "Pool", true},
|
||||
{"upper case flag", `w3wp.exe -AP "Pool"`, "Pool", true},
|
||||
{"not first argument", `w3wp.exe -debug -ap "Pool" -v "v4.0"`, "Pool", true},
|
||||
{"first of duplicates", `w3wp.exe -ap "First" -ap "Second"`, "First", true},
|
||||
{"quoted program with spaces", `"C:\Program Files\w3wp.exe" -ap "Pool"`, "Pool", true},
|
||||
{"embedded quotes", `w3wp.exe -ap Default"App Pool"`, "DefaultApp Pool", true},
|
||||
{"escaped quote", `w3wp.exe -ap "a\"b"`, `a"b`, true},
|
||||
{"trailing backslash", `w3wp.exe -ap "a\\"`, `a\`, true},
|
||||
{"literal backslash", `w3wp.exe -ap "a\b"`, `a\b`, true},
|
||||
{"missing", `w3wp.exe -v "v4.0"`, "", false},
|
||||
{"no arguments", `c:\windows\system32\inetsrv\w3wp.exe`, "", false},
|
||||
{"empty command line", ``, "", false},
|
||||
{"missing value", `w3wp.exe -v "v4.0" -ap`, "", false},
|
||||
{"empty value", `w3wp.exe -ap "" -v "v4.0"`, "", false},
|
||||
{"flag inside quotes", `w3wp.exe "-ap Pool"`, "", false},
|
||||
{"flag prefix", `w3wp.exe -apx "Pool"`, "", false},
|
||||
{"slash flag", `w3wp.exe /ap "Pool"`, "", false},
|
||||
{"program named -ap", `-ap Pool`, "", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pool, ok := appPoolFromCommandLine(tc.cmdLine)
|
||||
require.Equal(t, tc.ok, ok)
|
||||
require.Equal(t, tc.pool, pool)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzSplitCommandLine(f *testing.F) {
|
||||
for _, tc := range commandLineTests {
|
||||
f.Add(tc.cmdLine)
|
||||
}
|
||||
|
||||
f.Add(`w3wp.exe -ap "a\\\"b" -ap`)
|
||||
f.Add(`"" "" ""`)
|
||||
f.Add(`a """"""" b`)
|
||||
f.Add("a\v\r\nb\u00a0c\u3000d")
|
||||
|
||||
f.Fuzz(func(t *testing.T, cmdLine string) {
|
||||
// CommandLineToArgvW takes NUL-terminated UTF-16 and returns the executable path for an empty command line.
|
||||
if cmdLine == "" || strings.ContainsRune(cmdLine, 0) || !utf8.ValidString(cmdLine) {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
args := requireCommandLineToArgvW(t, cmdLine)
|
||||
|
||||
var (
|
||||
want string
|
||||
wantOK bool
|
||||
)
|
||||
|
||||
for i := 1; i < len(args)-1; i++ {
|
||||
if strings.EqualFold(args[i], "-ap") {
|
||||
want, wantOK = args[i+1], args[i+1] != ""
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pool, ok := appPoolFromCommandLine(cmdLine)
|
||||
require.Equal(t, wantOK, ok)
|
||||
require.Equal(t, want, pool)
|
||||
})
|
||||
}
|
||||
|
||||
func requireCommandLineToArgvW(t *testing.T, cmdLine string) []string {
|
||||
t.Helper()
|
||||
|
||||
want, err := windows.DecomposeCommandLine(cmdLine)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := splitCommandLine(cmdLine)
|
||||
if len(want) == 0 {
|
||||
require.Empty(t, got, "command line %q", cmdLine)
|
||||
} else {
|
||||
require.Equal(t, want, got, "command line %q", cmdLine)
|
||||
}
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// The command line is read from a process that never ran, with an argument
|
||||
// list as WAS passes it, so no IIS installation is needed.
|
||||
func TestWorkerProcessAppPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
executable, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
const pool = "Café Пул ✓ Ωμέγα 🚀"
|
||||
|
||||
cmdLine := `"` + executable + `" -ap "` + pool + `" -v "v4.0" -a \\.\pipe\iisipm -w "" -m 0`
|
||||
|
||||
appName, err := windows.UTF16PtrFromString(executable)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmdLineUTF16, err := windows.UTF16FromString(cmdLine)
|
||||
require.NoError(t, err)
|
||||
|
||||
startupInfo := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))}
|
||||
|
||||
var processInfo windows.ProcessInformation
|
||||
|
||||
// CREATE_SUSPENDED: the process parameters exist, but the test binary never runs.
|
||||
require.NoError(t, windows.CreateProcess(appName, &cmdLineUTF16[0], nil, nil, false,
|
||||
windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, nil, &startupInfo, &processInfo))
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, windows.TerminateProcess(processInfo.Process, 1))
|
||||
require.NoError(t, windows.CloseHandle(processInfo.Thread))
|
||||
require.NoError(t, windows.CloseHandle(processInfo.Process))
|
||||
})
|
||||
|
||||
pid := processInfo.ProcessId
|
||||
|
||||
got, err := queryProcessCommandLine(pid)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cmdLine, got)
|
||||
|
||||
gotPool, err := workerProcessAppPool(pid)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pool, gotPool)
|
||||
}
|
||||
|
||||
func TestWorkerProcessAppPoolWithoutArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := workerProcessAppPool(windows.GetCurrentProcessId())
|
||||
require.ErrorIs(t, err, errNoAppPoolArgument)
|
||||
}
|
||||
|
||||
func TestQueryProcessCommandLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := queryProcessCommandLine(windows.GetCurrentProcessId())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, windows.UTF16PtrToString(windows.GetCommandLine()), got)
|
||||
}
|
||||
|
||||
func TestResolveAppPools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
commandLines := func(results map[uint32]error) func(uint32) (string, error) {
|
||||
return func(pid uint32) (string, error) {
|
||||
if err := results[pid]; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("native-%d", pid), nil
|
||||
}
|
||||
}
|
||||
|
||||
wmiResult := []WorkerProcess{
|
||||
{ProcessId: 4, AppPoolName: "wmi-4"},
|
||||
{ProcessId: 8, AppPoolName: "wmi-8"},
|
||||
{ProcessId: 12, AppPoolName: ""},
|
||||
}
|
||||
|
||||
t.Run("command line only", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(nil), func() ([]WorkerProcess, error) {
|
||||
t.Fatal("WMI must not be queried when every command line has the pool")
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[uint32]string{4: "native-4", 8: "native-8"}, pools)
|
||||
})
|
||||
|
||||
t.Run("WMI fallback", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := 0
|
||||
pools, err := resolveAppPools(logger, []uint32{4, 8, 12, 16}, commandLines(map[uint32]error{
|
||||
8: windows.ERROR_ACCESS_DENIED,
|
||||
12: errNoAppPoolArgument,
|
||||
16: windows.STATUS_INVALID_INFO_CLASS,
|
||||
}), func() ([]WorkerProcess, error) {
|
||||
calls++
|
||||
|
||||
return wmiResult, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, calls)
|
||||
// An empty WMI pool name and a process WMI doesn't know get no label.
|
||||
require.Equal(t, map[uint32]string{4: "native-4", 8: "wmi-8"}, pools)
|
||||
})
|
||||
|
||||
t.Run("exited process", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
|
||||
8: fmt.Errorf("OpenProcess: %w", windows.ERROR_INVALID_PARAMETER),
|
||||
}), func() ([]WorkerProcess, error) {
|
||||
t.Fatal("WMI must not be queried for exited processes")
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
|
||||
})
|
||||
|
||||
t.Run("without WMI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
|
||||
8: windows.ERROR_ACCESS_DENIED,
|
||||
}), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
|
||||
})
|
||||
|
||||
t.Run("WMI error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wmiErr := windows.ERROR_TIMEOUT
|
||||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
|
||||
8: windows.ERROR_ACCESS_DENIED,
|
||||
}), func() ([]WorkerProcess, error) {
|
||||
return nil, wmiErr
|
||||
})
|
||||
require.ErrorIs(t, err, wmiErr)
|
||||
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
|
||||
})
|
||||
}
|
||||
|
||||
// Grafana Alloy builds and closes collectors repeatedly. Without
|
||||
// root\WebAdministration, Build must succeed and use the command line only.
|
||||
func TestBuildCloseWorkerProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
miApp, err := mi.ApplicationInitialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, miApp.Close()) })
|
||||
|
||||
miSession, err := miApp.NewSession(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, miSession.Close()) })
|
||||
|
||||
c := New(&Config{
|
||||
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
|
||||
ProcessExclude: types.RegExpEmpty,
|
||||
EnableWorkerProcess: true,
|
||||
CounterVersion: 1,
|
||||
})
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
for _, session := range []*mi.Session{nil, miSession, nil} {
|
||||
require.NoError(t, c.Build(logger, session))
|
||||
|
||||
if session == nil {
|
||||
require.Nil(t, c.miSession)
|
||||
}
|
||||
|
||||
require.NoError(t, c.Close())
|
||||
require.NoError(t, c.Close())
|
||||
require.Nil(t, c.miSession)
|
||||
}
|
||||
}
|
||||
|
||||
// The command line source costs one OpenProcess and one NtQueryInformationProcess per worker process.
|
||||
func BenchmarkWorkerProcessAppPool(b *testing.B) {
|
||||
pid := windows.GetCurrentProcessId()
|
||||
|
||||
for b.Loop() {
|
||||
cmdLine, err := queryProcessCommandLine(pid)
|
||||
require.NoError(b, err)
|
||||
|
||||
appPoolFromCommandLine(cmdLine)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAppPoolFromCommandLine(b *testing.B) {
|
||||
for b.Loop() {
|
||||
appPoolFromCommandLine(wasCommandLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Errors must be the native Windows error, so callers can tell exited processes from denied access.
|
||||
func TestQueryProcessCommandLineMissingProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Process IDs are multiples of four and far below this value.
|
||||
_, err := queryProcessCommandLine(0xFFFFFFFC)
|
||||
require.ErrorIs(t, err, windows.ERROR_INVALID_PARAMETER)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Copyright The Prometheus Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package process
|
||||
|
||||
// WorkerProcessAppPool exposes the command line source to the IIS tests, which
|
||||
// must live in process_test because testutils imports the collector registry.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var WorkerProcessAppPool = workerProcessAppPool
|
||||
@@ -64,6 +64,8 @@ type Collector struct {
|
||||
|
||||
logger *slog.Logger
|
||||
|
||||
// miSession is set when root\WebAdministration answered during Build. It's
|
||||
// the fallback for worker processes whose command line can't be read.
|
||||
miSession *mi.Session
|
||||
workerProcessMIQueryQuery mi.Query
|
||||
|
||||
@@ -133,7 +135,7 @@ func NewWithFlags(app *kingpin.Application) *Collector {
|
||||
|
||||
app.Flag(
|
||||
"collector.process.iis",
|
||||
"Enable IIS collectWorker process name queries. May cause the collector to leak memory.",
|
||||
"Append the IIS application pool name to the process name of IIS worker processes (w3wp).",
|
||||
).Default(strconv.FormatBool(c.config.EnableWorkerProcess)).BoolVar(&c.config.EnableWorkerProcess)
|
||||
|
||||
app.Flag(
|
||||
@@ -183,6 +185,9 @@ func (c *Collector) Close() error {
|
||||
c.workerWG.Wait()
|
||||
}
|
||||
|
||||
// The session belongs to the caller of Build.
|
||||
c.miSession = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -329,31 +334,50 @@ func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
|
||||
nil,
|
||||
)
|
||||
|
||||
c.miSession = nil
|
||||
|
||||
if c.config.EnableWorkerProcess {
|
||||
if miSession == nil {
|
||||
return errors.New("miSession is nil")
|
||||
}
|
||||
|
||||
miQuery, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WMI query: %w", err)
|
||||
}
|
||||
|
||||
c.workerProcessMIQueryQuery = miQuery
|
||||
c.miSession = miSession
|
||||
|
||||
var workerProcesses []WorkerProcess
|
||||
|
||||
if err = c.miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, c.workerProcessMIQueryQuery, 0); err != nil {
|
||||
c.config.EnableWorkerProcess = false
|
||||
|
||||
return fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
|
||||
if err := c.buildWorkerProcessWMI(miSession); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildWorkerProcessWMI enables the root\WebAdministration fallback for application pool names
|
||||
// if the IIS WMI provider is installed. Without it, only the w3wp command line is used.
|
||||
func (c *Collector) buildWorkerProcessWMI(miSession *mi.Session) error {
|
||||
if miSession == nil {
|
||||
c.logger.LogAttrs(context.Background(), slog.LevelDebug,
|
||||
"No MI session, reading IIS application pool names from the w3wp command line only",
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
miQuery, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WMI query: %w", err)
|
||||
}
|
||||
|
||||
var workerProcesses []WorkerProcess
|
||||
|
||||
if err = miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, 0); err != nil {
|
||||
c.logger.LogAttrs(context.Background(), slog.LevelDebug,
|
||||
`root\WebAdministration isn't available, reading IIS application pool names from the w3wp command line only`,
|
||||
slog.Any("err", err),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
c.workerProcessMIQueryQuery = miQuery
|
||||
c.miSession = miSession
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
|
||||
return c.collect(ch, maxScrapeDuration)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Copyright The Prometheus Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package process_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/collector/process"
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/types"
|
||||
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// appPoolIdentityPrefix is the domain of ApplicationPoolIdentity virtual accounts,
|
||||
// whose names are the application pool names.
|
||||
const appPoolIdentityPrefix = `IIS APPPOOL\`
|
||||
|
||||
// The application pool read from each w3wp command line must match
|
||||
// WorkerProcess.AppPoolName and the ApplicationPoolIdentity of the process.
|
||||
func TestWorkerProcessAppPoolIIS(t *testing.T) {
|
||||
pids := requireWorkerProcesses(t)
|
||||
|
||||
appPools := make(map[uint32]string, len(pids))
|
||||
|
||||
for _, pid := range pids {
|
||||
appPool, err := process.WorkerProcessAppPool(pid)
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
// The worker process exited, for example after its idle timeout.
|
||||
continue
|
||||
}
|
||||
|
||||
if errors.Is(err, windows.ERROR_ACCESS_DENIED) && !iisRequired() {
|
||||
t.Skipf("worker process %d: %v; run the test elevated", pid, err)
|
||||
}
|
||||
|
||||
require.NoError(t, err, "worker process %d", pid)
|
||||
require.NotEmpty(t, appPool)
|
||||
|
||||
appPools[pid] = appPool
|
||||
|
||||
if owner := processOwner(t, pid); strings.HasPrefix(owner, appPoolIdentityPrefix) {
|
||||
require.Equal(t, strings.TrimPrefix(owner, appPoolIdentityPrefix), appPool, "worker process %d", pid)
|
||||
}
|
||||
}
|
||||
|
||||
workerProcesses, err := queryWorkerProcesses(t)
|
||||
if err != nil {
|
||||
t.Logf(`root\WebAdministration isn't available, skipping the WMI comparison: %v`, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, wp := range workerProcesses {
|
||||
if appPool, ok := appPools[uint32(wp.ProcessId)]; ok {
|
||||
require.Equal(t, wp.AppPoolName, appPool, "worker process %d", wp.ProcessId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The collector must publish the application pool in the process label.
|
||||
func TestCollectorIISAppPool(t *testing.T) {
|
||||
requireWorkerProcesses(t)
|
||||
|
||||
families := testutils.TestCollector(t, process.New, &process.Config{
|
||||
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
|
||||
ProcessExclude: types.RegExpEmpty,
|
||||
EnableWorkerProcess: true,
|
||||
CounterVersion: 1,
|
||||
})
|
||||
|
||||
require.Contains(t, families, "windows_process_info")
|
||||
|
||||
var labeled int
|
||||
|
||||
for _, metric := range families["windows_process_info"].GetMetric() {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range metric.GetLabel() {
|
||||
labels[label.GetName()] = label.GetValue()
|
||||
}
|
||||
|
||||
owner := labels["owner"]
|
||||
if !strings.HasPrefix(owner, appPoolIdentityPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
require.Equal(t, "w3wp_"+strings.TrimPrefix(owner, appPoolIdentityPrefix), labels["process"], "process_id %s", labels["process_id"])
|
||||
|
||||
labeled++
|
||||
}
|
||||
|
||||
if iisRequired() {
|
||||
require.Positive(t, labeled, "no worker process runs as an ApplicationPoolIdentity")
|
||||
}
|
||||
}
|
||||
|
||||
// A suspended copy of the test binary named w3wp.exe, started with a WAS
|
||||
// command line, must get the pool in its process label without IIS.
|
||||
func TestCollectorWorkerProcessCommandLine(t *testing.T) {
|
||||
executable, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(executable)
|
||||
require.NoError(t, err)
|
||||
|
||||
workerProcess := filepath.Join(t.TempDir(), "w3wp.exe")
|
||||
//nolint:gosec // The destination is a fixed filename in a test-owned temporary directory.
|
||||
require.NoError(t, os.WriteFile(workerProcess, content, 0o600))
|
||||
|
||||
const appPool = "Test Pool Ωμέγα 🚀"
|
||||
|
||||
pid := startSuspended(t, workerProcess, `"`+workerProcess+`" -ap "`+appPool+`" -v "v4.0" -w "" -m 0`)
|
||||
|
||||
families := testutils.TestCollector(t, process.New, &process.Config{
|
||||
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
|
||||
ProcessExclude: types.RegExpEmpty,
|
||||
EnableWorkerProcess: true,
|
||||
CounterVersion: 1,
|
||||
})
|
||||
|
||||
if _, ok := families["windows_process_info"]; !ok {
|
||||
t.Skip("the Process counter set has no w3wp instance")
|
||||
}
|
||||
|
||||
for _, metric := range families["windows_process_info"].GetMetric() {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range metric.GetLabel() {
|
||||
labels[label.GetName()] = label.GetValue()
|
||||
}
|
||||
|
||||
if labels["process_id"] == strconv.FormatUint(uint64(pid), 10) {
|
||||
require.Equal(t, "w3wp_"+appPool, labels["process"])
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("windows_process_info has no series for process %d", pid)
|
||||
}
|
||||
|
||||
// startSuspended starts a process that never runs and returns its ID.
|
||||
func startSuspended(t *testing.T, path, cmdLine string) uint32 {
|
||||
t.Helper()
|
||||
|
||||
appName, err := windows.UTF16PtrFromString(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmdLineUTF16, err := windows.UTF16FromString(cmdLine)
|
||||
require.NoError(t, err)
|
||||
|
||||
startupInfo := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))}
|
||||
|
||||
var processInfo windows.ProcessInformation
|
||||
|
||||
require.NoError(t, windows.CreateProcess(appName, &cmdLineUTF16[0], nil, nil, false,
|
||||
windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, nil, &startupInfo, &processInfo))
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, windows.TerminateProcess(processInfo.Process, 1))
|
||||
_, _ = windows.WaitForSingleObject(processInfo.Process, windows.INFINITE)
|
||||
require.NoError(t, windows.CloseHandle(processInfo.Thread))
|
||||
require.NoError(t, windows.CloseHandle(processInfo.Process))
|
||||
})
|
||||
|
||||
return processInfo.ProcessId
|
||||
}
|
||||
|
||||
// iisRequired reports whether CI provisioned IIS, so a missing worker process is a failure.
|
||||
func iisRequired() bool {
|
||||
return slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), "iis")
|
||||
}
|
||||
|
||||
// requireWorkerProcesses returns the IIS worker processes. If W3SVC runs
|
||||
// without one, it requests the local default site to start one.
|
||||
func requireWorkerProcesses(t *testing.T) []uint32 {
|
||||
t.Helper()
|
||||
|
||||
if pids := workerProcessIDs(t); len(pids) > 0 {
|
||||
return pids
|
||||
}
|
||||
|
||||
if !serviceRunning(t, "W3SVC") {
|
||||
if iisRequired() {
|
||||
t.Fatal("W3SVC isn't running")
|
||||
}
|
||||
|
||||
t.Skip("IIS isn't running")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1/", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
} else {
|
||||
t.Logf("request to start a worker process failed: %v", err)
|
||||
}
|
||||
|
||||
pids := workerProcessIDs(t)
|
||||
if len(pids) == 0 {
|
||||
if iisRequired() {
|
||||
t.Fatal("no IIS worker process is running")
|
||||
}
|
||||
|
||||
t.Skip("no IIS worker process is running")
|
||||
}
|
||||
|
||||
return pids
|
||||
}
|
||||
|
||||
func workerProcessIDs(t *testing.T) []uint32 {
|
||||
t.Helper()
|
||||
|
||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = windows.CloseHandle(snapshot) }()
|
||||
|
||||
var (
|
||||
pids []uint32
|
||||
entry = windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))}
|
||||
)
|
||||
|
||||
for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) {
|
||||
if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), "w3wp.exe") {
|
||||
pids = append(pids, entry.ProcessID)
|
||||
}
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, windows.ERROR_NO_MORE_FILES)
|
||||
|
||||
return pids
|
||||
}
|
||||
|
||||
func serviceRunning(t *testing.T, name string) bool {
|
||||
t.Helper()
|
||||
|
||||
scm, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = windows.CloseServiceHandle(scm) }()
|
||||
|
||||
serviceName, err := windows.UTF16PtrFromString(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
h, err := windows.OpenService(scm, serviceName, windows.SERVICE_QUERY_STATUS)
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
return false
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = windows.CloseServiceHandle(h) }()
|
||||
|
||||
var status windows.SERVICE_STATUS
|
||||
|
||||
require.NoError(t, windows.QueryServiceStatus(h, &status))
|
||||
|
||||
return status.CurrentState == windows.SERVICE_RUNNING
|
||||
}
|
||||
|
||||
// processOwner returns the DOMAIN\user owner of pid, or "" if it can't be read.
|
||||
func processOwner(t *testing.T, pid uint32) string {
|
||||
t.Helper()
|
||||
|
||||
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer func() { _ = windows.CloseHandle(hProcess) }()
|
||||
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(hProcess, windows.TOKEN_QUERY, &token); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer func() { _ = token.Close() }()
|
||||
|
||||
user, err := token.GetTokenUser()
|
||||
require.NoError(t, err)
|
||||
|
||||
account, domain, _, err := user.User.Sid.LookupAccount("")
|
||||
if err != nil {
|
||||
t.Logf("worker process %d: LookupAccountSid: %v", pid, err)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
return domain + `\` + account
|
||||
}
|
||||
|
||||
func queryWorkerProcesses(t *testing.T) ([]process.WorkerProcess, error) {
|
||||
t.Helper()
|
||||
|
||||
miApp, err := mi.ApplicationInitialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, miApp.Close()) })
|
||||
|
||||
miSession, err := miApp.NewSession(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, miSession.Close()) })
|
||||
|
||||
query, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
|
||||
require.NoError(t, err)
|
||||
|
||||
var workerProcesses []process.WorkerProcess
|
||||
|
||||
if err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, query, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, wp := range workerProcesses {
|
||||
t.Logf("WorkerProcess %s: %q", strconv.FormatUint(wp.ProcessId, 10), wp.AppPoolName)
|
||||
}
|
||||
|
||||
return workerProcesses, nil
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
@@ -29,14 +30,15 @@ import (
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type processWorkerRequest struct {
|
||||
ch chan<- prometheus.Metric
|
||||
name string
|
||||
appPoolName string
|
||||
performanceCounterValues perfDataCounterValues
|
||||
waitGroup *sync.WaitGroup
|
||||
workerProcesses []WorkerProcess
|
||||
}
|
||||
|
||||
func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
|
||||
@@ -45,16 +47,9 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
|
||||
return fmt.Errorf("failed to collect metrics: %w", err)
|
||||
}
|
||||
|
||||
err = nil
|
||||
requests := make([]processWorkerRequest, 0, len(c.perfDataObject))
|
||||
|
||||
var workerProcesses []WorkerProcess
|
||||
if c.config.EnableWorkerProcess {
|
||||
if err = c.miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, c.workerProcessMIQueryQuery, maxScrapeDuration); err != nil {
|
||||
err = fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
var workerProcessIDs []uint32
|
||||
|
||||
for _, process := range c.perfDataObject {
|
||||
// Duplicate processes are suffixed #, and an index number. Remove those.
|
||||
@@ -77,15 +72,32 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
if c.config.EnableWorkerProcess && isWorkerProcess(name) {
|
||||
workerProcessIDs = append(workerProcessIDs, uint32(process.ProcessID))
|
||||
}
|
||||
|
||||
c.workerCh <- processWorkerRequest{
|
||||
requests = append(requests, processWorkerRequest{
|
||||
ch: ch,
|
||||
name: name,
|
||||
performanceCounterValues: process,
|
||||
workerProcesses: workerProcesses,
|
||||
waitGroup: wg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var appPools map[uint32]string
|
||||
|
||||
if len(workerProcessIDs) > 0 {
|
||||
appPools, err = resolveAppPools(c.logger, workerProcessIDs, workerProcessAppPool, c.workerProcessWMIQuery(maxScrapeDuration))
|
||||
}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
|
||||
for _, req := range requests {
|
||||
req.appPoolName = appPools[uint32(req.performanceCounterValues.ProcessID)]
|
||||
req.waitGroup = wg
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
c.workerCh <- req
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
@@ -93,6 +105,80 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
|
||||
return err
|
||||
}
|
||||
|
||||
// workerProcessWMIQuery returns the root\WebAdministration query, or nil if it was unavailable during Build.
|
||||
func (c *Collector) workerProcessWMIQuery(maxScrapeDuration time.Duration) func() ([]WorkerProcess, error) {
|
||||
miSession, miQuery := c.miSession, c.workerProcessMIQueryQuery
|
||||
if miSession == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() ([]WorkerProcess, error) {
|
||||
var workerProcesses []WorkerProcess
|
||||
|
||||
err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, maxScrapeDuration)
|
||||
|
||||
return workerProcesses, err
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAppPools maps IIS worker process IDs to application pool names.
|
||||
//
|
||||
// The command line of the worker process is the primary source: it needs no
|
||||
// optional feature and no WMI round trip. WMI is queried at most once, for the
|
||||
// worker processes whose command line couldn't be read or has no -ap argument,
|
||||
// and only if queryWMI isn't nil. Unresolved processes have no entry.
|
||||
func resolveAppPools(
|
||||
logger *slog.Logger,
|
||||
pids []uint32,
|
||||
readCommandLine func(pid uint32) (string, error),
|
||||
queryWMI func() ([]WorkerProcess, error),
|
||||
) (map[uint32]string, error) {
|
||||
appPools := make(map[uint32]string, len(pids))
|
||||
|
||||
var unresolved []uint32
|
||||
|
||||
for _, pid := range pids {
|
||||
appPool, err := readCommandLine(pid)
|
||||
if err == nil {
|
||||
appPools[pid] = appPool
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.LogAttrs(context.Background(), slog.LevelDebug, "Failed to read the IIS application pool from the worker process command line",
|
||||
slog.Uint64("pid", uint64(pid)),
|
||||
slog.Any("err", err),
|
||||
)
|
||||
|
||||
// OpenProcess fails with ERROR_INVALID_PARAMETER for processes that exited after the
|
||||
// performance counter snapshot. WAS no longer reports them either.
|
||||
if !errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
unresolved = append(unresolved, pid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unresolved) == 0 || queryWMI == nil {
|
||||
return appPools, nil
|
||||
}
|
||||
|
||||
workerProcesses, err := queryWMI()
|
||||
if err != nil {
|
||||
return appPools, fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
|
||||
}
|
||||
|
||||
for _, pid := range unresolved {
|
||||
for _, wp := range workerProcesses {
|
||||
if wp.ProcessId == uint64(pid) && wp.AppPoolName != "" {
|
||||
appPools[pid] = wp.AppPoolName
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return appPools, nil
|
||||
}
|
||||
|
||||
func (c *Collector) collectWorker(requests <-chan processWorkerRequest) {
|
||||
defer c.workerWG.Done()
|
||||
|
||||
@@ -115,14 +201,8 @@ func (c *Collector) collectWorker(requests <-chan processWorkerRequest) {
|
||||
pid := uint64(data.ProcessID)
|
||||
parentPID := strconv.FormatUint(uint64(data.CreatingProcessID), 10)
|
||||
|
||||
if c.config.EnableWorkerProcess {
|
||||
for _, wp := range req.workerProcesses {
|
||||
if wp.ProcessId == pid {
|
||||
name = strings.Join([]string{name, wp.AppPoolName}, "_")
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if req.appPoolName != "" {
|
||||
name = name + "_" + req.appPoolName
|
||||
}
|
||||
|
||||
cmdLine, processOwner, processGroupID, err := c.getProcessInformation(uint32(pid))
|
||||
|
||||
Reference in New Issue
Block a user