Files
windows_exporter/internal/collector/process/process_iis_test.go
T
2026-10-10 16:09:02 +02:00

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
}