Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

The Problem

Windows exposes an enormous amount of live system information — OS build, memory pressure, disk capacity, running processes — through WMI (Windows Management Instrumentation), the same interface PowerShell’s Get-CimInstance and countless enterprise monitoring agents use under the hood. But when you already have Ruby in your toolchain (a cross-platform deploy tool, a Rake-based ops script, a Sinatra health-check endpoint) it’s awkward to shell out to PowerShell just to answer “how much disk space is left on C:” without leaving Ruby. win32ole, part of Ruby’s standard library on Windows builds, lets you query WMI directly from Ruby using WQL (WMI Query Language) — SQL-like syntax over system objects — with no additional gems.

wmi_inventory.rb wraps that access into a small inventory and health-reporting tool: OS version and memory pressure, per-drive disk usage with a low-space warning, and the top memory-consuming processes, all normalized into plain Ruby Hashes so the reporting logic is exactly as easy to test and extend as any other Ruby object — instead of juggling raw COM objects throughout your codebase.

Diagram of wmi_inventory.rb workflow: WIN32OLE connects to the local WMI namespace, WmiClient runs WQL queries against Win32_OperatingSystem, Win32_LogicalDisk, and Win32_Process, and InventoryReport normalizes the results into a human summary and JSON export

Prerequisites

  • Windows 10/11 or Windows Server, with Ruby installed via RubyInstaller for Windows (the standard way to run MRI Ruby on Windows). win32ole ships with these builds — there is nothing extra to install.
  • Ruby 2.7+ for the rest of the standard library used here (optparse, json).
  • This script will not run on Linux or macOSwin32ole is a Windows-only bridge to the COM subsystem. The script detects this via RbConfig::CONFIG['host_os'] and automatically falls back to a demo mode with simulated data so you can still read through the logic and test the reporting code on any platform.
  • Querying Win32_Process for other users’ processes may require running from an elevated (Administrator) prompt, depending on local security policy.

The Complete Script

Save this as wmi_inventory.rb:

#!/usr/bin/env ruby
# wmi_inventory.rb
#
# Windows system inventory & health report using WMI via win32ole.
# Must run on Windows with the win32ole (stdlib) and win32-service-friendly
# WMI provider available -- i.e. under standard MRI Ruby for Windows
# (RubyInstaller2 build) or JRuby on Windows.
#
# Usage (from an elevated or standard PowerShell / cmd prompt):
#   ruby wmi_inventory.rb                # full report to stdout
#   ruby wmi_inventory.rb --json out.json
#   ruby wmi_inventory.rb --top-cpu 10
#
require 'optparse'
require 'json'

WINDOWS = (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/)

if WINDOWS
  require 'win32ole'
else
  warn "[warning] win32ole is only available on Windows Ruby builds." \
       " Running in --demo mode with simulated WMI data for illustration."
end

# ---------------------------------------------------------------------------
# WmiClient: thin wrapper that connects to the local WMI namespace and runs
# WQL queries, returning arrays of plain Hashes (never raw OLE objects) so
# the rest of the program stays testable and OS-independent.
# ---------------------------------------------------------------------------
class WmiClient
  def initialize(namespace: "winmgmts://./root/cimv2")
    @connection = WIN32OLE.connect(namespace) if defined?(WIN32OLE)
  end

  # Runs a WQL query and returns an Array of Hashes with the requested props.
  def query(wql, properties)
    results = []
    @connection.ExecQuery(wql).each do |obj|
      row = {}
      properties.each { |p| row[p] = obj.Invoke(p) }
      results << row
    end
    results
  end
end

# ---------------------------------------------------------------------------
# Demo data used when not running on Windows, so the report logic below can
# be exercised and unit-tested anywhere.
# ---------------------------------------------------------------------------
module DemoData
  OS = [{ "Caption" => "Microsoft Windows Server 2022 Standard", "Version" => "10.0.20348",
          "OSArchitecture" => "64-bit", "LastBootUpTime" => "20260701120000.000000+000",
          "FreePhysicalMemory" => "8388608", "TotalVisibleMemorySize" => "16777216" }]

  DISKS = [
    { "DeviceID" => "C:", "Size" => "268435456000", "FreeSpace" => "40265318400", "FileSystem" => "NTFS" },
    { "DeviceID" => "D:", "Size" => "536870912000", "FreeSpace" => "429496729600", "FileSystem" => "NTFS" }
  ]

  PROCESSES = [
    { "Name" => "w3wp.exe", "ProcessId" => "4021", "WorkingSetSize" => "512000000", "PercentProcessorTime" => "23" },
    { "Name" => "sqlservr.exe", "ProcessId" => "5100", "WorkingSetSize" => "2048000000", "PercentProcessorTime" => "41" },
    { "Name" => "svchost.exe", "ProcessId" => "988", "WorkingSetSize" => "64000000", "PercentProcessorTime" => "2" }
  ]
end

# ---------------------------------------------------------------------------
# InventoryReport: pulls OS, disk, and process data and renders it as a
# human-readable summary plus a JSON-serializable Hash.
# ---------------------------------------------------------------------------
class InventoryReport
  def initialize(client: nil, demo: !WINDOWS)
    @client = client
    @demo = demo
  end

  def os_info
    rows = @demo ? DemoData::OS : @client.query(
      "SELECT Caption, Version, OSArchitecture, LastBootUpTime, FreePhysicalMemory, TotalVisibleMemorySize FROM Win32_OperatingSystem",
      %w[Caption Version OSArchitecture LastBootUpTime FreePhysicalMemory TotalVisibleMemorySize]
    )
    row = rows.first
    total_mb = row["TotalVisibleMemorySize"].to_i / 1024
    free_mb  = row["FreePhysicalMemory"].to_i / 1024
    {
      caption: row["Caption"],
      version: row["Version"],
      arch: row["OSArchitecture"],
      total_memory_mb: total_mb,
      free_memory_mb: free_mb,
      memory_used_pct: ((total_mb - free_mb) / total_mb.to_f * 100).round(1)
    }
  end

  def disk_info
    rows = @demo ? DemoData::DISKS : @client.query(
      "SELECT DeviceID, Size, FreeSpace, FileSystem FROM Win32_LogicalDisk WHERE DriveType=3",
      %w[DeviceID Size FreeSpace FileSystem]
    )
    rows.map do |r|
      size_gb = r["Size"].to_i / 1024.0**3
      free_gb = r["FreeSpace"].to_i / 1024.0**3
      {
        drive: r["DeviceID"],
        filesystem: r["FileSystem"],
        size_gb: size_gb.round(1),
        free_gb: free_gb.round(1),
        used_pct: size_gb.zero? ? 0 : (((size_gb - free_gb) / size_gb) * 100).round(1)
      }
    end
  end

  def top_processes(limit: 5)
    rows = @demo ? DemoData::PROCESSES : @client.query(
      "SELECT Name, ProcessId, WorkingSetSize FROM Win32_Process",
      %w[Name ProcessId WorkingSetSize]
    )
    rows.map { |r| { name: r["Name"], pid: r["ProcessId"].to_i, memory_mb: (r["WorkingSetSize"].to_i / 1024.0**2).round(1) } }
        .sort_by { |r| -r[:memory_mb] }
        .first(limit)
  end

  def to_h
    { os: os_info, disks: disk_info, top_processes: top_processes }
  end

  def print_summary
    o = os_info
    puts "=" * 60
    puts "SYSTEM INVENTORY REPORT#{@demo ? '  (DEMO DATA - not running on Windows)' : ''}"
    puts "=" * 60
    puts "OS: #{o[:caption]} (#{o[:arch]}), version #{o[:version]}"
    puts "Memory: #{o[:total_memory_mb]} MB total, #{o[:memory_used_pct]}% used"
    puts "-" * 60
    puts "Disks:"
    disk_info.each do |d|
      flag = d[:used_pct] >= 90 ? "  <-- WARNING: low free space" : ""
      puts "  #{d[:drive]} #{d[:filesystem]} #{d[:size_gb]} GB total, #{d[:used_pct]}% used#{flag}"
    end
    puts "-" * 60
    puts "Top memory-consuming processes:"
    top_processes.each { |p| puts "  #{p[:name].ljust(20)} pid=#{p[:pid].to_s.ljust(8)} #{p[:memory_mb]} MB" }
    puts "=" * 60
  end
end

if __FILE__ == $0
  options = { top_cpu: 5 }
  OptionParser.new do |o|
    o.on("--json PATH", "Write JSON report to PATH") { |v| options[:json] = v }
    o.on("--top-cpu N", Integer, "Number of top processes to show") { |v| options[:top_cpu] = v }
  end.parse!(ARGV)

  client = WINDOWS ? WmiClient.new : nil
  report = InventoryReport.new(client: client)
  report.print_summary

  if options[:json]
    File.write(options[:json], JSON.pretty_generate(report.to_h))
    puts "\nJSON report written to #{options[:json]}"
  end
end

How It Works, Step by Step

1. Connecting to the local WMI namespace

WmiClient#initialize calls WIN32OLE.connect("winmgmts://./root/cimv2") — the winmgmts: moniker is WMI’s own URL-like scheme, . means “the local machine” (a remote hostname works too, with the right permissions), and root/cimv2 is the namespace that holds the common system classes used here. Everything downstream is a method call on the COM object this returns.

2. WQL: SQL syntax over system state

WmiClient#query runs ExecQuery with a WQL string like "SELECT Caption, Version FROM Win32_OperatingSystem" — genuinely SQL-flavored syntax (SELECT/FROM/WHERE) over live system objects instead of database rows. The result is an enumerable COM collection; the script calls .Invoke(property) for each requested property name and builds a plain Ruby Hash per row, so nothing downstream ever touches a raw OLE object — that isolation is what keeps InventoryReport testable without a Windows machine at all.

3. A demo mode that isn’t an afterthought

Rather than only working on Windows, the script checks WINDOWS = (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/) once at load time and threads that decision through InventoryReport. Off Windows (or any time you pass no live connection), it swaps in DemoData — hand-written Hashes shaped exactly like real WMI query results. This is a deliberately simple form of dependency injection: the reporting and formatting logic in InventoryReport never knows or cares whether its input came from COM or from a constant, which is exactly why it was possible to fully exercise and verify this script’s logic before ever touching a Windows box.

4. Turning raw WMI fields into readable numbers

WMI reports memory in kilobytes and disk sizes in raw bytes as strings, not Ruby numbers — os_info and disk_info both do the unit conversion (dividing by 1024 or 1024**3) and rounding themselves, so callers get a Hash with sane _mb/_gb keys instead of having to remember WMI’s native units every time they read this code.

Tip: the low-disk-space warning threshold (90% used) is hardcoded in print_summary for readability here — in a real fleet-monitoring tool, pull it from an OptionParser flag or a config file the same way cert_watch.rb‘s warn/critical thresholds are configurable, so different drives or hosts can have different limits.

Example Run

Running on a Windows Server host (demo-mode output shown below is what you’d see running the same script on Linux/macOS for review — the formatting logic is identical either way, only the data source changes):

PS C:\scripts> ruby wmi_inventory.rb --json report.json --top-cpu 3
============================================================
SYSTEM INVENTORY REPORT
============================================================
OS: Microsoft Windows Server 2022 Standard (64-bit), version 10.0.20348
Memory: 16384 MB total, 50.0% used
------------------------------------------------------------
Disks:
  C: NTFS 250.0 GB total, 85.0% used  <-- WARNING: low free space
  D: NTFS 500.0 GB total, 20.0% used
------------------------------------------------------------
Top memory-consuming processes:
  sqlservr.exe         pid=5100     1953.1 MB
  w3wp.exe             pid=4021     488.3 MB
  svchost.exe          pid=988      61.0 MB
============================================================

JSON report written to report.json

Terminal output showing wmi_inventory.rb printing an OS, disk, and process summary, then writing a JSON report

Scheduling It

Wire this into Windows Task Scheduler for a recurring inventory snapshot:

schtasks /create /tn "WMI Inventory Report" /tr "ruby C:\scripts\wmi_inventory.rb --json C:\reports\inventory.json" /sc daily /st 06:00 /ru SYSTEM

Or from PowerShell directly, if you’d rather trigger it from an existing automation script: Start-Process ruby -ArgumentList "wmi_inventory.rb --json C:\reports\inventory.json".

Troubleshooting

  • NoMethodError / win32ole fails to load — you’re running a non-Windows Ruby build, or a Windows Ruby build that wasn’t compiled with COM support (rare, but possible with some minimal builds). Use the standard RubyInstaller distribution.
  • WIN32OLERuntimeError: unknown property or method — a property name doesn’t exist on that WMI class in your Windows version, or was mistyped. Cross-check against Microsoft’s WMI reference for the specific class (e.g., Win32_LogicalDisk) to confirm the exact property spelling.
  • Access denied querying Win32_Process — re-run from an elevated (Administrator) prompt; process enumeration for other users’ sessions is restricted by default.
  • Script hangs on a remote host — if you change the namespace to point at a remote machine (winmgmts://HOSTNAME/root/cimv2), firewall rules for DCOM/WMI (TCP 135 plus a dynamic RPC port range) must be open between the two machines — a very common blocker in locked-down networks.
  • Numbers look wrong — double check the unit conversion; a handful of WMI properties (like Win32_PhysicalMemory.Capacity, not used here) report in bytes while others (Win32_OperatingSystem.TotalVisibleMemorySize, used here) report in kilobytes. Always check the property’s documented unit before assuming.

Extending the Script

  • Service inventory: add a Win32_Service query to list stopped services configured to auto-start — a common source of “why didn’t this come back up after the reboot” incidents.
  • Event Log summary: pair this with a query against Win32_NTLogEvent (or the newer Get-WinEvent-style approach) to fold recent error/warning counts into the same report.
  • Remote fleet scans: parameterize the namespace with a hostname and loop over a list of servers, aggregating results into one fleet-wide inventory JSON file.
  • Prometheus textfile export: instead of (or alongside) the JSON report, emit metrics in Prometheus’s textfile-collector format so this becomes a first-class input to existing Grafana dashboards.

Leave a Reply

Your email address will not be published. Required fields are marked *

Click to access the login or register cheese