From 4f8fc2baef8102dde1541ab5e9da28d299d6d811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Fri, 16 Oct 2026 21:30:11 +0200 Subject: [PATCH] service: add delayed_start and trigger_start labels to windows_service_info (#2768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jan-Otto Kröpke --- docs/collector.service.md | 66 +++++-- docs/upgrade/0.32.md | 1 + internal/collector/service/AGENTS.md | 13 ++ internal/collector/service/config_cache.go | 9 +- .../service/config_cache_internal_test.go | 3 +- internal/collector/service/service.go | 165 ++++++++++++++---- .../service/service_internal_test.go | 87 ++++++++- tools/e2e-output.txt | 2 +- 8 files changed, 289 insertions(+), 57 deletions(-) diff --git a/docs/collector.service.md b/docs/collector.service.md index f5db020a..6c394869 100644 --- a/docs/collector.service.md +++ b/docs/collector.service.md @@ -30,22 +30,24 @@ Recommended to keep down number of returned metrics. Default: `auto,boot,manual, ## Configuration cache -The service configuration (display name, start mode, path and account, used by -`windows_service_info` and `windows_service_start_mode`) is cached between scrapes. +The service configuration (display name, start mode, delayed start, triggers, path and +account, used by `windows_service_info` and `windows_service_start_mode`) is cached between scrapes. The collector watches the registry key `HKLM\SYSTEM\CurrentControlSet\Services`. When a service's own key changes, for example after `sc config`, a change in the Services console or a reinstall, its configuration is queried again on the next scrape. All configurations are queried again at the latest every 10 minutes. The service state and process are queried -on every scrape. +on every scrape. Reading the delayed start flag and the triggers takes extra calls per service, +which run when a configuration is queried: on a cache miss, or on every scrape if the registry +key can't be watched and the cache is disabled. ## Metrics -| Name | Description | Type | Labels | -|------------------------------|-----------------------------------------------------------------------------------------------|-------|---------------------------------------| -| `windows_service_info` | Contains service information run as user in labels, constant 1 | gauge | service, display_name, run_as, path_name | -| `windows_service_start_mode` | The start mode of the service, 1 if the current start mode, 0 otherwise | gauge | service, start_mode | -| `windows_service_state` | The state of the service, 1 if the current state, 0 otherwise | gauge | service, state | -| `windows_service_process` | Process of started service. The value is the creation time of the process as a Unix timestamp | gauge | service, process_id | +| Name | Description | Type | Labels | +|------------------------------|-----------------------------------------------------------------------------------------------|-------|--------------------------------------------------------------------------| +| `windows_service_info` | Contains service information run as user in labels, constant 1 | gauge | service, display_name, run_as, path_name, delayed_start, trigger_start | +| `windows_service_start_mode` | The start mode of the service, 1 if the current start mode, 0 otherwise | gauge | service, start_mode | +| `windows_service_state` | The state of the service, 1 if the current state, 0 otherwise | gauge | service, state | +| `windows_service_process` | Process of started service. The value is the creation time of the process as a Unix timestamp | gauge | service, process_id | ### States @@ -69,6 +71,22 @@ A service can have the following start modes: Note that there is some overlap with service state. +### Delayed and trigger start + +The Services console shows "Automatic (Delayed Start)" and "(Trigger Start)" in addition to +the start mode. `windows_service_start_mode` reports these services with their start mode +(`auto` or `manual`); the labels of `windows_service_info` tell them apart: + +- `delayed_start` is `true` for an `auto` service that starts after the other automatic + services, `false` otherwise. Windows ignores the delayed start setting of services that + don't start automatically, so it is always `false` for them. +- `trigger_start` is `true` if the service has triggers, which start or stop it when an event + occurs, for example when a device arrives, the computer joins a domain or a network becomes + available. Such a service can stop by design while its start mode is `auto`. + +Both labels are empty if the service configuration, or the delayed start flag and triggers, +can't be read. The other labels are still published in the second case. + ### Run As Account name under which a service runs. Depending on the service type, the account name may be in the form of "DomainName\Username" or UPN format ("Username@DomainName"). @@ -78,8 +96,8 @@ Account name under which a service runs. Depending on the service type, the acco ``` # HELP windows_service_info A metric with a constant '1' value labeled with service information # TYPE windows_service_info gauge -windows_service_info{display_name="Declared Configuration(DC) service",service="dcsvc",path_name="C:\\WINDOWS\\system32\\svchost.exe -k netsvcs -p",run_as="LocalSystem"} 1 -windows_service_info{display_name="Designs",service="Themes",path_name="C:\\WINDOWS\\System32\\svchost.exe -k netsvcs -p",run_as="LocalSystem"} 1 +windows_service_info{delayed_start="false",display_name="Declared Configuration(DC) service",service="dcsvc",path_name="C:\\WINDOWS\\system32\\svchost.exe -k netsvcs -p",run_as="LocalSystem",trigger_start="true"} 1 +windows_service_info{delayed_start="false",display_name="Designs",service="Themes",path_name="C:\\WINDOWS\\System32\\svchost.exe -k netsvcs -p",run_as="LocalSystem",trigger_start="false"} 1 # HELP windows_service_process Process of started service. The value is the creation time of the process as a unix timestamp. # TYPE windows_service_process gauge windows_service_process{service="Themes",process_id="2856"} 1.7244891e+09 @@ -148,3 +166,29 @@ groups: description: "Service {{ $labels.service }} on instance {{ $labels.instance }} has been down for more than 3 minutes." ``` In this example, `instance` is the target label of the host. So each alert will be processed per host, which is then used in the alert description. + +The next rule alerts on every automatic service that is not running, except trigger-start +services, which stop by design: + +```yaml +groups: +- name: Windows services + rules: + + # Sends an alert when a service with start mode auto and without triggers is not running for 15 minutes. + - alert: WindowsAutoServiceNotRunning + expr: | + windows_service_state{state="running"} == 0 + and on (instance, service) windows_service_start_mode{start_mode="auto"} == 1 + unless on (instance, service) windows_service_info{trigger_start="true"} + for: 15m + labels: + severity: warning + annotations: + summary: "Service {{ $labels.service }} not running" + description: "Automatic service {{ $labels.service }} on instance {{ $labels.instance }} has not been running for more than 15 minutes." +``` + +Delayed-start services start about two minutes after the other automatic services, so the +`for` duration covers them after a reboot. To leave them out as well, add +`unless on (instance, service) windows_service_info{delayed_start="true"}`. diff --git a/docs/upgrade/0.32.md b/docs/upgrade/0.32.md index 3cc734d2..f9076f1b 100644 --- a/docs/upgrade/0.32.md +++ b/docs/upgrade/0.32.md @@ -900,6 +900,7 @@ windows_service_state{state="running"} == 0 ### Label changes - `windows_os_info` has an `installation_type` label, for example `Server` or `Client`. +- `windows_service_info` has the labels `delayed_start` and `trigger_start` (`true` or `false`), which tell "Automatic (Delayed Start)" and trigger-start services apart. `windows_service_start_mode` is unchanged. - The IIS HTTP request queue metrics (`windows_iis_http_requests_*`) have the label `app` instead of `site`. The value is unchanged: it always was the application pool that owns the HTTP.sys request queue, not a site. They are now filtered with `--collector.iis.app-include` and `--collector.iis.app-exclude` instead of the site filters. - All `windows_netframework_*` metrics have a `process_id` label, so processes with the same name, for example IIS worker processes (`w3wp`), can be joined with the `process` collector metrics. Only `.NET CLR Memory` reports the process ID; the other metrics take it from the `.NET CLR Memory` instance of the same sample and have an empty `process_id` if there is none. The new `--collector.netframework.process-include` and `--collector.netframework.process-exclude` flags filter the processes. The label changes the series identity: a restarted process, for example a recycled `w3wp`, starts a new series instead of continuing `process="w3wp"`. To keep the 0.31 series, aggregate with `without (process_id)` or `by (process)`. - In the `windows_hyperv_dynamic_memory_vm_*` metrics, unnamed VM instances have `vm="(unknown)"` (or `(unknown)#N`) instead of `vm="------"`. diff --git a/internal/collector/service/AGENTS.md b/internal/collector/service/AGENTS.md index 13069db6..42410e84 100644 --- a/internal/collector/service/AGENTS.md +++ b/internal/collector/service/AGENTS.md @@ -9,6 +9,19 @@ `RegNotifyChangeKeyValue` instead. Arm it with `REG_NOTIFY_THREAD_AGNOSTIC`: without it the registration ends when the arming OS thread exits, and Go goroutines are not bound to one. +- The delayed auto-start flag and the triggers are queried with + `QueryServiceConfig2` on the same handle and cached with the configuration. + The SCM ignores the delayed flag unless the start type is auto, so it is only + queried for auto-start services. `x/sys/windows` has no + `SERVICE_TRIGGER_INFO`; the mirror in `service.go` only reads `cTriggers`. +- The delayed flag is the `DelayedAutostart` value of the service key, so a + change updates the key's last write time. Triggers are stored in the + `TriggerInfo` subkey. Adding the first trigger creates it, which updates the + last write time of the service key (`TestCollectorPicksUpConfigChanges` + checks this). That a later trigger change made with `sc triggerinfo` also + does is assumed, not verified: the SCM is believed to rewrite the whole + `TriggerInfo` subkey. If it only changes deeper keys, the cache picks the + change up after at most `configCacheMaxAge`. - The subtree notification fires every few seconds on a normal machine (W32Time, Tcpip, VSS, driver `Enum` keys, Hyper-V). Treat it as a hint only and compare the last write time of each service's own key, which changes only for its own diff --git a/internal/collector/service/config_cache.go b/internal/collector/service/config_cache.go index d67efeed..f5dcd5e6 100644 --- a/internal/collector/service/config_cache.go +++ b/internal/collector/service/config_cache.go @@ -26,7 +26,6 @@ import ( "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "golang.org/x/sys/windows/svc/mgr" ) const ( @@ -54,7 +53,7 @@ var errConfigCacheClosed = errors.New("service configuration cache is closed") // cachedServiceConfig is the result of queryServiceConfig. type cachedServiceConfig struct { - config mgr.Config + config serviceConfig available bool // lastWrite is the last write time of the service's registry key, read @@ -280,7 +279,7 @@ func (cache *serviceConfigCache) close() error { // getCachedServiceConfig returns the configuration of a service from the cache, // or queries and caches it on a miss. -func (c *Collector) getCachedServiceConfig(serviceName string) (mgr.Config, bool, error) { +func (c *Collector) getCachedServiceConfig(serviceName string) (serviceConfig, bool, error) { entry, generation, ok := c.configCache.get(serviceName) if ok { return entry.config, entry.available, nil @@ -296,7 +295,7 @@ func (c *Collector) getCachedServiceConfig(serviceName string) (mgr.Config, bool config, available, err := c.queryServiceConfig(serviceName) if err != nil { - return mgr.Config{}, false, err + return serviceConfig{}, false, err } switch { @@ -308,6 +307,8 @@ func (c *Collector) getCachedServiceConfig(serviceName string) (mgr.Config, bool case !available: // An unavailable configuration, for example a display name from a missing // MUI file, can become available without a change to the service key. + case !config.extendedAvailable: + // The delayed auto-start flag and triggers are queried again on the next scrape. case time.Since(lastWrite) < configCacheMinKeyAge: // Cached on a later scrape, once a second write in the same tick is ruled out. default: diff --git a/internal/collector/service/config_cache_internal_test.go b/internal/collector/service/config_cache_internal_test.go index bffa477c..97333977 100644 --- a/internal/collector/service/config_cache_internal_test.go +++ b/internal/collector/service/config_cache_internal_test.go @@ -25,7 +25,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows/registry" - "golang.org/x/sys/windows/svc/mgr" ) // newTestConfigCache returns a cache that watches a temporary key below @@ -68,7 +67,7 @@ func newTestConfigCache(t *testing.T) (*serviceConfigCache, registry.Key) { _, generation, _ := cache.get(name) cache.put(name, generation, cachedServiceConfig{ - config: mgr.Config{DisplayName: name}, + config: serviceConfig{DisplayName: name}, available: true, lastWrite: lastWrite, }) diff --git a/internal/collector/service/service.go b/internal/collector/service/service.go index ccfce198..a57852bf 100644 --- a/internal/collector/service/service.go +++ b/internal/collector/service/service.go @@ -95,6 +95,32 @@ type Collector struct { metricCache metriccache.Cache[string, serviceValues] } +// serviceConfig is the configuration of a service that the collector publishes. +// Of the embedded [mgr.Config], only BinaryPathName, DisplayName, StartType, +// ServiceStartName and DelayedAutoStart are set. DelayedAutoStart is only +// queried for auto-start services and false for the others. +type serviceConfig struct { + mgr.Config + + // triggerStart is true if the service has triggers, which start or stop it + // on events such as a device arrival or a network change. + triggerStart bool + + // extendedAvailable is true if DelayedAutoStart and triggerStart were queried + // successfully. Otherwise both are false and their labels stay empty. + extendedAvailable bool +} + +// serviceTriggerInfo mirrors SERVICE_TRIGGER_INFO, which QueryServiceConfig2 +// returns for SERVICE_CONFIG_TRIGGER_INFO. golang.org/x/sys/windows doesn't define it. +// Only cTriggers is read; the pointers point into the same buffer. +// https://learn.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_trigger_info +type serviceTriggerInfo struct { + triggers uint32 // cTriggers + trigger uintptr // pTriggers + _ uintptr // pReserved +} + // serviceValues holds the inputs of the metrics of a service besides its name. type serviceValues struct { state uint32 @@ -102,7 +128,10 @@ type serviceValues struct { displayName string runAs string pathName string - processID uint32 + // delayedStart and triggerStart are the label values "true" or "false". + delayedStart string + triggerStart string + processID uint32 // processStartTime is the creation time of the process in nanoseconds, // or 0 if it is unknown and the process metric is not published. processStartTime uint64 @@ -187,7 +216,9 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.serviceConfigPoolBytes = sync.Pool{ New: func() any { - return new([]byte) + buf := make([]byte, 1024) + + return &buf }, } @@ -196,7 +227,7 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.info = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "info"), "A metric with a constant '1' value labeled with service information", - []string{"service", "display_name", "run_as", "path_name"}, + []string{"service", "display_name", "run_as", "path_name", "delayed_start", "trigger_start"}, nil, ) c.state = prometheus.NewDesc( @@ -408,6 +439,13 @@ func (c *Collector) collectService(ch chan<- prometheus.Metric, scrape *metricca processID: service.status.ProcessId, } + // The labels stay empty if the service configuration or the delayed + // auto-start flag and triggers are not available. + if configAvailable && serviceConfig.extendedAvailable { + values.delayedStart = strconv.FormatBool(serviceConfig.DelayedAutoStart) + values.triggerStart = strconv.FormatBool(serviceConfig.triggerStart) + } + if values.processID != 0 { values.processStartTime = c.processStartTime(serviceName, values.processID) } @@ -435,6 +473,8 @@ func (c *Collector) serviceMetrics(serviceName string, values serviceValues) []p values.displayName, values.runAs, values.pathName, + values.delayedStart, + values.triggerStart, )) var ( @@ -588,16 +628,16 @@ func (c *Collector) queryAllServices() ([]serviceEntry, error) { // queryServiceConfig opens the service and queries its configuration. // configAvailable is false if the service has no readable configuration. -func (c *Collector) queryServiceConfig(serviceName string) (mgr.Config, bool, error) { +func (c *Collector) queryServiceConfig(serviceName string) (serviceConfig, bool, error) { serviceNamePtr, err := windows.UTF16PtrFromString(serviceName) if err != nil { - return mgr.Config{}, false, fmt.Errorf("invalid service name: %w", err) + return serviceConfig{}, false, fmt.Errorf("invalid service name: %w", err) } // Open connection for service handler. serviceHandle, err := windows.OpenService(c.serviceManagerHandle.Handle, serviceNamePtr, windows.SERVICE_QUERY_CONFIG) if err != nil { - return mgr.Config{}, false, fmt.Errorf("failed to open service: %w", err) + return serviceConfig{}, false, fmt.Errorf("failed to open service: %w", err) } // Create handle for each service. @@ -612,10 +652,10 @@ func (c *Collector) queryServiceConfig(serviceName string) (mgr.Config, bool, er }(serviceManager) // Get Service Configuration. - serviceConfig, err := c.getServiceConfig(serviceManager) + config, err := c.getServiceConfig(serviceManager) if err != nil { if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_MUI_FILE_NOT_FOUND) { - return mgr.Config{}, false, fmt.Errorf("failed to get service configuration: %w", err) + return serviceConfig{}, false, fmt.Errorf("failed to get service configuration: %w", err) } c.logger.Log(context.Background(), slog.LevelDebug, "failed collecting service config", @@ -623,10 +663,10 @@ func (c *Collector) queryServiceConfig(serviceName string) (mgr.Config, bool, er slog.String("service", serviceName), ) - return mgr.Config{}, false, nil + return serviceConfig{}, false, nil } - return serviceConfig, true, nil + return config, true, nil } func (c *Collector) getProcessStartTime(pid uint32) (uint64, error) { @@ -657,45 +697,100 @@ func (c *Collector) getProcessStartTime(pid uint32) (uint64, error) { return uint64(creation.Nanoseconds()), nil } -// getServiceConfig is an optimized variant of [mgr.Service] that only +// getServiceConfig is an optimized variant of [mgr.Service.Config] that only // retrieves the necessary information. -func (c *Collector) getServiceConfig(service *mgr.Service) (mgr.Config, error) { - var serviceConfig *windows.QUERY_SERVICE_CONFIG +func (c *Collector) getServiceConfig(service *mgr.Service) (serviceConfig, error) { + buf := c.serviceConfigPoolBytes.Get().(*[]byte) //nolint:forcetypeassert // The pool only holds *[]byte. + defer c.serviceConfigPoolBytes.Put(buf) - bytesNeeded := uint32(1024) - - buf, ok := c.serviceConfigPoolBytes.Get().(*[]byte) - if !ok || len(*buf) == 0 { - *buf = make([]byte, bytesNeeded) - } else { - bytesNeeded = uint32(cap(*buf)) + err := queryServiceConfigBuffer(buf, func(data *byte, size uint32, bytesNeeded *uint32) error { + return windows.QueryServiceConfig(service.Handle, (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(data)), size, bytesNeeded) + }) + if err != nil { + return serviceConfig{}, err } - for { - serviceConfig = (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(&(*buf)[0])) + queryServiceConfig := (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(&(*buf)[0])) - err := windows.QueryServiceConfig(service.Handle, serviceConfig, bytesNeeded, &bytesNeeded) + config := serviceConfig{ + BinaryPathName: windows.UTF16PtrToString(queryServiceConfig.BinaryPathName), + DisplayName: windows.UTF16PtrToString(queryServiceConfig.DisplayName), + StartType: queryServiceConfig.StartType, + ServiceStartName: windows.UTF16PtrToString(queryServiceConfig.ServiceStartName), + } + + // A failure only leaves the delayed_start and trigger_start labels empty. + // It must not hide the rest of the configuration. + if err := queryExtendedServiceConfig(service.Handle, buf, &config); err != nil { + c.logger.Log(context.Background(), slog.LevelDebug, "failed to query the delayed auto-start flag and triggers of the service", + slog.Any("err", err), + slog.String("service", service.Name), + ) + + config.DelayedAutoStart = false + config.triggerStart = false + + return config, nil + } + + config.extendedAvailable = true + + return config, nil +} + +// queryExtendedServiceConfig queries the delayed auto-start flag and the +// triggers of a service with QueryServiceConfig2 into config. buf must not be +// empty; the configuration strings must already be copied out of it. +func queryExtendedServiceConfig(handle windows.Handle, buf *[]byte, config *serviceConfig) error { + // The service control manager ignores the delayed auto-start flag of services + // that don't start automatically, so it is only queried for them. + if config.StartType == windows.SERVICE_AUTO_START { + var ( + delayed windows.SERVICE_DELAYED_AUTO_START_INFO + bytesNeeded uint32 + ) + + err := windows.QueryServiceConfig2(handle, windows.SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + (*byte)(unsafe.Pointer(&delayed)), uint32(unsafe.Sizeof(delayed)), &bytesNeeded) + if err != nil { + return fmt.Errorf("failed to query the delayed auto-start flag: %w", err) + } + + config.DelayedAutoStart = delayed.IsDelayedAutoStartUp != 0 + } + + err := queryServiceConfigBuffer(buf, func(data *byte, size uint32, bytesNeeded *uint32) error { + return windows.QueryServiceConfig2(handle, windows.SERVICE_CONFIG_TRIGGER_INFO, data, size, bytesNeeded) + }) + if err != nil { + return fmt.Errorf("failed to query the service triggers: %w", err) + } + + config.triggerStart = (*serviceTriggerInfo)(unsafe.Pointer(&(*buf)[0])).triggers > 0 + + return nil +} + +// queryServiceConfigBuffer calls query with the buffer and grows the buffer +// until the result fits. query wraps QueryServiceConfig or QueryServiceConfig2. +// The buffer must not be empty. +func queryServiceConfigBuffer(buf *[]byte, query func(data *byte, size uint32, bytesNeeded *uint32) error) error { + for { + var bytesNeeded uint32 + + err := query(&(*buf)[0], uint32(len(*buf)), &bytesNeeded) if err == nil { - break + return nil } if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) && !errors.Is(err, windows.ERROR_MORE_DATA) { - return mgr.Config{}, err + return err } if bytesNeeded <= uint32(len(*buf)) { - return mgr.Config{}, fmt.Errorf("win32 reports buffer too small (%d), but buffer is large enough (%d): %w", uint32(cap(*buf)), bytesNeeded, err) + return fmt.Errorf("win32 reports the buffer as too small (%d bytes needed, %d available): %w", bytesNeeded, len(*buf), err) } *buf = make([]byte, bytesNeeded) } - - defer c.serviceConfigPoolBytes.Put(buf) - - return mgr.Config{ - BinaryPathName: windows.UTF16PtrToString(serviceConfig.BinaryPathName), - DisplayName: windows.UTF16PtrToString(serviceConfig.DisplayName), - StartType: serviceConfig.StartType, - ServiceStartName: windows.UTF16PtrToString(serviceConfig.ServiceStartName), - }, nil } diff --git a/internal/collector/service/service_internal_test.go b/internal/collector/service/service_internal_test.go index 8e91185e..9f7ebd85 100644 --- a/internal/collector/service/service_internal_test.go +++ b/internal/collector/service/service_internal_test.go @@ -26,6 +26,7 @@ import ( "strings" "testing" "time" + "unsafe" "github.com/prometheus-community/windows_exporter/internal/scrapecache" "github.com/prometheus/client_golang/prometheus" @@ -98,6 +99,40 @@ func serviceMetrics(t assert.TestingT, c *Collector, name string) map[string]flo return metrics } +// setStartTrigger gives a service a trigger that starts it once the first IP +// address becomes available. golang.org/x/sys/windows/svc/mgr can't set triggers. +func setStartTrigger(t *testing.T, service *mgr.Service) { + t.Helper() + + // NETWORK_MANAGER_FIRST_IP_ADDRESS_ARRIVAL_GUID. + subtype := windows.GUID{Data1: 0x4f27f2de, Data2: 0x14e2, Data3: 0x430b, Data4: [8]byte{0xa5, 0x49, 0x7c, 0xd4, 0x8c, 0xbc, 0x82, 0x45}} + + // SERVICE_TRIGGER. + trigger := struct { + triggerType uint32 + action uint32 + subtype *windows.GUID + dataItems uint32 + data uintptr + }{ + triggerType: 3, // SERVICE_TRIGGER_TYPE_IP_ADDRESS_AVAILABILITY + action: 1, // SERVICE_TRIGGER_ACTION_SERVICE_START + subtype: &subtype, + } + + // SERVICE_TRIGGER_INFO. + info := struct { + triggers uint32 + trigger unsafe.Pointer + reserved uintptr + }{ + triggers: 1, + trigger: unsafe.Pointer(&trigger), + } + + require.NoError(t, windows.ChangeServiceConfig2(service.Handle, windows.SERVICE_CONFIG_TRIGGER_INFO, (*byte)(unsafe.Pointer(&info)))) +} + // TestCollectorPicksUpConfigChanges creates a service, changes its configuration // through the service control manager and checks that the cached configuration // is replaced. It needs an elevated process, which the Windows CI runners are. @@ -148,7 +183,7 @@ func TestCollectorPicksUpConfigChanges(t *testing.T) { metrics := serviceMetrics(t, c, name) assert.InDelta(t, 1.0, metrics["windows_service_start_mode{start_mode=manual}"], 0) - assert.Contains(t, metrics, `windows_service_info{display_name=before,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem}`) + assert.Contains(t, metrics, `windows_service_info{delayed_start=false,display_name=before,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=false}`) _, _, ok := c.configCache.get(name) require.True(t, ok, "the configuration must be cached after a scrape") @@ -156,16 +191,45 @@ func TestCollectorPicksUpConfigChanges(t *testing.T) { config, err := service.Config() require.NoError(t, err) - config.StartType = mgr.StartDisabled + // The service is never started. It is switched back to manual start below, + // so that a killed test doesn't leave an auto-start service behind. + config.StartType = mgr.StartAutomatic + config.DelayedAutoStart = true config.DisplayName = "after" require.NoError(t, service.UpdateConfig(config)) // The change notification is delivered asynchronously. require.EventuallyWithT(t, func(collect *assert.CollectT) { metrics := serviceMetrics(collect, c, name) - assert.InDelta(collect, 1.0, metrics["windows_service_start_mode{start_mode=disabled}"], 0) + assert.InDelta(collect, 1.0, metrics["windows_service_start_mode{start_mode=auto}"], 0) assert.InDelta(collect, 0.0, metrics["windows_service_start_mode{start_mode=manual}"], 0) - assert.Contains(collect, metrics, `windows_service_info{display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem}`) + assert.Contains(collect, metrics, `windows_service_info{delayed_start=true,display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=false}`) + }, 5*time.Second, 50*time.Millisecond) + + // Windows ignores the delayed auto-start flag of a manual service, and so does the label. + config.StartType = mgr.StartManual + require.NoError(t, service.UpdateConfig(config)) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + metrics := serviceMetrics(collect, c, name) + assert.InDelta(collect, 1.0, metrics["windows_service_start_mode{start_mode=manual}"], 0) + assert.Contains(collect, metrics, `windows_service_info{delayed_start=false,display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=false}`) + }, 5*time.Second, 50*time.Millisecond) + + // Cache the changed configuration, so that the trigger must invalidate it. + time.Sleep(configCacheMinKeyAge + 100*time.Millisecond) + serviceMetrics(t, c, name) + + _, _, ok = c.configCache.get(name) + require.True(t, ok, "the changed configuration must be cached after a scrape") + + // Triggers are stored in the TriggerInfo subkey of the service key. Creating + // it changes the last write time of the service key. + setStartTrigger(t, service) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + metrics := serviceMetrics(collect, c, name) + assert.Contains(collect, metrics, `windows_service_info{delayed_start=false,display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=true}`) }, 5*time.Second, 50*time.Millisecond) // The service control manager removes the service once the last handle is closed. @@ -182,3 +246,18 @@ func TestCollectorPicksUpConfigChanges(t *testing.T) { assert.False(collect, ok, "the configuration of a deleted service must be dropped") }, 5*time.Second, 50*time.Millisecond) } + +// TestServiceTriggerInfoLayout checks the mirror of SERVICE_TRIGGER_INFO +// (winsvc.h): DWORD cTriggers, PSERVICE_TRIGGER pTriggers and PBYTE pReserved. +// The pointers are pointer-aligned, so cTriggers is padded to a pointer word. +func TestServiceTriggerInfoLayout(t *testing.T) { + t.Parallel() + + var info serviceTriggerInfo + + pointerSize := unsafe.Sizeof(uintptr(0)) + + assert.Equal(t, uintptr(0), unsafe.Offsetof(info.triggers)) + assert.Equal(t, pointerSize, unsafe.Offsetof(info.trigger)) + assert.Equal(t, 3*pointerSize, unsafe.Sizeof(info)) +} diff --git a/tools/e2e-output.txt b/tools/e2e-output.txt index ade1903a..9a8afc07 100644 --- a/tools/e2e-output.txt +++ b/tools/e2e-output.txt @@ -341,7 +341,7 @@ windows_scheduled_task_state{state="running",task="/Microsoft/Windows/PLA/GAEven windows_scheduled_task_state{state="unknown",task="/Microsoft/Windows/PLA/GAEvents"} 0 # HELP windows_service_info A metric with a constant '1' value labeled with service information # TYPE windows_service_info gauge -windows_service_info{display_name="Themes",path_name="C:\\Windows\\System32\\svchost.exe -k netsvcs -p",run_as="LocalSystem",service="Themes"} 1 +windows_service_info{delayed_start="false",display_name="Themes",path_name="C:\\Windows\\System32\\svchost.exe -k netsvcs -p",run_as="LocalSystem",service="Themes",trigger_start="false"} 1 # HELP windows_service_process Process of started service. The value is the creation time of the process as a unix timestamp. # TYPE windows_service_process gauge # HELP windows_service_start_mode The start mode of the service (StartMode)