From 7c200078ef69260e7370245ab58828d6d4cd30d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Fri, 9 Oct 2026 09:36:59 +0200 Subject: [PATCH] printer: read printers and jobs from the spooler API (#2577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Signed-off-by: Jan-Otto Kröpke --- docs/collector.printer.md | 10 +- internal/collector/printer/printer.go | 269 ++++++++++-------- .../printer/printer_internal_test.go | 144 ++++++++++ internal/collector/printer/printer_test.go | 23 +- internal/headers/winspool/types.go | 144 ++++++++++ internal/headers/winspool/winspool.go | 179 ++++++++++++ internal/headers/winspool/winspool_test.go | 143 ++++++++++ 7 files changed, 774 insertions(+), 138 deletions(-) create mode 100644 internal/collector/printer/printer_internal_test.go create mode 100644 internal/headers/winspool/types.go create mode 100644 internal/headers/winspool/winspool.go create mode 100644 internal/headers/winspool/winspool_test.go diff --git a/docs/collector.printer.md b/docs/collector.printer.md index 32d63167..7895b80e 100644 --- a/docs/collector.printer.md +++ b/docs/collector.printer.md @@ -4,20 +4,20 @@ The printer collector exposes metrics about printers and their jobs. | | | |---------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Metric name prefix | `printer` | -| Data source | WMI | -| Classes | [Win32_Printer](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer)
[Win32_PrintJob](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printjob) | +| Metric name prefix | `printer` | +| Data source | Print spooler API (`EnumPrintersW`, `EnumJobsW`) | +| Classes | Values match [Win32_Printer](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer) and [Win32_PrintJob](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printjob) | | Enabled by default? | false | ## Flags ### `--collector.printer.include` -If given, a printer needs to match the include regexp in order for the corresponding printer metrics to be reported +If given, a printer needs to match the include regular expression in order for the corresponding printer metrics to be reported ### `--collector.printer.exclude` -If given, a printer needs to *not* match the exclude regexp in order for the corresponding printer metrics to be reported +If given, a printer needs to *not* match the exclude regular expression in order for the corresponding printer metrics to be reported ## Metrics diff --git a/internal/collector/printer/printer.go b/internal/collector/printer/printer.go index 9bebfdcb..43d7d1b4 100644 --- a/internal/collector/printer/printer.go +++ b/internal/collector/printer/printer.go @@ -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 } diff --git a/internal/collector/printer/printer_internal_test.go b/internal/collector/printer/printer_internal_test.go new file mode 100644 index 00000000..48afd4b0 --- /dev/null +++ b/internal/collector/printer/printer_internal_test.go @@ -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)) +} diff --git a/internal/collector/printer/printer_test.go b/internal/collector/printer/printer_test.go index 45b0ea20..a863c884 100644 --- a/internal/collector/printer/printer_test.go +++ b/internal/collector/printer/printer_test.go @@ -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)) } diff --git a/internal/headers/winspool/types.go b/internal/headers/winspool/types.go new file mode 100644 index 00000000..8d4f91c0 --- /dev/null +++ b/internal/headers/winspool/types.go @@ -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 +} diff --git a/internal/headers/winspool/winspool.go b/internal/headers/winspool/winspool.go new file mode 100644 index 00000000..8fedba56 --- /dev/null +++ b/internal/headers/winspool/winspool.go @@ -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 +} diff --git a/internal/headers/winspool/winspool_test.go b/internal/headers/winspool/winspool_test.go new file mode 100644 index 00000000..d3be7dd1 --- /dev/null +++ b/internal/headers/winspool/winspool_test.go @@ -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) +}