feat: Collect Windows services as workloads
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
const servicesTimeout = 60 * time.Second
|
||||
|
||||
const servicesScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$svcs = Get-CimInstance Win32_Service |
|
||||
Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode
|
||||
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
// collectUnits enumerates Windows services. The bool and string it returns are
|
||||
// the same SystemdOK / SystemdError pair the Linux collector fills: the wire
|
||||
// shape is shared, and the UI words it per platform.
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, servicesTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, servicesScript)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service query failed: " + err.Error()
|
||||
}
|
||||
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
|
||||
wls, err := parseServices(out, systemRoot)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service output could not be read: " + err.Error()
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// winService is one row of Get-CimInstance Win32_Service.
|
||||
//
|
||||
// Win32_Service rather than Get-Service: Get-Service exposes neither PathName
|
||||
// nor StartMode, and the filter below needs both.
|
||||
type winService struct {
|
||||
Name string `json:"Name"`
|
||||
DisplayName string `json:"DisplayName"`
|
||||
State string `json:"State"`
|
||||
StartMode string `json:"StartMode"`
|
||||
PathName string `json:"PathName"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
}
|
||||
|
||||
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
|
||||
// carrying it has not failed — it has not run since boot — and painting that
|
||||
// red would cry wolf on every host.
|
||||
const exitCodeNeverStarted = 1077
|
||||
|
||||
// servicePath extracts the executable from a Win32_Service PathName.
|
||||
//
|
||||
// A naive split on whitespace misfiles a substantial share of a real fleet:
|
||||
// `"C:\Program Files\X\x.exe" -service` is one path and one argument.
|
||||
func servicePath(pathName string) string {
|
||||
s := strings.TrimSpace(pathName)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' {
|
||||
if end := strings.IndexByte(s[1:], '"'); end >= 0 {
|
||||
return s[1 : 1+end]
|
||||
}
|
||||
return strings.TrimPrefix(s, `"`)
|
||||
}
|
||||
if i := strings.Index(strings.ToLower(s), ".exe"); i >= 0 {
|
||||
return s[:i+len(".exe")]
|
||||
}
|
||||
if i := strings.IndexAny(s, " \t"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseServices turns the collector's JSON into workloads.
|
||||
//
|
||||
// systemRoot is a parameter rather than an environment read so this is testable
|
||||
// off Windows. The caller passes %SystemRoot%.
|
||||
//
|
||||
// The filter mirrors the systemd collector's intent: show what an operator
|
||||
// installed, and show what is meant to be up but is not. Services under
|
||||
// %SystemRoot%\System32 are the platform's own, and a typical host has well
|
||||
// over a hundred of them.
|
||||
func parseServices(jsonText, systemRoot string) ([]Workload, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []winService
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winService
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = []winService{one}
|
||||
}
|
||||
|
||||
sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`)
|
||||
|
||||
var wls []Workload
|
||||
for _, r := range rows {
|
||||
if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) {
|
||||
continue
|
||||
}
|
||||
|
||||
running := strings.EqualFold(r.State, "Running")
|
||||
failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted
|
||||
auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto")
|
||||
if !running && !failed && !auto {
|
||||
continue
|
||||
}
|
||||
|
||||
state := "stopped"
|
||||
switch {
|
||||
case running:
|
||||
state = "running"
|
||||
case failed:
|
||||
state = "failed"
|
||||
}
|
||||
|
||||
name := r.DisplayName
|
||||
if name == "" {
|
||||
name = r.Name
|
||||
}
|
||||
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: r.Name,
|
||||
Name: name,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
return wls, nil
|
||||
}
|
||||
|
||||
// psQuote renders a Go string as a PowerShell single-quoted literal. Single
|
||||
// quotes suppress every form of expansion, so the only character needing an
|
||||
// escape is the quote itself, which is doubled.
|
||||
func psQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package workloads
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestServicePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
{`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`},
|
||||
{`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`},
|
||||
{`"C:\no\args.exe"`, `C:\no\args.exe`},
|
||||
{``, ``},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := servicePath(c.in); got != c.want {
|
||||
t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesFilters(t *testing.T) {
|
||||
in := `[
|
||||
{"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0},
|
||||
{"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0},
|
||||
{"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0},
|
||||
{"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0},
|
||||
{"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067}
|
||||
]`
|
||||
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
|
||||
byID := map[string]Workload{}
|
||||
for _, w := range got {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// The OS's own svchost service is dropped; a manual, stopped, never-failed
|
||||
// service is nobody's business either.
|
||||
if _, ok := byID["Themes"]; ok {
|
||||
t.Error("Themes (under %SystemRoot%) should be filtered out")
|
||||
}
|
||||
if _, ok := byID["Northwind"]; ok {
|
||||
t.Error("stopped Manual service should be filtered out")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" {
|
||||
t.Errorf("Contoso = %+v", w)
|
||||
}
|
||||
// Enabled but not running is exactly the row worth seeing.
|
||||
if byID["Fabrikam"].State != "stopped" {
|
||||
t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State)
|
||||
}
|
||||
// A non-zero exit code on a stopped service is a crash, not a clean stop.
|
||||
if byID["Crashed"].State != "failed" {
|
||||
t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1077 means "no attempt to start since boot" — a clean stopped service, not a
|
||||
// failure, and reporting it red would cry wolf on every host.
|
||||
func TestParseServicesExitCode1077(t *testing.T) {
|
||||
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].State != "stopped" {
|
||||
t.Fatalf("got %+v, want one stopped workload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
|
||||
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
|
||||
got, err := parseServices(one, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("single object: got %+v, err %v", got, err)
|
||||
}
|
||||
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPSQuote(t *testing.T) {
|
||||
if got := psQuote(`it's`); got != `'it''s'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
if got := psQuote(`plain`); got != `'plain'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user