Bringing 40 print servers back in line with a documented service policy usually means either clicking through services.msc forty times or writing a PowerShell one-liner nobody will remember the intent of in six months. This tutorial builds a small, idempotent, declarative alternative in pure Ruby: describe the state you want, and let WMI do the rest.
Step through the build below:
Windows services drift. Someone flips the Print Spooler to Manual to chase down a bug and forgets to flip it back. A GPO change resets a service’s startup type fleet-wide except on the three boxes that were offline that day. The usual fix is either manual and doesn’t scale, or a pile of ad-hoc PowerShell that runs the same Set-Service command whether or not anything is actually wrong — which means every run looks like a change, and nobody can tell real drift from noise. winservice_manager.rb takes the Chef/Puppet approach instead: you write down the desired state of a set of services in YAML, and the script only touches WMI when a service is actually out of that state — and tells you exactly what it changed, or would change with --dry-run.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# winservice_manager.rb -- declarative Windows service management via WMI.
#
# Problem it solves:
# "Make sure the Spooler service is running and set to Automatic startup
# on all 40 print servers" is a one-line requirement that sysadmins end
# up re-solving by hand with services.msc, or with a pile of ad-hoc
# PowerShell one-liners that don't record *what* they changed. This
# script takes a small YAML file describing the desired state of a set
# of services (running/stopped, startup type) and reconciles reality to
# match it -- Chef/Puppet-style, but ~250 lines of stdlib Ruby talking
# straight to WMI's Win32_Service class. It only touches a service when
# it's actually out of the desired state, and it tells you exactly what
# it changed (or would change, with --dry-run).
#
# Prerequisites:
# - Ruby with win32ole (ships with any Windows Ruby install, e.g. RubyInstaller)
# - Administrator privileges (starting/stopping services and changing
# startup type requires elevation)
# - Run locally, or against a remote host with WMI/DCOM reachable and
# appropriate credentials (see --host)
#
# Usage:
# ruby winservice_manager.rb --config services.yml
# ruby winservice_manager.rb --config services.yml --dry-run
# ruby winservice_manager.rb --config services.yml --json
#
# services.yml:
# Spooler:
# state: running
# start_mode: automatic
# Fax:
# state: stopped
# start_mode: disabled
# wuauserv:
# state: running
#
# Exit codes (cron/monitoring friendly):
# 0 = everything already matched desired state (no changes needed)
# 1 = drift found and successfully reconciled (or would be, in --dry-run)
# 2 = one or more services missing or a WMI call failed
#
# No gems required -- win32ole, yaml, json, optparse are all stdlib.
require "optparse"
require "json"
require "yaml"
class WinServiceManager
# WMI Win32_Service StartMode strings, normalized to short symbols so the
# YAML config can say `automatic` / `manual` / `disabled` without the
# caller needing to know WMI's exact casing.
START_MODE_TO_WMI = {
automatic: "Automatic",
manual: "Manual",
disabled: "Disabled"
}.freeze
# Common WMI Win32_Service method return codes worth naming explicitly --
# the rest are just reported as "WMI error <code>". Source: MSDN
# Win32_Service.StartService / ChangeStartMode documentation.
WMI_RETURN_CODES = {
0 => "Success",
1 => "Not Supported",
2 => "Access Denied",
3 => "Dependent Services Running",
4 => "Invalid Service Control",
5 => "Service Cannot Accept Control",
6 => "Service Not Active",
7 => "Service Request Timeout",
8 => "Unknown Failure",
9 => "Path Not Found",
10 => "Service Already Running",
11 => "Service Database Locked",
12 => "Service Dependency Deleted",
13 => "Service Dependency Failure",
14 => "Service Disabled",
15 => "Service Logon Failed",
16 => "Service Marked For Deletion",
17 => "Service No Thread",
18 => "Status Circular Dependency",
19 => "Status Duplicate Name",
20 => "Status Invalid Name",
21 => "Status Invalid Parameter",
22 => "Status Invalid Service Account",
23 => "Status Service Exists",
24 => "Service Already Paused"
}.freeze
Result = Struct.new(:service, :status, :actions, :error, keyword_init: true) do
def to_h
{ service: service, status: status, actions: actions, error: error }.compact
end
end
# wmi: an object responding to #exec_query(wql) -- injected so this class
# can be unit-tested on any platform without a real Windows host or WIN32OLE.
# In production, WinServiceManager.connect(host) builds the real WMI adapter.
def initialize(wmi:, dry_run: false)
@wmi = wmi
@dry_run = dry_run
end
# Connects to WMI on `host` ("." for local machine) via WIN32OLE and
# returns a ready-to-use WinServiceManager. Only callable on Windows.
def self.connect(host: ".", dry_run: false)
require "win32ole"
swbem = WIN32OLE.connect("winmgmts:{impersonationLevel=impersonate}!//#{host}/root/cimv2")
new(wmi: RealWmiAdapter.new(swbem), dry_run: dry_run)
end
# Thin wrapper around the real WIN32OLE SWbemServices object so the
# reconciliation logic below only ever talks to the small #exec_query
# interface, never to WIN32OLE directly.
class RealWmiAdapter
def initialize(swbem)
@swbem = swbem
end
def exec_query(wql)
@swbem.ExecQuery(wql).to_enum(:each).to_a
end
end
# desired: Hash of { "ServiceName" => { state: :running|:stopped, start_mode: :automatic|:manual|:disabled } }
# Returns an Array of Result, one per service in `desired`.
def reconcile(desired)
desired.map { |name, spec| reconcile_one(name, spec) }
end
private
def reconcile_one(name, spec)
svc = find_service(name)
return Result.new(service: name, status: :missing, actions: [], error: "no such service") unless svc
actions = []
if spec[:start_mode]
wmi_mode = START_MODE_TO_WMI.fetch(spec[:start_mode]) { raise ArgumentError, "unknown start_mode #{spec[:start_mode]}" }
if svc.StartMode != wmi_mode
actions << change_start_mode(svc, name, wmi_mode)
end
end
if spec[:state]
current_running = (svc.State == "Running")
want_running = (spec[:state].to_sym == :running)
if want_running && !current_running
actions << start_service(svc, name)
elsif !want_running && current_running
actions << stop_service(svc, name)
end
end
failed = actions.any? { |a| a[:ok] == false }
status = actions.empty? ? :ok : (failed ? :error : :changed)
error = failed ? actions.find { |a| a[:ok] == false }[:detail] : nil
Result.new(service: name, status: status, actions: actions, error: error)
rescue StandardError => e
Result.new(service: name, status: :error, actions: actions || [], error: "#{e.class}: #{e.message}")
end
def find_service(name)
escaped = name.gsub("'", "''")
rows = @wmi.exec_query("SELECT * FROM Win32_Service WHERE Name='#{escaped}'")
rows.first
end
def change_start_mode(svc, name, wmi_mode)
if @dry_run
return { op: "set_start_mode", target: wmi_mode, ok: true, detail: "DRY-RUN: would change #{name} start mode -> #{wmi_mode}" }
end
rc = svc.ChangeStartMode(wmi_mode)
describe_result("set_start_mode", wmi_mode, rc)
end
def start_service(svc, _name)
return { op: "start", target: "Running", ok: true, detail: "DRY-RUN: would start service" } if @dry_run
rc = svc.StartService
describe_result("start", "Running", rc)
end
def stop_service(svc, _name)
return { op: "stop", target: "Stopped", ok: true, detail: "DRY-RUN: would stop service" } if @dry_run
rc = svc.StopService
describe_result("stop", "Stopped", rc)
end
def describe_result(op, target, return_code)
code = return_code.to_i
label = WMI_RETURN_CODES.fetch(code, "WMI error #{code}")
{ op: op, target: target, ok: code.zero?, detail: "#{label} (code #{code})" }
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { host: ".", dry_run: false, json: false }
OptionParser.new do |opts|
opts.banner = "Usage: winservice_manager.rb --config services.yml [options]"
opts.on("--config PATH", "YAML file describing desired service state (required)") { |v| options[:config] = v }
opts.on("--host HOST", "Target host for WMI (default: . = local machine)") { |v| options[:host] = v }
opts.on("--dry-run", "Report drift without changing anything") { options[:dry_run] = true }
opts.on("--json", "Emit machine-readable JSON instead of text") { options[:json] = true }
opts.on("-h", "--help", "Show this help") { puts opts; exit 0 }
end.parse!
abort "ERROR: --config is required" unless options[:config]
abort "ERROR: config file not found: #{options[:config]}" unless File.exist?(options[:config])
raw = YAML.safe_load(File.read(options[:config]), symbolize_names: true)
desired = raw.each_with_object({}) do |(name, spec), h|
h[name.to_s] = {
state: spec[:state]&.to_sym,
start_mode: spec[:start_mode]&.to_sym
}
end
manager = WinServiceManager.connect(host: options[:host], dry_run: options[:dry_run])
results = manager.reconcile(desired)
if options[:json]
puts JSON.pretty_generate(results.map(&:to_h))
else
results.each do |r|
case r.status
when :ok
puts "OK #{r.service}: already in desired state"
when :changed
puts "CHANGED #{r.service}:"
r.actions.each { |a| puts " - #{a[:op]} -> #{a[:target]}: #{a[:detail]}" }
when :missing
puts "MISSING #{r.service}: #{r.error}"
when :error
puts "ERROR #{r.service}: #{r.error}"
r.actions.each { |a| puts " - #{a[:op]} -> #{a[:target]}: #{a[:detail]}" unless a[:ok] }
end
end
end
exit_code =
if results.any? { |r| r.status == :missing || r.status == :error }
2
elsif results.any? { |r| r.status == :changed }
1
else
0
end
exit exit_code
end
The key design choice is the injected WMI adapter. WinServiceManager.new(wmi:, dry_run:) takes any object that responds to #exec_query(wql) — in production that’s RealWmiAdapter, a two-line wrapper around a real WIN32OLE SWbemServices connection. In tests, it’s a plain Ruby object backed by fake Win32_Service-shaped structs. That single seam is what makes the entire reconciliation engine — the diffing, the action selection, the WMI-return-code interpretation — testable on a Linux sandbox with zero access to a real Windows host, while the production code path is a five-line class. It’s the same trick config_state_engine.rb and docker_health_audit.rb use elsewhere in this toolkit, applied to WMI specifically.
$ ruby winservice_manager_test.rb
PASS: already-correct service reports :ok with no actions
PASS: stopped-but-should-run service is started
PASS: start action recorded with op=start
PASS: start_mode drift is corrected to Disabled
PASS: state already matched (Stopped) so only 1 action taken
PASS: running-but-should-stop service is stopped
PASS: 2 actions taken (stop + start_mode change)
PASS: missing service reports :missing status
PASS: missing service has a helpful error message
PASS: dry_run reports :changed
PASS: dry_run does NOT actually start the service
PASS: dry_run does NOT actually change start mode
PASS: dry_run action details are labeled DRY-RUN
PASS: WMI Access Denied surfaces as :error status
PASS: error detail names the failure
PASS: service state is left untouched after failed call
PASS: multi-service reconcile returns one result per service
PASS: svcA unaffected (already correct)
PASS: svcB corrected on both axes
ALL 8 TESTS PASSED (0 failures)
$ ruby winservice_manager.rb --help
Usage: winservice_manager.rb --config services.yml [options]
--config PATH YAML file describing desired service state (required)
--host HOST Target host for WMI (default: . = local machine)
--dry-run Report drift without changing anything
--json Emit machine-readable JSON instead of text
Most of the Windows scripts in this series so far have been read-only auditors — they tell you when a service, a firewall rule, or a scheduled task is wrong, and leave the fixing to a human. winservice_manager.rb is the first one that actually acts: given a small YAML file describing what state a set of services should be in, it talks to WMI’s Win32_Service class, compares reality to the declared desired state, and only issues a StartService, StopService, or ChangeStartMode call when something is actually drifted — the same idempotent philosophy Chef, Puppet, and Ansible bring to package and file management, in about 250 lines of dependency-free Ruby.
Full script + README on GitHub: ruby-devops-toolkit/winservice-manager
- Ruby with
win32ole— ships by default with RubyInstaller for Windows and any standard Windows Ruby build. - Administrator privileges — starting/stopping services and changing startup type both require elevation; run from an elevated shell or scheduled task.
- WMI/DCOM reachability if targeting a remote host via
--host— same firewall and credential requirements as any other remote WMI query. - YAML stdlib — bundled with Ruby, no gems required.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# winservice_manager.rb -- declarative Windows service management via WMI.
#
# Problem it solves:
# "Make sure the Spooler service is running and set to Automatic startup
# on all 40 print servers" is a one-line requirement that sysadmins end
# up re-solving by hand with services.msc, or with a pile of ad-hoc
# PowerShell one-liners that don't record *what* they changed. This
# script takes a small YAML file describing the desired state of a set
# of services (running/stopped, startup type) and reconciles reality to
# match it -- Chef/Puppet-style, but ~250 lines of stdlib Ruby talking
# straight to WMI's Win32_Service class. It only touches a service when
# it's actually out of the desired state, and it tells you exactly what
# it changed (or would change, with --dry-run).
#
# Prerequisites:
# - Ruby with win32ole (ships with any Windows Ruby install, e.g. RubyInstaller)
# - Administrator privileges (starting/stopping services and changing
# startup type requires elevation)
# - Run locally, or against a remote host with WMI/DCOM reachable and
# appropriate credentials (see --host)
#
# Usage:
# ruby winservice_manager.rb --config services.yml
# ruby winservice_manager.rb --config services.yml --dry-run
# ruby winservice_manager.rb --config services.yml --json
#
# services.yml:
# Spooler:
# state: running
# start_mode: automatic
# Fax:
# state: stopped
# start_mode: disabled
# wuauserv:
# state: running
#
# Exit codes (cron/monitoring friendly):
# 0 = everything already matched desired state (no changes needed)
# 1 = drift found and successfully reconciled (or would be, in --dry-run)
# 2 = one or more services missing or a WMI call failed
#
# No gems required -- win32ole, yaml, json, optparse are all stdlib.
require "optparse"
require "json"
require "yaml"
class WinServiceManager
# WMI Win32_Service StartMode strings, normalized to short symbols so the
# YAML config can say `automatic` / `manual` / `disabled` without the
# caller needing to know WMI's exact casing.
START_MODE_TO_WMI = {
automatic: "Automatic",
manual: "Manual",
disabled: "Disabled"
}.freeze
# Common WMI Win32_Service method return codes worth naming explicitly --
# the rest are just reported as "WMI error <code>". Source: MSDN
# Win32_Service.StartService / ChangeStartMode documentation.
WMI_RETURN_CODES = {
0 => "Success",
1 => "Not Supported",
2 => "Access Denied",
3 => "Dependent Services Running",
4 => "Invalid Service Control",
5 => "Service Cannot Accept Control",
6 => "Service Not Active",
7 => "Service Request Timeout",
8 => "Unknown Failure",
9 => "Path Not Found",
10 => "Service Already Running",
11 => "Service Database Locked",
12 => "Service Dependency Deleted",
13 => "Service Dependency Failure",
14 => "Service Disabled",
15 => "Service Logon Failed",
16 => "Service Marked For Deletion",
17 => "Service No Thread",
18 => "Status Circular Dependency",
19 => "Status Duplicate Name",
20 => "Status Invalid Name",
21 => "Status Invalid Parameter",
22 => "Status Invalid Service Account",
23 => "Status Service Exists",
24 => "Service Already Paused"
}.freeze
Result = Struct.new(:service, :status, :actions, :error, keyword_init: true) do
def to_h
{ service: service, status: status, actions: actions, error: error }.compact
end
end
# wmi: an object responding to #exec_query(wql) -- injected so this class
# can be unit-tested on any platform without a real Windows host or WIN32OLE.
# In production, WinServiceManager.connect(host) builds the real WMI adapter.
def initialize(wmi:, dry_run: false)
@wmi = wmi
@dry_run = dry_run
end
# Connects to WMI on `host` ("." for local machine) via WIN32OLE and
# returns a ready-to-use WinServiceManager. Only callable on Windows.
def self.connect(host: ".", dry_run: false)
require "win32ole"
swbem = WIN32OLE.connect("winmgmts:{impersonationLevel=impersonate}!//#{host}/root/cimv2")
new(wmi: RealWmiAdapter.new(swbem), dry_run: dry_run)
end
# Thin wrapper around the real WIN32OLE SWbemServices object so the
# reconciliation logic below only ever talks to the small #exec_query
# interface, never to WIN32OLE directly.
class RealWmiAdapter
def initialize(swbem)
@swbem = swbem
end
def exec_query(wql)
@swbem.ExecQuery(wql).to_enum(:each).to_a
end
end
# desired: Hash of { "ServiceName" => { state: :running|:stopped, start_mode: :automatic|:manual|:disabled } }
# Returns an Array of Result, one per service in `desired`.
def reconcile(desired)
desired.map { |name, spec| reconcile_one(name, spec) }
end
private
def reconcile_one(name, spec)
svc = find_service(name)
return Result.new(service: name, status: :missing, actions: [], error: "no such service") unless svc
actions = []
if spec[:start_mode]
wmi_mode = START_MODE_TO_WMI.fetch(spec[:start_mode]) { raise ArgumentError, "unknown start_mode #{spec[:start_mode]}" }
if svc.StartMode != wmi_mode
actions << change_start_mode(svc, name, wmi_mode)
end
end
if spec[:state]
current_running = (svc.State == "Running")
want_running = (spec[:state].to_sym == :running)
if want_running && !current_running
actions << start_service(svc, name)
elsif !want_running && current_running
actions << stop_service(svc, name)
end
end
failed = actions.any? { |a| a[:ok] == false }
status = actions.empty? ? :ok : (failed ? :error : :changed)
error = failed ? actions.find { |a| a[:ok] == false }[:detail] : nil
Result.new(service: name, status: status, actions: actions, error: error)
rescue StandardError => e
Result.new(service: name, status: :error, actions: actions || [], error: "#{e.class}: #{e.message}")
end
def find_service(name)
escaped = name.gsub("'", "''")
rows = @wmi.exec_query("SELECT * FROM Win32_Service WHERE Name='#{escaped}'")
rows.first
end
def change_start_mode(svc, name, wmi_mode)
if @dry_run
return { op: "set_start_mode", target: wmi_mode, ok: true, detail: "DRY-RUN: would change #{name} start mode -> #{wmi_mode}" }
end
rc = svc.ChangeStartMode(wmi_mode)
describe_result("set_start_mode", wmi_mode, rc)
end
def start_service(svc, _name)
return { op: "start", target: "Running", ok: true, detail: "DRY-RUN: would start service" } if @dry_run
rc = svc.StartService
describe_result("start", "Running", rc)
end
def stop_service(svc, _name)
return { op: "stop", target: "Stopped", ok: true, detail: "DRY-RUN: would stop service" } if @dry_run
rc = svc.StopService
describe_result("stop", "Stopped", rc)
end
def describe_result(op, target, return_code)
code = return_code.to_i
label = WMI_RETURN_CODES.fetch(code, "WMI error #{code}")
{ op: op, target: target, ok: code.zero?, detail: "#{label} (code #{code})" }
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { host: ".", dry_run: false, json: false }
OptionParser.new do |opts|
opts.banner = "Usage: winservice_manager.rb --config services.yml [options]"
opts.on("--config PATH", "YAML file describing desired service state (required)") { |v| options[:config] = v }
opts.on("--host HOST", "Target host for WMI (default: . = local machine)") { |v| options[:host] = v }
opts.on("--dry-run", "Report drift without changing anything") { options[:dry_run] = true }
opts.on("--json", "Emit machine-readable JSON instead of text") { options[:json] = true }
opts.on("-h", "--help", "Show this help") { puts opts; exit 0 }
end.parse!
abort "ERROR: --config is required" unless options[:config]
abort "ERROR: config file not found: #{options[:config]}" unless File.exist?(options[:config])
raw = YAML.safe_load(File.read(options[:config]), symbolize_names: true)
desired = raw.each_with_object({}) do |(name, spec), h|
h[name.to_s] = {
state: spec[:state]&.to_sym,
start_mode: spec[:start_mode]&.to_sym
}
end
manager = WinServiceManager.connect(host: options[:host], dry_run: options[:dry_run])
results = manager.reconcile(desired)
if options[:json]
puts JSON.pretty_generate(results.map(&:to_h))
else
results.each do |r|
case r.status
when :ok
puts "OK #{r.service}: already in desired state"
when :changed
puts "CHANGED #{r.service}:"
r.actions.each { |a| puts " - #{a[:op]} -> #{a[:target]}: #{a[:detail]}" }
when :missing
puts "MISSING #{r.service}: #{r.error}"
when :error
puts "ERROR #{r.service}: #{r.error}"
r.actions.each { |a| puts " - #{a[:op]} -> #{a[:target]}: #{a[:detail]}" unless a[:ok] }
end
end
end
exit_code =
if results.any? { |r| r.status == :missing || r.status == :error }
2
elsif results.any? { |r| r.status == :changed }
1
else
0
end
exit exit_code
end
How it works
The whole script is built around one method, #reconcile, and one seam, the injected wmi adapter.
WinServiceManager.connect — the only Windows-only line in the file
require "win32ole" happens lazily, inside WinServiceManager.connect — not at the top of the file. That means the class definition, the reconciliation logic, and everything else load and run fine on any platform; only calling .connect (which the CLI does, but a test harness never has to) actually requires a Windows host with WIN32OLE available. connect opens a winmgmts: moniker against root/cimv2 on the target host ("." for local) and wraps the resulting SWbemServices object in RealWmiAdapter, whose entire job is translating #exec_query(wql) into swbem.ExecQuery(wql).to_enum(:each).to_a — turning WMI’s native enumerator into a plain Ruby array the rest of the code can use with normal Array methods.
#reconcile(desired) — diff, then act
For each service name in the desired-state hash, reconcile_one queries Win32_Service by name, then checks two independent axes: startup type (svc.StartMode vs. the desired automatic/manual/disabled, normalized through START_MODE_TO_WMI) and running state (svc.State == "Running" vs. the desired running/stopped). Each axis that’s already correct is left alone — no WMI call, no log line, nothing. Only actual drift produces an action, which is exactly why running this against 40 servers where 38 are already compliant produces a report with 38 quiet OK lines and 2 loud CHANGED ones, instead of 40 identical “I set the startup type” lines that make real changes invisible in the noise.
Reading WMI’s return codes instead of guessing
WMI_RETURN_CODES maps the integer return codes that StartService, StopService, and ChangeStartMode actually return (per the Win32_Service MSDN documentation) into human-readable labels — 2 becomes "Access Denied", 10 becomes "Service Already Running". That turns a silent or cryptic numeric failure into "ERROR AudioSrv: Access Denied (code 2)" in the report, which is the difference between a five-second diagnosis and a five-minute MSDN search at 2am.
Dry-run and exit codes for cron/CI
--dry-run short-circuits every mutating call — change_start_mode, start_service, and stop_service all check @dry_run first and return a synthetic DRY-RUN:-prefixed result instead of ever calling into WMI. That means you can point this script at a whole fleet, review exactly what it intends to change, and only re-run without --dry-run once you trust the plan. The CLI’s exit code is cron/CI-friendly by design: 0 means nothing needed to change, 1 means drift was found and reconciled (a useful signal for “something happened, maybe worth a Slack ping” — see alert_notifier.rb elsewhere in this toolkit), and 2 means a service was missing or a WMI call actually failed.
Example output
Since win32ole isn’t available outside Windows, the reconciliation logic is fully unit-tested with a stub WMI adapter (winservice_manager_test.rb) built from fake Win32_Service-shaped objects exposing exactly the attributes and methods the real WIN32OLE objects do — State, StartMode, StartService, StopService, ChangeStartMode. This is the same honest approach the toolkit’s other WMI-dependent scripts (scheduled-task-audit, windows-firewall-audit) take, and it’s called out explicitly rather than glossed over:
On a real Windows host, text output looks like this:
Access Denied (code 2)on every action. The script needs an elevated (Administrator) PowerShell/cmd session, or a scheduled task configured to “Run with highest privileges” — starting/stopping services and changing startup type are both privileged operations regardless of how you invoke Ruby.WIN32OLERuntimeErrorconnecting to a remote host. Confirm WMI/DCOM ports are reachable through any firewall between the two hosts, and that the account running the script has WMI permissions on the remote machine (wmimgmt.msc→ right-clickWMI Control→ Security tab).- A service shows
:missingbut you can see it inservices.msc. Double-check the exact internal service name (not the display name) — WMI queries byName, which is often shorter and less friendly than what the Services console shows (e.g.wuauserv, not “Windows Update”). - Startup type change succeeds but the service doesn’t start on next reboot.
ChangeStartModeonly sets the registry-level startup type; it deliberately does not start a currently-stopped service unless you also declarestate: runningfor it, matching how you’d expect a declarative tool to behave. - Running against many hosts is slow. Each
.connectcall opens a fresh DCOM session; if you’re iterating over a large fleet, look at howssh_fleet_runner.rbelsewhere in this toolkit bounds concurrency with a thread pool — the same pattern applies here with oneWinServiceManagerinstance per host.
- Log-on account enforcement. Add a
log_on_as:key to the desired-state spec and compare againstsvc.StartName, usingChange()(the general-purpose WMI method) instead of the narrowerChangeStartModewhen the account itself needs to change. - Fleet-wide execution. Wrap
WinServiceManager.connect(host: h)in a loop (or borrow the bounded thread pool fromssh_fleet_runner.rb) to reconcile the sameservices.ymlacross every host in an inventory file, aggregating results into one report. - Wire it into
alert_notifier.rb. Since the CLI exits1on any reconciled drift, pipe a JSON run’s summary straight into the Slack alerting library from this series so unexpected service-state drift pages someone instead of silently auto-healing. - Dependency-aware ordering. WMI exposes service dependencies via the associator query
Win32_DependentService— a fuller version of this tool could refuse to stop a service that others still depend on, or restart dependents automatically after a start.