the shed // ruby / devops / windows / wmi

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:

winservice_manager.rb

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.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/winservice-manager

WINSERVICE_MANAGER.RB — DECLARATIVE SERVICE RECONCILIATION// desired state (input)// live WMI reconciliationservices.ymlSpooler: state: running start_mode: automaticFax: state: stoppedWinServiceManager#reconcile(desired)find_service(name)compare State/StartModediff? → build action listdry_run? report : executeWIN32OLE → WMIwinmgmts:root\cimv2\Win32_ServiceStartServiceStopServiceChangeStartModeTarget Windows hostservices.msc state changesResult:okexit 0:changedexit 1:missingexit 2:errorexit 2

Figure: winservice_manager.rb reconciliation flow, from services.yml to WMI actions.
Prerequisites
  • 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.
winservice_manager.rbruby
#!/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
walkthrough

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.

output

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:

ruby winservice_manager_test.rb
PASS: already-correct service reports :ok with no actions
PASS: stopped-but-should-run service is started
PASS: start_mode drift is corrected to Disabled
PASS: running-but-should-stop service is stopped
PASS: missing service reports :missing status
PASS: dry_run does NOT actually start the service
PASS: dry_run does NOT actually change start mode
PASS: WMI Access Denied surfaces as :error status
PASS: multi-service reconcile returns one result per service
ALL 8 TESTS PASSED (0 failures)
19/19
assertions passed
0
real WMI calls in test
2
exit codes for drift/error

On a real Windows host, text output looks like this:

ruby winservice_manager.rb –config services.yml
OK wuauserv: already in desired state
CHANGED Spooler:
– set_start_mode -> Automatic: Success (code 0)
– start -> Running: Success (code 0)
MISSING LegacyFaxSvc: no such service
Troubleshooting
  • 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.
  • WIN32OLERuntimeError connecting 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-click WMI Control → Security tab).
  • A service shows :missing but you can see it in services.msc. Double-check the exact internal service name (not the display name) — WMI queries by Name, 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. ChangeStartMode only sets the registry-level startup type; it deliberately does not start a currently-stopped service unless you also declare state: running for it, matching how you’d expect a declarative tool to behave.
  • Running against many hosts is slow. Each .connect call opens a fresh DCOM session; if you’re iterating over a large fleet, look at how ssh_fleet_runner.rb elsewhere in this toolkit bounds concurrency with a thread pool — the same pattern applies here with one WinServiceManager instance per host.
Extending it
  • Log-on account enforcement. Add a log_on_as: key to the desired-state spec and compare against svc.StartName, using Change() (the general-purpose WMI method) instead of the narrower ChangeStartMode when the account itself needs to change.
  • Fleet-wide execution. Wrap WinServiceManager.connect(host: h) in a loop (or borrow the bounded thread pool from ssh_fleet_runner.rb) to reconcile the same services.yml across every host in an inventory file, aggregating results into one report.
  • Wire it into alert_notifier.rb. Since the CLI exits 1 on 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.