update: include optional updates such as the monthly preview update (#2688)

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Otto Kröpke
2026-10-11 20:47:53 +02:00
committed by GitHub
co-authored by Claude
parent be5036dfa8
commit e88c8a712c
7 changed files with 410 additions and 68 deletions
+51 -14
View File
@@ -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 {
@@ -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 {
+18
View File
@@ -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) }