// 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 update import ( "context" "errors" "fmt" "log/slog" "math" "runtime" "strconv" "sync" "time" "github.com/alecthomas/kingpin/v2" "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/ole" "github.com/prometheus-community/windows_exporter/internal/ole/wuapi" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus-community/windows_exporter/internal/utils/recovery" "github.com/prometheus/client_golang/prometheus" ) const Name = "update" type Config struct { Online bool `yaml:"online"` ScrapeInterval time.Duration `yaml:"scrape-interval"` } //nolint:gochecknoglobals var ConfigDefaults = Config{ Online: false, 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") ) type Collector struct { config Config mu sync.RWMutex ctxCancelFn context.CancelFunc logger *slog.Logger metricsBuf []prometheus.Metric pendingUpdate *prometheus.Desc pendingUpdateLastPublished *prometheus.Desc queryDurationSeconds *prometheus.Desc lastScrapeMetric *prometheus.Desc } func New(config *Config) *Collector { if config == nil { config = &ConfigDefaults } c := &Collector{ config: *config, } return c } func NewWithFlags(app *kingpin.Application) *Collector { c := &Collector{ config: ConfigDefaults, } app.Flag( "collector.update.online", "Whether to search for updates online.", ).Default(strconv.FormatBool(ConfigDefaults.Online)).BoolVar(&c.config.Online) app.Flag( "collector.update.scrape-interval", "Define the interval of scraping Windows Update information.", ).Default(ConfigDefaults.ScrapeInterval.String()).DurationVar(&c.config.ScrapeInterval) return c } func (c *Collector) Close() error { if c.ctxCancelFn != nil { c.ctxCancelFn() c.ctxCancelFn = nil } return nil } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.logger = logger.With(slog.String("collector", Name)) c.logger.Info("update collector is in an experimental state! The configuration and metrics may change in future. Please report any issues.") 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", "deployment_action"}, nil, ) c.pendingUpdateLastPublished = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "pending_published_timestamp"), "Expose last published timestamp for a single pending update item", []string{"id", "revision"}, nil, ) c.queryDurationSeconds = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "scrape_query_duration_seconds"), "Duration of the last scrape query to the Windows Update API", nil, nil, ) c.lastScrapeMetric = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "scrape_timestamp_seconds"), "Timestamp of the last scrape", nil, nil, ) ctx, cancel := context.WithCancel(context.Background()) // scheduleUpdateStatus sends exactly one value to initErrCh, nil after a successful initialization. initErrCh := make(chan error, 1) go func() { err := recovery.Run(func() error { c.scheduleUpdateStatus(ctx, logger, initErrCh, c.config.Online) return nil }) if err != nil { logger.Error("Windows Update worker stopped", slog.Any("err", err), ) // Unblock Build if the panic happened during the initialization. select { case initErrCh <- err: default: } } }() c.ctxCancelFn = cancel if err := <-initErrCh; err != nil { return fmt.Errorf("failed to initialize Windows Update collector: %w", err) } return nil } func (c *Collector) GetName() string { return Name } func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { c.mu.RLock() defer c.mu.RUnlock() if c.metricsBuf == nil { return ErrNoUpdates } for _, m := range c.metricsBuf { ch <- m } return nil } func (c *Collector) scheduleUpdateStatus(ctx context.Context, logger *slog.Logger, initErrCh chan<- error, online bool) { // COM initialization and every interface call stay on the same OS thread. runtime.LockOSThread() defer runtime.UnlockOSThread() if err := ole.Initialize(); err != nil { initErrCh <- err return } defer ole.Uninitialize() session, err := wuapi.NewUpdateSession() if err != nil { initErrCh <- fmt.Errorf("create Microsoft.Update.Session: %w", err) return } defer session.Release() if err := session.SetUserLocale(1033); err != nil { initErrCh <- fmt.Errorf("set UserLocale: %w", err) return } if err := session.SetClientApplicationID("windows_exporter"); err != nil { initErrCh <- fmt.Errorf("set ClientApplicationID: %w", err) return } searcher, err := session.CreateUpdateSearcher() if err != nil { initErrCh <- fmt.Errorf("create update searcher: %w", err) return } defer searcher.Release() if err := searcher.SetOnline(online); err != nil { initErrCh <- fmt.Errorf("set Online: %w", err) return } // A local history query checks that the Windows Update service is enabled. if _, err := searcher.GetTotalHistoryCount(); err != nil { initErrCh <- fmt.Errorf("get update history count: %w", errors.Join(ErrUpdateServiceDisabled, err)) return } initErrCh <- nil var metricsBuf []prometheus.Metric for { // A panic in a single search must not stop the worker. err = recovery.Run(func() error { var err error metricsBuf, err = c.fetchUpdates(logger, searcher) return err }) if err != nil { logger.ErrorContext(ctx, "failed to fetch updates", slog.Any("err", err), ) c.mu.Lock() c.metricsBuf = nil c.mu.Unlock() } else { c.mu.Lock() c.metricsBuf = metricsBuf c.mu.Unlock() } // Failed searches also observe the interval and cancellation. select { case <-time.After(c.config.ScrapeInterval): case <-ctx.Done(): return } } } func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSearcher) ([]prometheus.Metric, error) { metricsBuf := make([]prometheus.Metric, 0, len(c.metricsBuf)*2+1) timeStart := time.Now() result, err := searcher.Search(searchCriteria) if err != nil { return nil, fmt.Errorf("search for updates: %w", err) } defer result.Release() logger.Debug(fmt.Sprintf("search for updates took %s", time.Since(timeStart))) metricsBuf = append(metricsBuf, prometheus.MustNewConstMetric( c.queryDurationSeconds, prometheus.GaugeValue, time.Since(timeStart).Seconds(), )) updates, err := result.Updates() if err != nil { return nil, fmt.Errorf("get updates: %w", err) } 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 { return nil, fmt.Errorf("enumerate updates: %w", err) } logger.Error("failed to fetch Windows Update history item", slog.Any("err", err)) continue } update, err := c.getUpdateStatus(item) if err != nil { logger.Error("failed to fetch Windows Update history item", slog.Any("err", err), ) continue } key := update.identity + "/" + update.revision if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} metricsBuf = append(metricsBuf, prometheus.MustNewConstMetric( c.pendingUpdate, prometheus.GaugeValue, 1, update.identity, update.revision, update.category, update.severity, update.title, update.deploymentAction, )) if update.lastPublished != (time.Time{}) { metricsBuf = append(metricsBuf, prometheus.MustNewConstMetric( c.pendingUpdateLastPublished, prometheus.GaugeValue, float64(update.lastPublished.Unix()), update.identity, update.revision, )) } } metricsBuf = append(metricsBuf, prometheus.MustNewConstMetric( c.lastScrapeMetric, prometheus.GaugeValue, float64(time.Now().UnixMicro())/1e6, )) return metricsBuf, nil } type windowsUpdate struct { identity string revision string category string severity string title string deploymentAction string lastPublished time.Time } // getUpdateStatus retrieves the update status of the given item. // other available properties can be found here: // https://learn.microsoft.com/en-us/previous-versions/windows/desktop/aa386114(v=vs.85) func (c *Collector) getUpdateStatus(item *wuapi.Update) (windowsUpdate, error) { severity, err := item.MsrcSeverity() if err != nil { return windowsUpdate{}, fmt.Errorf("get MsrcSeverity: %w", err) } categoryName, err := getUpdateCategory(item) if err != nil { return windowsUpdate{}, fmt.Errorf("get category: %w", err) } title, err := item.Title() if err != nil { 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) } defer identity.Release() updateID, err := identity.UpdateID() if err != nil { return windowsUpdate{}, fmt.Errorf("get UpdateID: %w", err) } revision, err := identity.RevisionNumber() if err != nil { return windowsUpdate{}, fmt.Errorf("get RevisionNumber: %w", err) } lastPublished, err := item.LastDeploymentChangeTime() if err != nil { return windowsUpdate{}, fmt.Errorf("get LastDeploymentChangeTime: %w", err) } lastPublishedDate, err := lastPublished.Time() if err != nil { c.logger.Debug("failed to convert LastDeploymentChangeTime", slog.String("title", title), slog.Any("err", err), ) lastPublishedDate = time.Time{} } return windowsUpdate{ 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 { return "", fmt.Errorf("get Categories: %w", err) } defer categories.Release() var categoryName string order := int64(math.MaxInt64) for category, err := range categories.All() { if err != nil { return "", fmt.Errorf("enumerate categories: %w", err) } name, err := category.Name() if err != nil { return "", fmt.Errorf("get category Name: %w", err) } categoryOrder, err := category.Order() if err != nil { return "", fmt.Errorf("get category Order: %w", err) } if int64(categoryOrder) < order { order = int64(categoryOrder) categoryName = name } } return categoryName, nil }