From e88c8a712cbb2f32f6f0ee81b23e25ac4593cf7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Sun, 11 Oct 2026 20:47:53 +0200 Subject: [PATCH] update: include optional updates such as the monthly preview update (#2688) Co-authored-by: Claude Signed-off-by: Claude --- contrib/mixin/dashboards/updates.libsonnet | 20 ++- dashboard/windows-exporter-dashboard.json | 152 ++++++++++++++++-- dashboard/windows-exporter-updates.json | 152 ++++++++++++++++-- docs/collector.update.md | 26 ++- internal/collector/update/update.go | 65 ++++++-- .../collector/update/update_internal_test.go | 45 ++++-- internal/ole/wuapi/wuapi_windows.go | 18 +++ 7 files changed, 410 insertions(+), 68 deletions(-) diff --git a/contrib/mixin/dashboards/updates.libsonnet b/contrib/mixin/dashboards/updates.libsonnet index 75f5e252..ef4b6fef 100644 --- a/contrib/mixin/dashboards/updates.libsonnet +++ b/contrib/mixin/dashboards/updates.libsonnet @@ -9,24 +9,25 @@ function(on) b.tab( 'Updates', b.rows([ - if on('update') then b.summary([210, 211, 212, 214, 215]), + if on('update') then b.summary([210, 211, 212, 213, 214, 215]), if on('update') then b.row('Pending updates', b.flow([[216, 24, 10]])), if on('update') then b.row('History', b.flow([[217, 12, 8], [218, 12, 8]])), ]), [ - b.stat(210, 'Pending', 'Updates that are available but not installed.' + requirement, pending('')), + b.stat(210, 'Pending', 'Updates that are available but not installed, including optional updates.' + requirement, pending('')), b.stat(211, 'Critical', 'Pending updates with the Microsoft security severity Critical.' + requirement, pending('severity="Critical"'), steps=[{ color: 'green', value: null }, { color: 'red', value: 1 }]), b.stat(212, 'Important', 'Pending updates with the Microsoft security severity Important.' + requirement, pending('severity="Important"'), steps=[{ color: 'green', value: null }, { color: 'orange', value: 1 }]), - b.stat(214, 'Oldest pending', 'Time since the oldest pending update was published. Orange after 30 days, red after 60 days.' + requirement, 'time() - min(' + metric('pending_published_timestamp') + ')', 'dtdurations', steps=[{ color: 'green', value: null }, { color: 'orange', value: days(30) }, { color: 'red', value: days(60) }]) + b.stat(213, 'Optional', 'Pending optional updates, such as the monthly non-security preview update. Windows does not install them automatically.' + requirement, pending('deployment_action="optional_installation"')), + b.stat(214, 'Oldest pending', 'Time since the oldest pending regular update was published. Optional updates are left out. Orange after 30 days, red after 60 days.' + requirement, 'time() - min(' + metric('pending_published_timestamp') + ' and on (id, revision) ' + metric('pending_info', 'deployment_action="installation"') + ')', 'dtdurations', steps=[{ color: 'green', value: null }, { color: 'orange', value: days(30) }, { color: 'red', value: days(60) }]) + b.panel.withDefaults({ noValue: 'none' }) + b.panel.withOptions({ reduceOptions: { calcs: ['last'] } }), b.stat(215, 'Last check', 'Time since the collector last queried Windows Update. Orange after 1 day, red after 7 days.' + requirement, 'time() - ' + metric('scrape_timestamp_seconds'), 'dtdurations', steps=[{ color: 'green', value: null }, { color: 'orange', value: days(1) }, { color: 'red', value: days(7) }]) + b.panel.withOptions({ reduceOptions: { calcs: ['last'] } }), b.panel.new(216, 'Pending updates', 'table') - + b.panel.withDescription('Pending updates with their category, security severity and publish date. Category names follow the Windows display language.' + requirement) + + b.panel.withDescription('Pending updates with their category, security severity, type and publish date. Optional updates, such as the monthly non-security preview update, are not installed automatically. Category names follow the Windows display language.' + requirement) + b.panel.withQueries([ - b.query.new('max by (title, category, severity) (' + '(' + metric('pending_published_timestamp') + ' * on (id, revision) group_right ' + metric('pending_info') + ') or on (id, revision) (0 * ' + metric('pending_info') + ')) * 1000', 'A') + b.query.new('max by (title, category, severity, deployment_action) (' + '(' + metric('pending_published_timestamp') + ' * on (id, revision) group_right ' + metric('pending_info') + ') or on (id, revision) (0 * ' + metric('pending_info') + ')) * 1000', 'A') + b.query.withInstant() + b.query.withFormat('table'), ]) @@ -37,8 +38,8 @@ function(on) options: { excludeByName: { Time: true }, includeByName: {}, - indexByName: { title: 0, category: 1, severity: 2, Value: 3 }, - renameByName: { title: 'Update', category: 'Category', severity: 'Severity', Value: 'Published' }, + indexByName: { title: 0, category: 1, severity: 2, deployment_action: 3, Value: 4 }, + renameByName: { title: 'Update', category: 'Category', severity: 'Severity', deployment_action: 'Type', Value: 'Published' }, }, }, }]) @@ -51,6 +52,11 @@ function(on) { id: 'mappings', value: [{ type: 'value', options: { Critical: { color: 'red', index: 0 }, Important: { color: 'orange', index: 1 } } }] }, { id: 'custom.cellOptions', value: { type: 'color-text' } }, ], + }, { + matcher: { id: 'byName', options: 'Type' }, + properties: [ + { id: 'mappings', value: [{ type: 'value', options: { installation: { index: 0, text: 'Regular' }, optional_installation: { index: 1, text: 'Optional' } } }] }, + ], }]), b.graph(217, 'Pending updates by category', 'Number of pending updates per update category.' + requirement, 'short', [ diff --git a/dashboard/windows-exporter-dashboard.json b/dashboard/windows-exporter-dashboard.json index f751b9b6..642b63a7 100644 --- a/dashboard/windows-exporter-dashboard.json +++ b/dashboard/windows-exporter-dashboard.json @@ -12728,7 +12728,7 @@ "transformations": [] } }, - "description": "Updates that are available but not installed. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "description": "Updates that are available but not installed, including optional updates. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", "id": 210, "links": [], "title": "Pending", @@ -12953,6 +12953,91 @@ } } }, + "panel-213": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "count(windows_update_pending_info{job=~\"$job\", instance=\"$instance\", deployment_action=\"optional_installation\"}) or (0 * windows_update_scrape_timestamp_seconds{job=~\"$job\", instance=\"$instance\"})", + "instant": false, + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": { + "maxDataPoints": 100 + }, + "transformations": [] + } + }, + "description": "Pending optional updates, such as the monthly non-security preview update. Windows does not install them automatically. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "id": 213, + "links": [], + "title": "Optional", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "text", + "mode": "fixed" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "" + } + } + }, "panel-214": { "kind": "Panel", "spec": { @@ -12972,7 +13057,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "time() - min(windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"})", + "expr": "time() - min(windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"} and on (id, revision) windows_update_pending_info{job=~\"$job\", instance=\"$instance\", deployment_action=\"installation\"})", "instant": false, "legendFormat": "__auto", "range": true @@ -12989,7 +13074,7 @@ "transformations": [] } }, - "description": "Time since the oldest pending update was published. Orange after 30 days, red after 60 days. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "description": "Time since the oldest pending regular update was published. Optional updates are left out. Orange after 30 days, red after 60 days. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", "id": 214, "links": [], "title": "Oldest pending", @@ -13157,7 +13242,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "max by (title, category, severity) ((windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"} * on (id, revision) group_right windows_update_pending_info{job=~\"$job\", instance=\"$instance\"}) or on (id, revision) (0 * windows_update_pending_info{job=~\"$job\", instance=\"$instance\"})) * 1000", + "expr": "max by (title, category, severity, deployment_action) ((windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"} * on (id, revision) group_right windows_update_pending_info{job=~\"$job\", instance=\"$instance\"}) or on (id, revision) (0 * windows_update_pending_info{job=~\"$job\", instance=\"$instance\"})) * 1000", "format": "table", "instant": true, "legendFormat": "__auto", @@ -13181,14 +13266,16 @@ }, "includeByName": {}, "indexByName": { - "Value": 3, + "Value": 4, "category": 1, + "deployment_action": 3, "severity": 2, "title": 0 }, "renameByName": { "Value": "Published", "category": "Category", + "deployment_action": "Type", "severity": "Severity", "title": "Update" } @@ -13198,7 +13285,7 @@ ] } }, - "description": "Pending updates with their category, security severity and publish date. Category names follow the Windows display language. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "description": "Pending updates with their category, security severity, type and publish date. Optional updates, such as the monthly non-security preview update, are not installed automatically. Category names follow the Windows display language. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", "id": 216, "links": [], "title": "Pending updates", @@ -13281,6 +13368,32 @@ } } ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "installation": { + "index": 0, + "text": "Regular" + }, + "optional_installation": { + "index": 1, + "text": "Optional" + } + }, + "type": "value" + } + ] + } + ] } ] }, @@ -29025,7 +29138,7 @@ "name": "panel-210" }, "height": 4, - "width": 5, + "width": 4, "x": 0, "y": 0 } @@ -29038,8 +29151,8 @@ "name": "panel-211" }, "height": 4, - "width": 5, - "x": 5, + "width": 4, + "x": 4, "y": 0 } }, @@ -29051,8 +29164,21 @@ "name": "panel-212" }, "height": 4, - "width": 5, - "x": 10, + "width": 4, + "x": 8, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-213" + }, + "height": 4, + "width": 4, + "x": 12, "y": 0 } }, @@ -29064,8 +29190,8 @@ "name": "panel-214" }, "height": 4, - "width": 5, - "x": 15, + "width": 4, + "x": 16, "y": 0 } }, diff --git a/dashboard/windows-exporter-updates.json b/dashboard/windows-exporter-updates.json index 017e4807..b3c7a9f3 100644 --- a/dashboard/windows-exporter-updates.json +++ b/dashboard/windows-exporter-updates.json @@ -95,7 +95,7 @@ "transformations": [] } }, - "description": "Updates that are available but not installed. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "description": "Updates that are available but not installed, including optional updates. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", "id": 210, "links": [], "title": "Pending", @@ -320,6 +320,91 @@ } } }, + "panel-213": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "count(windows_update_pending_info{job=~\"$job\", instance=\"$instance\", deployment_action=\"optional_installation\"}) or (0 * windows_update_scrape_timestamp_seconds{job=~\"$job\", instance=\"$instance\"})", + "instant": false, + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": { + "maxDataPoints": 100 + }, + "transformations": [] + } + }, + "description": "Pending optional updates, such as the monthly non-security preview update. Windows does not install them automatically. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "id": 213, + "links": [], + "title": "Optional", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "text", + "mode": "fixed" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "" + } + } + }, "panel-214": { "kind": "Panel", "spec": { @@ -339,7 +424,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "time() - min(windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"})", + "expr": "time() - min(windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"} and on (id, revision) windows_update_pending_info{job=~\"$job\", instance=\"$instance\", deployment_action=\"installation\"})", "instant": false, "legendFormat": "__auto", "range": true @@ -356,7 +441,7 @@ "transformations": [] } }, - "description": "Time since the oldest pending update was published. Orange after 30 days, red after 60 days. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "description": "Time since the oldest pending regular update was published. Optional updates are left out. Orange after 30 days, red after 60 days. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", "id": 214, "links": [], "title": "Oldest pending", @@ -524,7 +609,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "max by (title, category, severity) ((windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"} * on (id, revision) group_right windows_update_pending_info{job=~\"$job\", instance=\"$instance\"}) or on (id, revision) (0 * windows_update_pending_info{job=~\"$job\", instance=\"$instance\"})) * 1000", + "expr": "max by (title, category, severity, deployment_action) ((windows_update_pending_published_timestamp{job=~\"$job\", instance=\"$instance\"} * on (id, revision) group_right windows_update_pending_info{job=~\"$job\", instance=\"$instance\"}) or on (id, revision) (0 * windows_update_pending_info{job=~\"$job\", instance=\"$instance\"})) * 1000", "format": "table", "instant": true, "legendFormat": "__auto", @@ -548,14 +633,16 @@ }, "includeByName": {}, "indexByName": { - "Value": 3, + "Value": 4, "category": 1, + "deployment_action": 3, "severity": 2, "title": 0 }, "renameByName": { "Value": "Published", "category": "Category", + "deployment_action": "Type", "severity": "Severity", "title": "Update" } @@ -565,7 +652,7 @@ ] } }, - "description": "Pending updates with their category, security severity and publish date. Category names follow the Windows display language. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", + "description": "Pending updates with their category, security severity, type and publish date. Optional updates, such as the monthly non-security preview update, are not installed automatically. Category names follow the Windows display language. Requires the update collector, which is not enabled by default. The collector queries Windows Update in the background, every 6 hours by default.", "id": 216, "links": [], "title": "Pending updates", @@ -648,6 +735,32 @@ } } ] + }, + { + "matcher": { + "id": "byName", + "options": "Type" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "installation": { + "index": 0, + "text": "Regular" + }, + "optional_installation": { + "index": 1, + "text": "Optional" + } + }, + "type": "value" + } + ] + } + ] } ] }, @@ -922,7 +1035,7 @@ "name": "panel-210" }, "height": 4, - "width": 5, + "width": 4, "x": 0, "y": 0 } @@ -935,8 +1048,8 @@ "name": "panel-211" }, "height": 4, - "width": 5, - "x": 5, + "width": 4, + "x": 4, "y": 0 } }, @@ -948,8 +1061,21 @@ "name": "panel-212" }, "height": 4, - "width": 5, - "x": 10, + "width": 4, + "x": 8, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-213" + }, + "height": 4, + "width": 4, + "x": 12, "y": 0 } }, @@ -961,8 +1087,8 @@ "name": "panel-214" }, "height": 4, - "width": 5, - "x": 15, + "width": 4, + "x": 16, "y": 0 } }, diff --git a/docs/collector.update.md b/docs/collector.update.md index 2f624263..e65c2504 100644 --- a/docs/collector.update.md +++ b/docs/collector.update.md @@ -27,23 +27,27 @@ Default: `6h0m0s` ## Metrics -| Name | Description | Type | Labels | -|------------------------------------------------|------------------------------------------------------------------|-------|-----------------------------------------------| -| `windows_update_pending_info` | Expose information for a single pending update item | gauge | `id`,`revision`,`category`,`severity`,`title` | -| `windows_update_pending_published_timestamp` | Expose last published timestamp for a single pending update item | gauge | `id`,`revision` | -| `windows_update_scrape_query_duration_seconds` | Duration of the last scrape query to the Windows Update API | gauge | | -| `windows_update_scrape_timestamp_seconds` | Timestamp of the last scrape | gauge | | +Pending updates include optional updates, such as the monthly non-security preview update. The `deployment_action` label is `installation` for regular updates and `optional_installation` for optional ones. Filter with `deployment_action="installation"` to ignore optional updates. + +| Name | Description | Type | Labels | +|------------------------------------------------|------------------------------------------------------------------|-------|-------------------------------------------------------------------| +| `windows_update_pending_info` | Expose information for a single pending update item | gauge | `id`,`revision`,`category`,`severity`,`title`,`deployment_action` | +| `windows_update_pending_published_timestamp` | Expose last published timestamp for a single pending update item | gauge | `id`,`revision` | +| `windows_update_scrape_query_duration_seconds` | Duration of the last scrape query to the Windows Update API | gauge | | +| `windows_update_scrape_timestamp_seconds` | Timestamp of the last scrape | gauge | | ### Example metrics ``` # HELP windows_update_pending_info Expose information for a single pending update item # TYPE windows_update_pending_info gauge -windows_update_pending_info{category="Definition Updates",id="a32ca1d0-ddd4-486b-b708-d941db4f1051",revision="204",severity="",title="Update for Windows Security platform - KB5007651 (Version 10.0.27840.1000)"} 1 -windows_update_pending_info{category="Definition Updates",id="b50a64de-a0bb-465b-9842-9963b6eee21e",revision="200",severity="",title="Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.429.146.0) - Current Channel (Broad)"} 1 +windows_update_pending_info{category="Definition Updates",deployment_action="installation",id="a32ca1d0-ddd4-486b-b708-d941db4f1051",revision="204",severity="",title="Update for Windows Security platform - KB5007651 (Version 10.0.27840.1000)"} 1 +windows_update_pending_info{category="Definition Updates",deployment_action="installation",id="b50a64de-a0bb-465b-9842-9963b6eee21e",revision="200",severity="",title="Security Intelligence Update for Microsoft Defender Antivirus - KB2267602 (Version 1.429.146.0) - Current Channel (Broad)"} 1 +windows_update_pending_info{category="Updates",deployment_action="optional_installation",id="4c5a2079-dd6c-4b78-a20b-07b2d5ea2217",revision="1",severity="",title="2026-09 Preview Update (KB5124010) (26300.9550)"} 1 # HELP windows_update_pending_published_timestamp Expose last published timestamp for a single pending update item # TYPE windows_update_pending_published_timestamp gauge windows_update_pending_published_timestamp{id="a32ca1d0-ddd4-486b-b708-d941db4f1051",revision="204"} 1.747872e+09 windows_update_pending_published_timestamp{id="b50a64de-a0bb-465b-9842-9963b6eee21e",revision="200"} 1.7479584e+09 +windows_update_pending_published_timestamp{id="4c5a2079-dd6c-4b78-a20b-07b2d5ea2217",revision="1"} 1.7900352e+09 # HELP windows_update_scrape_query_duration_seconds Duration of the last scrape query to the Windows Update API # TYPE windows_update_scrape_query_duration_seconds gauge windows_update_scrape_query_duration_seconds 2.8161838 @@ -60,5 +64,11 @@ Add extended information like cmdline or owner to other process metrics. windows_update_pending_published_timestamp * on(id, revision) group_left(severity, title) windows_update_pending_info ``` +Count pending updates without optional updates, such as the monthly non-security preview update. + +``` +count(windows_update_pending_info{deployment_action="installation"}) +``` + ## Alerting examples _This collector does not yet have alerting examples, we would appreciate your help adding them!_ diff --git a/internal/collector/update/update.go b/internal/collector/update/update.go index 477284ea..1dee53ee 100644 --- a/internal/collector/update/update.go +++ b/internal/collector/update/update.go @@ -50,6 +50,13 @@ var ConfigDefaults = Config{ ScrapeInterval: 6 * time.Hour, } +// searchCriteria finds pending updates of both deployment actions. Criteria +// without a DeploymentAction clause imply DeploymentAction='Installation' and +// miss optional updates, such as the monthly non-security preview update. +// OR is only allowed at the top level of a WUA criteria string. +const searchCriteria = "IsInstalled=0 and IsHidden=0 and DeploymentAction='Installation'" + + " or IsInstalled=0 and IsHidden=0 and DeploymentAction='OptionalInstallation'" + var ( ErrNoUpdates = errors.New("pending gather update metrics") ErrUpdateServiceDisabled = errors.New("windows updates service is disabled") @@ -118,7 +125,7 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.pendingUpdate = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "pending_info"), "Expose information for a single pending update item", - []string{"id", "revision", "category", "severity", "title"}, + []string{"id", "revision", "category", "severity", "title", "deployment_action"}, nil, ) @@ -289,7 +296,7 @@ func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSear timeStart := time.Now() - result, err := searcher.Search("IsInstalled=0 and IsHidden=0") + result, err := searcher.Search(searchCriteria) if err != nil { return nil, fmt.Errorf("search for updates: %w", err) } @@ -310,6 +317,10 @@ func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSear } defer updates.Release() + // Both criteria clauses are evaluated separately, so an update could be + // returned twice. Its publication metric must be emitted only once. + seen := make(map[string]struct{}) + for item, err := range updates.All() { if err != nil { if _, ok := errors.AsType[*ole.CollectionItemError](err); !ok { @@ -330,6 +341,13 @@ func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSear continue } + key := update.identity + "/" + update.revision + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + metricsBuf = append(metricsBuf, prometheus.MustNewConstMetric( c.pendingUpdate, prometheus.GaugeValue, @@ -339,6 +357,7 @@ func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSear update.category, update.severity, update.title, + update.deploymentAction, )) if update.lastPublished != (time.Time{}) { @@ -362,12 +381,13 @@ func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSear } type windowsUpdate struct { - identity string - revision string - category string - severity string - title string - lastPublished time.Time + identity string + revision string + category string + severity string + title string + deploymentAction string + lastPublished time.Time } // getUpdateStatus retrieves the update status of the given item. @@ -389,6 +409,11 @@ func (c *Collector) getUpdateStatus(item *wuapi.Update) (windowsUpdate, error) { return windowsUpdate{}, fmt.Errorf("get Title: %w", err) } + deploymentAction, err := item.DeploymentAction() + if err != nil { + return windowsUpdate{}, fmt.Errorf("get DeploymentAction: %w", err) + } + identity, err := item.Identity() if err != nil { return windowsUpdate{}, fmt.Errorf("get Identity: %w", err) @@ -421,15 +446,27 @@ func (c *Collector) getUpdateStatus(item *wuapi.Update) (windowsUpdate, error) { } return windowsUpdate{ - identity: updateID, - revision: strconv.FormatInt(int64(revision), 10), - category: categoryName, - severity: severity, - title: title, - lastPublished: lastPublishedDate, + identity: updateID, + revision: strconv.FormatInt(int64(revision), 10), + category: categoryName, + severity: severity, + title: title, + deploymentAction: deploymentActionLabel(deploymentAction), + lastPublished: lastPublishedDate, }, nil } +func deploymentActionLabel(action wuapi.DeploymentAction) string { + switch action { + case wuapi.DeploymentActionInstallation: + return "installation" + case wuapi.DeploymentActionOptionalInstallation: + return "optional_installation" + default: + return strconv.FormatInt(int64(action), 10) + } +} + func getUpdateCategory(update *wuapi.Update) (string, error) { categories, err := update.Categories() if err != nil { diff --git a/internal/collector/update/update_internal_test.go b/internal/collector/update/update_internal_test.go index a8041817..2bfafe14 100644 --- a/internal/collector/update/update_internal_test.go +++ b/internal/collector/update/update_internal_test.go @@ -38,7 +38,7 @@ func TestFetchUpdatesPartialCollection(t *testing.T) { name string countError bool }{ - {name: "skip unreadable item"}, + {name: "skip unreadable and duplicate items"}, {name: "count failure", countError: true}, } { t.Run(tc.name, func(t *testing.T) { @@ -47,13 +47,13 @@ func TestFetchUpdatesPartialCollection(t *testing.T) { logger := slog.New(slog.NewTextHandler(&logs, nil)) c := &Collector{ logger: logger, - pendingUpdate: prometheus.NewDesc("test_pending", "", []string{"id", "revision", "category", "severity", "title"}, nil), + pendingUpdate: prometheus.NewDesc("test_pending", "", []string{"id", "revision", "category", "severity", "title", "deployment_action"}, nil), pendingUpdateLastPublished: prometheus.NewDesc("test_published", "", []string{"id", "revision"}, nil), queryDurationSeconds: prometheus.NewDesc("test_duration", "", nil, nil), lastScrapeMetric: prometheus.NewDesc("test_timestamp", "", nil, nil), } - searcher, attempted := fakeUpdateSearch(tc.countError) + searcher, attempted, criteria := fakeUpdateSearch(tc.countError) metrics, err := c.fetchUpdates(logger, searcher) if tc.countError { @@ -65,10 +65,12 @@ func TestFetchUpdatesPartialCollection(t *testing.T) { } require.NoError(t, err) - require.Equal(t, []int32{0, 1, 2}, *attempted) - require.Len(t, metrics, 6, "two readable updates retain info and publication metrics plus scrape metrics") + require.Equal(t, searchCriteria, *criteria) + require.Equal(t, []int32{0, 1, 2, 3}, *attempted) + require.Len(t, metrics, 6, "two distinct readable updates retain info and publication metrics plus scrape metrics") ids := []string{} + actions := []string{} for _, metric := range metrics { if metric.Desc() != c.pendingUpdate { @@ -79,13 +81,17 @@ func TestFetchUpdatesPartialCollection(t *testing.T) { require.NoError(t, metric.Write(&sample)) for _, label := range sample.GetLabel() { - if label.GetName() == "id" { + switch label.GetName() { + case "id": ids = append(ids, label.GetValue()) + case "deployment_action": + actions = append(actions, label.GetValue()) } } } require.Equal(t, []string{"update-0", "update-2"}, ids) + require.Equal(t, []string{"installation", "optional_installation"}, actions) require.Contains(t, logs.String(), "failed to fetch Windows Update history item") require.Contains(t, logs.String(), "get collection item 1") }) @@ -93,11 +99,15 @@ func TestFetchUpdatesPartialCollection(t *testing.T) { } // fakeUpdateSearch implements only the native slots exercised by a scrape. -// Item 1 cannot be read; both surrounding items remain available. -func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32) { +// Item 1 cannot be read; both surrounding items remain available. Item 0 is a +// regular update and item 2 an optional one. Item 3 repeats the identity of +// item 0. The search criteria are recorded. +func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32, *string) { release := windows.NewCallback(func(uintptr) uintptr { return 0 }) attempted := []int32{} + var criteria string + var selected int32 var categoryMethods [10]uintptr @@ -119,11 +129,11 @@ func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32) { return 0 }) identityMethods[8] = windows.NewCallback(func(_ uintptr, out **uint16) uintptr { - return fakeBSTR(fmt.Sprintf("update-%d", selected), out) + return fakeBSTR(fmt.Sprintf("update-%d", selected%3), out) }) identity := &wuapi.UpdateIdentity{VTable: &identityMethods[0]} - var updateMethods [35]uintptr + var updateMethods [49]uintptr updateMethods[2] = release updateMethods[7] = windows.NewCallback(func(_ uintptr, out **uint16) uintptr { @@ -147,6 +157,14 @@ func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32) { updateMethods[34] = windows.NewCallback(func(_ uintptr, out **uint16) uintptr { return fakeBSTR("Critical", out) }) + updateMethods[48] = windows.NewCallback(func(_ uintptr, out *wuapi.DeploymentAction) uintptr { + *out = wuapi.DeploymentActionInstallation + if selected == 2 { + *out = wuapi.DeploymentActionOptionalInstallation + } + + return 0 + }) update := &wuapi.Update{VTable: &updateMethods[0]} var collectionMethods [11]uintptr @@ -169,7 +187,7 @@ func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32) { return 0x80070005 } - *out = 3 + *out = 4 return 0 }) @@ -188,13 +206,14 @@ func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32) { var searcherMethods [20]uintptr searcherMethods[2] = release - searcherMethods[19] = windows.NewCallback(func(_ uintptr, _ *uint16, out **wuapi.SearchResult) uintptr { + searcherMethods[19] = windows.NewCallback(func(_ uintptr, input *uint16, out **wuapi.SearchResult) uintptr { + criteria = windows.UTF16PtrToString(input) *out = result return 0 }) - return &wuapi.UpdateSearcher{VTable: &searcherMethods[0]}, &attempted + return &wuapi.UpdateSearcher{VTable: &searcherMethods[0]}, &attempted, &criteria } func fakeBSTR(value string, out **uint16) uintptr { diff --git a/internal/ole/wuapi/wuapi_windows.go b/internal/ole/wuapi/wuapi_windows.go index bcc968fc..0b0d9bcc 100644 --- a/internal/ole/wuapi/wuapi_windows.go +++ b/internal/ole/wuapi/wuapi_windows.go @@ -42,6 +42,7 @@ const ( updateIdentity = 19 updateDeploymentTime = 30 updateSeverity = 34 + updateDeploymentAction = 48 identityRevision = 7 identityUpdateID = 8 categoryCollectionItem = 7 @@ -113,6 +114,23 @@ func (u *Update) Identity() (*UpdateIdentity, error) { func (u *Update) LastDeploymentChangeTime() (ole.DATE, error) { return u.Get[ole.DATE](updateDeploymentTime) } + +// DeploymentAction is the WUA DeploymentAction enumeration. +// https://learn.microsoft.com/en-us/windows/win32/api/wuapi/ne-wuapi-deploymentaction +type DeploymentAction int32 + +const ( + DeploymentActionNone DeploymentAction = 0 + DeploymentActionInstallation DeploymentAction = 1 + DeploymentActionUninstallation DeploymentAction = 2 + DeploymentActionDetection DeploymentAction = 3 + DeploymentActionOptionalInstallation DeploymentAction = 4 +) + +func (u *Update) DeploymentAction() (DeploymentAction, error) { + return u.Get[DeploymentAction](updateDeploymentAction) +} + func (i *UpdateIdentity) UpdateID() (string, error) { return i.String(identityUpdateID) } func (i *UpdateIdentity) RevisionNumber() (int32, error) { return i.Get[int32](identityRevision) } func (c *Category) Name() (string, error) { return c.String(categoryName) }