mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 10:09:05 +02:00
printer: read printers and jobs from the spooler API (#2577)
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
cb5b4cb0bf
commit
7c200078ef
@@ -22,17 +22,28 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/prometheus-community/windows_exporter/internal/headers/winspool"
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const Name = "printer"
|
||||
|
||||
// Win32_Printer PrinterStatus values.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer
|
||||
const (
|
||||
printerStatusOther uint16 = 1
|
||||
printerStatusUnknown uint16 = 2
|
||||
printerStatusIdle uint16 = 3
|
||||
printerStatusPrinting uint16 = 4
|
||||
printerStatusWarmup uint16 = 5
|
||||
)
|
||||
|
||||
// printerStatusMap source: https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer#:~:text=Power%20Save-,PrinterStatus,Offline%20(7),-PrintJobDataType
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
@@ -58,10 +69,7 @@ var ConfigDefaults = Config{
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
config Config
|
||||
miSession *mi.Session
|
||||
miQueryPrinterJobs mi.Query
|
||||
miQueryPrinter mi.Query
|
||||
config Config
|
||||
|
||||
printerStatus *prometheus.Desc
|
||||
printerJobStatus *prometheus.Desc
|
||||
@@ -128,7 +136,7 @@ func (c *Collector) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
|
||||
func (c *Collector) Build(_ *slog.Logger, _ *mi.Session) error {
|
||||
c.printerJobStatus = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "job_status"),
|
||||
"A counter of printer jobs by status",
|
||||
@@ -148,86 +156,49 @@ func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
|
||||
nil,
|
||||
)
|
||||
|
||||
if miSession == nil {
|
||||
return errors.New("miSession is nil")
|
||||
}
|
||||
|
||||
miQuery, err := mi.NewQuery("SELECT Name, Default, PrinterStatus, JobCountSinceLastReset FROM win32_Printer")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WMI query: %w", err)
|
||||
}
|
||||
|
||||
c.miQueryPrinter = miQuery
|
||||
|
||||
miQuery, err = mi.NewQuery("SELECT Name, Status FROM win32_PrintJob")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WMI query: %w", err)
|
||||
}
|
||||
|
||||
c.miQueryPrinterJobs = miQuery
|
||||
c.miSession = miSession
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Collector) GetName() string { return Name }
|
||||
|
||||
type wmiPrinter struct {
|
||||
Name string `mi:"Name"`
|
||||
Default bool `mi:"Default"`
|
||||
PrinterStatus uint16 `mi:"PrinterStatus"`
|
||||
JobCountSinceLastReset uint32 `mi:"JobCountSinceLastReset"`
|
||||
}
|
||||
// Collect reads printers and jobs from the print spooler.
|
||||
// Win32_Printer is not used because the CIMWin32 provider calls DeviceCapabilities
|
||||
// for every printer, which blocks for about a minute on an unreachable network printer.
|
||||
func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error {
|
||||
// CIMWin32 enumerates the same printers for Win32_Printer.
|
||||
printers, err := winspool.EnumPrinters(winspool.PRINTER_ENUM_LOCAL | winspool.PRINTER_ENUM_CONNECTIONS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enumerate printers: %w", err)
|
||||
}
|
||||
|
||||
type wmiPrintJob struct {
|
||||
Name string `mi:"Name"`
|
||||
Status string `mi:"Status"`
|
||||
}
|
||||
|
||||
// minQueryTimeout keeps the job query bounded once the scrape budget is spent.
|
||||
// MI counts timeouts in whole milliseconds, and a zero timeout means no timeout at all.
|
||||
const minQueryTimeout = time.Millisecond
|
||||
|
||||
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
|
||||
var errs []error
|
||||
|
||||
deadline := time.Now().Add(maxScrapeDuration)
|
||||
|
||||
// Win32_Printer calls DeviceCapabilities for every printer, whichever properties are selected.
|
||||
// For an IPP printer that is offline, this blocks until the network requests time out,
|
||||
// which can take close to a minute. Bound the query by the scrape timeout, so that a hung call
|
||||
// fails with the native MI timeout instead of blocking the collector for later scrapes.
|
||||
if err := c.collectPrinterStatus(ch, maxScrapeDuration); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to collect printer status metrics: %w", err))
|
||||
}
|
||||
|
||||
jobQueryTimeout := maxScrapeDuration
|
||||
if maxScrapeDuration > 0 {
|
||||
jobQueryTimeout = max(time.Until(deadline), minQueryTimeout)
|
||||
}
|
||||
|
||||
if err := c.collectPrinterJobStatus(ch, jobQueryTimeout); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to collect printer job status metrics: %w", err))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Collector) collectPrinterStatus(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
|
||||
var printers []wmiPrinter
|
||||
if err := c.miSession.Query(&printers, mi.NamespaceRootCIMv2, c.miQueryPrinter, maxScrapeDuration); err != nil {
|
||||
return fmt.Errorf("WMI query failed: %w", err)
|
||||
}
|
||||
|
||||
for _, printer := range printers {
|
||||
if c.config.PrinterExclude.MatchString(printer.Name) ||
|
||||
!c.config.PrinterInclude.MatchString(printer.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
for printerStatus, printerStatusName := range printerStatusMap {
|
||||
// Win32_Printer JobCountSinceLastReset is PRINTER_INFO_2.cJobs, the number of queued jobs.
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.printerJobCount,
|
||||
prometheus.CounterValue,
|
||||
float64(printer.Jobs),
|
||||
printer.Name,
|
||||
)
|
||||
|
||||
jobs, err := printerJobs(printer)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to enumerate jobs of printer %s: %w", printer.Name, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
currentStatus := printerStatus(printer.Status, jobs)
|
||||
|
||||
for status, statusName := range printerStatusMap {
|
||||
isCurrentStatus := 0.0
|
||||
if printerStatus == printer.PrinterStatus {
|
||||
if status == currentStatus {
|
||||
isCurrentStatus = 1.0
|
||||
}
|
||||
|
||||
@@ -236,62 +207,128 @@ func (c *Collector) collectPrinterStatus(ch chan<- prometheus.Metric, maxScrapeD
|
||||
prometheus.GaugeValue,
|
||||
isCurrentStatus,
|
||||
printer.Name,
|
||||
printerStatusName,
|
||||
statusName,
|
||||
)
|
||||
}
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.printerJobCount,
|
||||
prometheus.CounterValue,
|
||||
float64(printer.JobCountSinceLastReset),
|
||||
printer.Name,
|
||||
)
|
||||
for status, count := range groupJobsByStatus(jobs) {
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.printerJobStatus,
|
||||
prometheus.GaugeValue,
|
||||
float64(count),
|
||||
printer.Name,
|
||||
status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Collector) collectPrinterJobStatus(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
|
||||
var printJobs []wmiPrintJob
|
||||
if err := c.miSession.Query(&printJobs, mi.NamespaceRootCIMv2, c.miQueryPrinterJobs, maxScrapeDuration); err != nil {
|
||||
return fmt.Errorf("WMI query failed: %w", err)
|
||||
// printerJobs returns the jobs of the printer in queue order.
|
||||
func printerJobs(printer winspool.Printer) (_ []winspool.Job, err error) {
|
||||
// Skip OpenPrinter for an empty queue. For a printer connection, it can contact the print server.
|
||||
if printer.Jobs == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
groupedPrintJobs := c.groupPrintJobs(printJobs)
|
||||
for group, count := range groupedPrintJobs {
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.printerJobStatus,
|
||||
prometheus.GaugeValue,
|
||||
float64(count),
|
||||
group.printerName,
|
||||
group.status,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type PrintJobStatusGroup struct {
|
||||
printerName string
|
||||
status string
|
||||
}
|
||||
|
||||
func (c *Collector) groupPrintJobs(printJobs []wmiPrintJob) map[PrintJobStatusGroup]int {
|
||||
groupedPrintJobs := make(map[PrintJobStatusGroup]int)
|
||||
|
||||
for _, printJob := range printJobs {
|
||||
printerName, _, _ := strings.Cut(printJob.Name, ",")
|
||||
|
||||
if c.config.PrinterExclude.MatchString(printerName) ||
|
||||
!c.config.PrinterInclude.MatchString(printerName) {
|
||||
continue
|
||||
handle, err := winspool.OpenPrinter(printer.Name)
|
||||
if err != nil {
|
||||
// The printer was deleted after EnumPrinters.
|
||||
if errors.Is(err, windows.ERROR_INVALID_PRINTER_NAME) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
groupedPrintJobs[PrintJobStatusGroup{
|
||||
printerName: printerName,
|
||||
status: printJob.Status,
|
||||
}]++
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return groupedPrintJobs
|
||||
defer func() {
|
||||
err = errors.Join(err, winspool.ClosePrinter(handle))
|
||||
}()
|
||||
|
||||
return winspool.EnumJobs(handle)
|
||||
}
|
||||
|
||||
// printerStatus returns the Win32_Printer PrinterStatus value for PRINTER_INFO_2.Status,
|
||||
// mapped the same way as the CIMWin32 provider (cimwin32.dll).
|
||||
//
|
||||
// CIMWin32 compares the whole Status value with single PRINTER_STATUS_* flags.
|
||||
// For 0 and for combinations of flags, it uses the first job in the queue instead.
|
||||
// It never reports Stopped Printing (6) or Offline (7); an offline printer is Other (1).
|
||||
func printerStatus(status uint32, jobs []winspool.Job) uint16 {
|
||||
switch status {
|
||||
case winspool.PRINTER_STATUS_PAUSED,
|
||||
winspool.PRINTER_STATUS_ERROR,
|
||||
winspool.PRINTER_STATUS_PENDING_DELETION,
|
||||
winspool.PRINTER_STATUS_PAPER_JAM,
|
||||
winspool.PRINTER_STATUS_PAPER_OUT,
|
||||
winspool.PRINTER_STATUS_MANUAL_FEED,
|
||||
winspool.PRINTER_STATUS_PAPER_PROBLEM,
|
||||
winspool.PRINTER_STATUS_OFFLINE,
|
||||
winspool.PRINTER_STATUS_OUTPUT_BIN_FULL,
|
||||
winspool.PRINTER_STATUS_TONER_LOW,
|
||||
winspool.PRINTER_STATUS_NO_TONER,
|
||||
winspool.PRINTER_STATUS_PAGE_PUNT,
|
||||
winspool.PRINTER_STATUS_USER_INTERVENTION,
|
||||
winspool.PRINTER_STATUS_OUT_OF_MEMORY,
|
||||
winspool.PRINTER_STATUS_DOOR_OPEN:
|
||||
return printerStatusOther
|
||||
case winspool.PRINTER_STATUS_IO_ACTIVE,
|
||||
winspool.PRINTER_STATUS_BUSY,
|
||||
winspool.PRINTER_STATUS_PRINTING,
|
||||
winspool.PRINTER_STATUS_PROCESSING:
|
||||
return printerStatusPrinting
|
||||
case winspool.PRINTER_STATUS_NOT_AVAILABLE:
|
||||
return printerStatusUnknown
|
||||
case winspool.PRINTER_STATUS_WAITING,
|
||||
winspool.PRINTER_STATUS_SERVER_UNKNOWN,
|
||||
winspool.PRINTER_STATUS_POWER_SAVE:
|
||||
return printerStatusIdle
|
||||
case winspool.PRINTER_STATUS_INITIALIZING,
|
||||
winspool.PRINTER_STATUS_WARMING_UP:
|
||||
return printerStatusWarmup
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
return printerStatusIdle
|
||||
}
|
||||
|
||||
job := jobs[0]
|
||||
|
||||
switch {
|
||||
case job.StatusText != "":
|
||||
return printerStatusUnknown
|
||||
case job.Status&(winspool.JOB_STATUS_PAUSED|winspool.JOB_STATUS_ERROR|winspool.JOB_STATUS_DELETING|
|
||||
winspool.JOB_STATUS_OFFLINE|winspool.JOB_STATUS_PAPEROUT|winspool.JOB_STATUS_PRINTED) != 0:
|
||||
return printerStatusOther
|
||||
case job.Status&(winspool.JOB_STATUS_SPOOLING|winspool.JOB_STATUS_PRINTING) != 0:
|
||||
return printerStatusPrinting
|
||||
default:
|
||||
return printerStatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// jobStatus returns the Win32_PrintJob Status value for JOB_INFO_1.Status,
|
||||
// mapped the same way as the CIMWin32 provider (cimwin32.dll).
|
||||
func jobStatus(status uint32) string {
|
||||
switch {
|
||||
case status&winspool.JOB_STATUS_ERROR != 0:
|
||||
return "Error"
|
||||
case status&(winspool.JOB_STATUS_PAUSED|winspool.JOB_STATUS_OFFLINE|winspool.JOB_STATUS_PAPEROUT) != 0:
|
||||
return "Degraded"
|
||||
case status&(winspool.JOB_STATUS_DELETING|winspool.JOB_STATUS_SPOOLING|winspool.JOB_STATUS_PRINTING|winspool.JOB_STATUS_PRINTED) != 0:
|
||||
return "OK"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
func groupJobsByStatus(jobs []winspool.Job) map[string]int {
|
||||
groupedJobs := make(map[string]int)
|
||||
|
||||
for _, job := range jobs {
|
||||
groupedJobs[jobStatus(job.Status)]++
|
||||
}
|
||||
|
||||
return groupedJobs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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 printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/headers/winspool"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The expected values follow the PrinterStatus switch in cimwin32.dll (10.0.26100).
|
||||
func TestPrinterStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for status, want := range map[uint32]uint16{
|
||||
winspool.PRINTER_STATUS_PAUSED: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_ERROR: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_PENDING_DELETION: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_PAPER_JAM: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_PAPER_OUT: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_MANUAL_FEED: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_PAPER_PROBLEM: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_OFFLINE: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_IO_ACTIVE: printerStatusPrinting,
|
||||
winspool.PRINTER_STATUS_BUSY: printerStatusPrinting,
|
||||
winspool.PRINTER_STATUS_PRINTING: printerStatusPrinting,
|
||||
winspool.PRINTER_STATUS_OUTPUT_BIN_FULL: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_NOT_AVAILABLE: printerStatusUnknown,
|
||||
winspool.PRINTER_STATUS_WAITING: printerStatusIdle,
|
||||
winspool.PRINTER_STATUS_PROCESSING: printerStatusPrinting,
|
||||
winspool.PRINTER_STATUS_INITIALIZING: printerStatusWarmup,
|
||||
winspool.PRINTER_STATUS_WARMING_UP: printerStatusWarmup,
|
||||
winspool.PRINTER_STATUS_TONER_LOW: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_NO_TONER: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_PAGE_PUNT: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_USER_INTERVENTION: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_OUT_OF_MEMORY: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_DOOR_OPEN: printerStatusOther,
|
||||
winspool.PRINTER_STATUS_SERVER_UNKNOWN: printerStatusIdle,
|
||||
winspool.PRINTER_STATUS_POWER_SAVE: printerStatusIdle,
|
||||
} {
|
||||
t.Run(fmt.Sprintf("0x%08X", status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A single flag ignores the jobs.
|
||||
require.Equal(t, want, printerStatus(status, nil))
|
||||
require.Equal(t, want, printerStatus(status, []winspool.Job{{Status: winspool.JOB_STATUS_ERROR}}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrinterStatusFromJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status uint32
|
||||
jobs []winspool.Job
|
||||
want uint16
|
||||
}{
|
||||
{name: "empty queue", jobs: nil, want: printerStatusIdle},
|
||||
{name: "spooling", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_SPOOLING}}, want: printerStatusPrinting},
|
||||
{name: "printing", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTING}}, want: printerStatusPrinting},
|
||||
{name: "paused", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PAUSED | winspool.JOB_STATUS_SPOOLING}}, want: printerStatusOther},
|
||||
{name: "error", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_ERROR | winspool.JOB_STATUS_PRINTING}}, want: printerStatusOther},
|
||||
{name: "printed", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTED}}, want: printerStatusOther},
|
||||
{name: "no job flags", jobs: []winspool.Job{{Status: 0}}, want: printerStatusUnknown},
|
||||
{name: "blocked only", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_BLOCKED_DEVQ}}, want: printerStatusUnknown},
|
||||
{name: "status text", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTING, StatusText: "Toner low"}}, want: printerStatusUnknown},
|
||||
{
|
||||
name: "only the first job counts",
|
||||
jobs: []winspool.Job{{Status: winspool.JOB_STATUS_SPOOLING}, {Status: winspool.JOB_STATUS_ERROR}},
|
||||
want: printerStatusPrinting,
|
||||
},
|
||||
{
|
||||
name: "combined printer flags use the jobs",
|
||||
status: winspool.PRINTER_STATUS_PAUSED | winspool.PRINTER_STATUS_OFFLINE,
|
||||
jobs: nil,
|
||||
want: printerStatusIdle,
|
||||
},
|
||||
{
|
||||
name: "server offline uses the jobs",
|
||||
status: winspool.PRINTER_STATUS_SERVER_OFFLINE,
|
||||
jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTING}},
|
||||
want: printerStatusPrinting,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tc.want, printerStatus(tc.status, tc.jobs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The expected values follow the Win32_PrintJob Status logic in cimwin32.dll (10.0.26100).
|
||||
func TestJobStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for status, want := range map[uint32]string{
|
||||
0: "UNKNOWN",
|
||||
winspool.JOB_STATUS_BLOCKED_DEVQ: "UNKNOWN",
|
||||
winspool.JOB_STATUS_SPOOLING: "OK",
|
||||
winspool.JOB_STATUS_PRINTING: "OK",
|
||||
winspool.JOB_STATUS_DELETING: "OK",
|
||||
winspool.JOB_STATUS_PRINTED: "OK",
|
||||
winspool.JOB_STATUS_PAUSED: "Degraded",
|
||||
winspool.JOB_STATUS_OFFLINE: "Degraded",
|
||||
winspool.JOB_STATUS_PAPEROUT: "Degraded",
|
||||
winspool.JOB_STATUS_ERROR: "Error",
|
||||
winspool.JOB_STATUS_PAUSED | winspool.JOB_STATUS_SPOOLING: "Degraded",
|
||||
winspool.JOB_STATUS_ERROR | winspool.JOB_STATUS_PAUSED: "Error",
|
||||
} {
|
||||
require.Equal(t, want, jobStatus(status), "status 0x%08X", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupJobsByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, map[string]int{"OK": 2, "Error": 1}, groupJobsByStatus([]winspool.Job{
|
||||
{Status: winspool.JOB_STATUS_SPOOLING},
|
||||
{Status: winspool.JOB_STATUS_PRINTING},
|
||||
{Status: winspool.JOB_STATUS_ERROR | winspool.JOB_STATUS_PRINTING},
|
||||
}))
|
||||
require.Empty(t, groupJobsByStatus(nil))
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/prometheus-community/windows_exporter/internal/collector/printer"
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -45,28 +44,18 @@ func TestCollector(t *testing.T) {
|
||||
testutils.RequireFixtureMetric(t, metrics, printer.Name, "windows_printer_job_count", prometheus.Labels{"printer": "CIPrinter"})
|
||||
}
|
||||
|
||||
// TestCollectorScrapeTimeout checks that the Win32_Printer query is bounded by the scrape timeout.
|
||||
// Win32_Printer can block for close to a minute on an offline IPP printer; without a timeout,
|
||||
// that call keeps the collector busy across scrapes.
|
||||
func TestCollectorScrapeTimeout(t *testing.T) {
|
||||
miApp, err := mi.ApplicationInitialize()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, miApp.Close()) })
|
||||
|
||||
miSession, err := miApp.NewSession(nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, miSession.Close()) })
|
||||
// TestCollectorWithoutMISession checks that the collector does not depend on WMI.
|
||||
// Win32_Printer can block for close to a minute on an offline IPP printer.
|
||||
func TestCollectorWithoutMISession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := printer.New(nil)
|
||||
|
||||
t.Cleanup(func() { assert.NoError(t, c.Close()) })
|
||||
|
||||
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), miSession))
|
||||
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil))
|
||||
|
||||
ch := make(chan prometheus.Metric, 1000)
|
||||
|
||||
// 1ms is the smallest timeout MI enforces, and well below the time Win32_Printer needs.
|
||||
err = c.Collect(ch, time.Millisecond)
|
||||
require.ErrorIs(t, err, mi.MI_RESULT_INVALID_OPERATION_TIMEOUT)
|
||||
require.ErrorContains(t, err, "failed to collect printer status metrics")
|
||||
require.NoError(t, c.Collect(ch, time.Second))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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 winspool
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// EnumPrinters flags.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinters
|
||||
const (
|
||||
PRINTER_ENUM_LOCAL uint32 = 0x00000002
|
||||
PRINTER_ENUM_CONNECTIONS uint32 = 0x00000004
|
||||
)
|
||||
|
||||
// PRINTER_STATUS_* values of PRINTER_INFO_2.Status.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/printer-info-2
|
||||
const (
|
||||
PRINTER_STATUS_PAUSED uint32 = 0x00000001
|
||||
PRINTER_STATUS_ERROR uint32 = 0x00000002
|
||||
PRINTER_STATUS_PENDING_DELETION uint32 = 0x00000004
|
||||
PRINTER_STATUS_PAPER_JAM uint32 = 0x00000008
|
||||
PRINTER_STATUS_PAPER_OUT uint32 = 0x00000010
|
||||
PRINTER_STATUS_MANUAL_FEED uint32 = 0x00000020
|
||||
PRINTER_STATUS_PAPER_PROBLEM uint32 = 0x00000040
|
||||
PRINTER_STATUS_OFFLINE uint32 = 0x00000080
|
||||
PRINTER_STATUS_IO_ACTIVE uint32 = 0x00000100
|
||||
PRINTER_STATUS_BUSY uint32 = 0x00000200
|
||||
PRINTER_STATUS_PRINTING uint32 = 0x00000400
|
||||
PRINTER_STATUS_OUTPUT_BIN_FULL uint32 = 0x00000800
|
||||
PRINTER_STATUS_NOT_AVAILABLE uint32 = 0x00001000
|
||||
PRINTER_STATUS_WAITING uint32 = 0x00002000
|
||||
PRINTER_STATUS_PROCESSING uint32 = 0x00004000
|
||||
PRINTER_STATUS_INITIALIZING uint32 = 0x00008000
|
||||
PRINTER_STATUS_WARMING_UP uint32 = 0x00010000
|
||||
PRINTER_STATUS_TONER_LOW uint32 = 0x00020000
|
||||
PRINTER_STATUS_NO_TONER uint32 = 0x00040000
|
||||
PRINTER_STATUS_PAGE_PUNT uint32 = 0x00080000
|
||||
PRINTER_STATUS_USER_INTERVENTION uint32 = 0x00100000
|
||||
PRINTER_STATUS_OUT_OF_MEMORY uint32 = 0x00200000
|
||||
PRINTER_STATUS_DOOR_OPEN uint32 = 0x00400000
|
||||
PRINTER_STATUS_SERVER_UNKNOWN uint32 = 0x00800000
|
||||
PRINTER_STATUS_POWER_SAVE uint32 = 0x01000000
|
||||
PRINTER_STATUS_SERVER_OFFLINE uint32 = 0x02000000
|
||||
PRINTER_STATUS_DRIVER_UPDATE_NEEDED uint32 = 0x04000000
|
||||
)
|
||||
|
||||
// JOB_STATUS_* values of JOB_INFO_1.Status.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/job-info-1
|
||||
const (
|
||||
JOB_STATUS_PAUSED uint32 = 0x00000001
|
||||
JOB_STATUS_ERROR uint32 = 0x00000002
|
||||
JOB_STATUS_DELETING uint32 = 0x00000004
|
||||
JOB_STATUS_SPOOLING uint32 = 0x00000008
|
||||
JOB_STATUS_PRINTING uint32 = 0x00000010
|
||||
JOB_STATUS_OFFLINE uint32 = 0x00000020
|
||||
JOB_STATUS_PAPEROUT uint32 = 0x00000040
|
||||
JOB_STATUS_PRINTED uint32 = 0x00000080
|
||||
JOB_STATUS_DELETED uint32 = 0x00000100
|
||||
JOB_STATUS_BLOCKED_DEVQ uint32 = 0x00000200
|
||||
JOB_STATUS_USER_INTERVENTION uint32 = 0x00000400
|
||||
JOB_STATUS_RESTART uint32 = 0x00000800
|
||||
JOB_STATUS_COMPLETE uint32 = 0x00001000
|
||||
JOB_STATUS_RETAINED uint32 = 0x00002000
|
||||
)
|
||||
|
||||
// printerInfo2 is PRINTER_INFO_2.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/printer-info-2
|
||||
type printerInfo2 struct {
|
||||
pServerName *uint16
|
||||
pPrinterName *uint16
|
||||
pShareName *uint16
|
||||
pPortName *uint16
|
||||
pDriverName *uint16
|
||||
pComment *uint16
|
||||
pLocation *uint16
|
||||
pDevMode uintptr
|
||||
pSepFile *uint16
|
||||
pPrintProcessor *uint16
|
||||
pDatatype *uint16
|
||||
pParameters *uint16
|
||||
pSecurityDescriptor uintptr
|
||||
Attributes uint32
|
||||
Priority uint32
|
||||
DefaultPriority uint32
|
||||
StartTime uint32
|
||||
UntilTime uint32
|
||||
Status uint32
|
||||
cJobs uint32
|
||||
AveragePPM uint32
|
||||
}
|
||||
|
||||
// jobInfo1 is JOB_INFO_1.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/printdocs/job-info-1
|
||||
type jobInfo1 struct {
|
||||
JobId uint32
|
||||
pPrinterName *uint16
|
||||
pMachineName *uint16
|
||||
pUserName *uint16
|
||||
pDocument *uint16
|
||||
pDatatype *uint16
|
||||
pStatus *uint16
|
||||
Status uint32
|
||||
Priority uint32
|
||||
Position uint32
|
||||
TotalPages uint32
|
||||
PagesPrinted uint32
|
||||
Submitted windows.Systemtime
|
||||
}
|
||||
|
||||
// Printer contains the PRINTER_INFO_2 fields used by windows_exporter.
|
||||
type Printer struct {
|
||||
Name string
|
||||
// Status is a combination of PRINTER_STATUS_* values.
|
||||
Status uint32
|
||||
// Jobs is the number of jobs currently queued for the printer (cJobs).
|
||||
Jobs uint32
|
||||
}
|
||||
|
||||
// Job contains the JOB_INFO_1 fields used by windows_exporter.
|
||||
type Job struct {
|
||||
ID uint32
|
||||
PrinterName string
|
||||
// Status is a combination of JOB_STATUS_* values.
|
||||
Status uint32
|
||||
// StatusText is the driver or port monitor status string (pStatus).
|
||||
// It is empty if the spooler reports no custom status.
|
||||
StatusText string
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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 winspool reads printers and print jobs through the print spooler API.
|
||||
//
|
||||
// EnumPrintersW and EnumJobsW only read the spooler state. Unlike the CIMWin32
|
||||
// Win32_Printer provider, they never call DeviceCapabilities, which can block
|
||||
// for about a minute on an unreachable network printer.
|
||||
package winspool
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
modWinspool = windows.NewLazySystemDLL("winspool.drv")
|
||||
|
||||
procEnumPrintersW = modWinspool.NewProc("EnumPrintersW")
|
||||
procOpenPrinterW = modWinspool.NewProc("OpenPrinterW")
|
||||
procClosePrinter = modWinspool.NewProc("ClosePrinter")
|
||||
procEnumJobsW = modWinspool.NewProc("EnumJobsW")
|
||||
)
|
||||
|
||||
// EnumPrinters returns the printers selected by flags, a combination of PRINTER_ENUM_* values.
|
||||
func EnumPrinters(flags uint32) ([]Printer, error) {
|
||||
buf, count, err := enum(func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error) {
|
||||
r1, _, err := procEnumPrintersW.Call(
|
||||
uintptr(flags),
|
||||
0, // local machine
|
||||
2, // PRINTER_INFO_2
|
||||
uintptr(unsafe.Pointer(buf)),
|
||||
uintptr(size),
|
||||
uintptr(unsafe.Pointer(needed)),
|
||||
uintptr(unsafe.Pointer(returned)),
|
||||
)
|
||||
|
||||
return r1, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EnumPrintersW: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return []Printer{}, nil
|
||||
}
|
||||
|
||||
infos := unsafe.Slice((*printerInfo2)(unsafe.Pointer(&buf[0])), count)
|
||||
printers := make([]Printer, 0, count)
|
||||
|
||||
for _, info := range infos {
|
||||
printers = append(printers, Printer{
|
||||
Name: windows.UTF16PtrToString(info.pPrinterName),
|
||||
Status: info.Status,
|
||||
Jobs: info.cJobs,
|
||||
})
|
||||
}
|
||||
|
||||
return printers, nil
|
||||
}
|
||||
|
||||
// OpenPrinter opens printerName with the default access rights of the caller.
|
||||
// The handle must be closed with ClosePrinter.
|
||||
func OpenPrinter(printerName string) (windows.Handle, error) {
|
||||
printerNamePtr, err := windows.UTF16PtrFromString(printerName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid printer name %q: %w", printerName, err)
|
||||
}
|
||||
|
||||
var handle windows.Handle
|
||||
|
||||
r1, _, err := procOpenPrinterW.Call(
|
||||
uintptr(unsafe.Pointer(printerNamePtr)),
|
||||
uintptr(unsafe.Pointer(&handle)),
|
||||
0, // PRINTER_DEFAULTS
|
||||
)
|
||||
if r1 == 0 {
|
||||
return 0, fmt.Errorf("OpenPrinterW(%s): %w", printerName, err)
|
||||
}
|
||||
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func ClosePrinter(handle windows.Handle) error {
|
||||
r1, _, err := procClosePrinter.Call(uintptr(handle))
|
||||
if r1 == 0 {
|
||||
return fmt.Errorf("ClosePrinter: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnumJobs returns all jobs of the printer in queue order.
|
||||
func EnumJobs(handle windows.Handle) ([]Job, error) {
|
||||
buf, count, err := enum(func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error) {
|
||||
r1, _, err := procEnumJobsW.Call(
|
||||
uintptr(handle),
|
||||
0, // FirstJob
|
||||
0xFFFFFFFF, // NoJobs
|
||||
1, // JOB_INFO_1
|
||||
uintptr(unsafe.Pointer(buf)),
|
||||
uintptr(size),
|
||||
uintptr(unsafe.Pointer(needed)),
|
||||
uintptr(unsafe.Pointer(returned)),
|
||||
)
|
||||
|
||||
return r1, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EnumJobsW: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return []Job{}, nil
|
||||
}
|
||||
|
||||
infos := unsafe.Slice((*jobInfo1)(unsafe.Pointer(&buf[0])), count)
|
||||
jobs := make([]Job, 0, count)
|
||||
|
||||
for _, info := range infos {
|
||||
jobs = append(jobs, Job{
|
||||
ID: info.JobId,
|
||||
PrinterName: windows.UTF16PtrToString(info.pPrinterName),
|
||||
Status: info.Status,
|
||||
StatusText: windows.UTF16PtrToString(info.pStatus),
|
||||
})
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// enum calls a spooler enumeration function until the buffer is large enough.
|
||||
// The returned buffer holds count structures followed by the strings they point to.
|
||||
func enum(call func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error)) ([]byte, uint32, error) {
|
||||
var (
|
||||
buf []byte
|
||||
needed uint32
|
||||
returned uint32
|
||||
)
|
||||
|
||||
// The required size can grow between two calls if printers or jobs are added.
|
||||
for range 5 {
|
||||
var bufPtr *byte
|
||||
if len(buf) > 0 {
|
||||
bufPtr = &buf[0]
|
||||
}
|
||||
|
||||
r1, err := call(bufPtr, uint32(len(buf)), &needed, &returned)
|
||||
if r1 != 0 {
|
||||
return buf, returned, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
buf = make([]byte, needed)
|
||||
}
|
||||
|
||||
return nil, 0, windows.ERROR_INSUFFICIENT_BUFFER
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 winspool
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
procStartDocPrinterW = modWinspool.NewProc("StartDocPrinterW")
|
||||
procAbortPrinter = modWinspool.NewProc("AbortPrinter")
|
||||
)
|
||||
|
||||
// docInfo1 is DOC_INFO_1.
|
||||
type docInfo1 struct {
|
||||
pDocName *uint16
|
||||
pOutputFile *uint16
|
||||
pDatatype *uint16
|
||||
}
|
||||
|
||||
func TestLayout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if unsafe.Sizeof(uintptr(0)) != 8 {
|
||||
t.Skip("offsets are checked for 64-bit Windows only")
|
||||
}
|
||||
|
||||
// Offsets of the 64-bit SDK structures.
|
||||
require.Equal(t, uintptr(136), unsafe.Sizeof(printerInfo2{}))
|
||||
require.Equal(t, uintptr(0x08), unsafe.Offsetof(printerInfo2{}.pPrinterName))
|
||||
require.Equal(t, uintptr(0x7c), unsafe.Offsetof(printerInfo2{}.Status))
|
||||
require.Equal(t, uintptr(0x80), unsafe.Offsetof(printerInfo2{}.cJobs))
|
||||
|
||||
require.Equal(t, uintptr(96), unsafe.Sizeof(jobInfo1{}))
|
||||
require.Equal(t, uintptr(0x08), unsafe.Offsetof(jobInfo1{}.pPrinterName))
|
||||
require.Equal(t, uintptr(0x30), unsafe.Offsetof(jobInfo1{}.pStatus))
|
||||
require.Equal(t, uintptr(0x38), unsafe.Offsetof(jobInfo1{}.Status))
|
||||
require.Equal(t, uintptr(0x4c), unsafe.Offsetof(jobInfo1{}.Submitted))
|
||||
}
|
||||
|
||||
func TestEnumPrinters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
printers, err := EnumPrinters(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, printers)
|
||||
|
||||
for _, printer := range printers {
|
||||
require.NotEmpty(t, printer.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPrinterInvalidName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := OpenPrinter("windows_exporter test printer that does not exist")
|
||||
require.ErrorIs(t, err, windows.ERROR_INVALID_PRINTER_NAME)
|
||||
}
|
||||
|
||||
// TestEnumJobs starts a document without writing data, so the job stays in the
|
||||
// spooling state and never reaches the port. AbortPrinter deletes it again.
|
||||
func TestEnumJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
printers, err := EnumPrinters(PRINTER_ENUM_LOCAL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// CIPrinter is created by the CI workflow. Microsoft Print to PDF exists on most desktops.
|
||||
idx := slices.IndexFunc(printers, func(p Printer) bool {
|
||||
return p.Name == "CIPrinter" || p.Name == "Microsoft Print to PDF"
|
||||
})
|
||||
if idx == -1 {
|
||||
t.Skip("no test printer available")
|
||||
}
|
||||
|
||||
printerName := printers[idx].Name
|
||||
|
||||
handle, err := OpenPrinter(printerName)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ClosePrinter(handle))
|
||||
})
|
||||
|
||||
docName, err := windows.UTF16PtrFromString("windows_exporter test job")
|
||||
require.NoError(t, err)
|
||||
|
||||
// An output file keeps Microsoft Print to PDF from prompting for a file name.
|
||||
outputFile, err := windows.UTF16PtrFromString(filepath.Join(t.TempDir(), "job.out"))
|
||||
require.NoError(t, err)
|
||||
|
||||
docInfo := docInfo1{
|
||||
pDocName: docName,
|
||||
pOutputFile: outputFile,
|
||||
}
|
||||
|
||||
jobID, _, err := procStartDocPrinterW.Call(uintptr(handle), 1, uintptr(unsafe.Pointer(&docInfo)))
|
||||
require.NotZero(t, jobID, "StartDocPrinterW: %v", err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
r1, _, err := procAbortPrinter.Call(uintptr(handle))
|
||||
require.NotZero(t, r1, "AbortPrinter: %v", err)
|
||||
})
|
||||
|
||||
jobs, err := EnumJobs(handle)
|
||||
require.NoError(t, err)
|
||||
|
||||
idx = slices.IndexFunc(jobs, func(j Job) bool { return j.ID == uint32(jobID) })
|
||||
require.NotEqual(t, -1, idx, "job %d not found in %+v", jobID, jobs)
|
||||
|
||||
job := jobs[idx]
|
||||
require.Equal(t, printerName, job.PrinterName)
|
||||
require.NotZero(t, job.Status&JOB_STATUS_SPOOLING, "status 0x%08X", job.Status)
|
||||
|
||||
printers, err = EnumPrinters(PRINTER_ENUM_LOCAL)
|
||||
require.NoError(t, err)
|
||||
|
||||
idx = slices.IndexFunc(printers, func(p Printer) bool { return p.Name == printerName })
|
||||
require.NotEqual(t, -1, idx)
|
||||
require.NotZero(t, printers[idx].Jobs)
|
||||
}
|
||||
Reference in New Issue
Block a user