mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 18:19:04 +02:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
349 lines
9.9 KiB
Go
349 lines
9.9 KiB
Go
// 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,
|
|
})
|
|
|
|
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,
|
|
})
|
|
|
|
// The process snapshot includes suspended processes.
|
|
require.Contains(t, families, "windows_process_info", "no w3wp process in the process snapshot")
|
|
|
|
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
|
|
}
|