diff --git a/command/agent/command.go b/command/agent/command.go index 10a5601ee..f466751ee 100644 --- a/command/agent/command.go +++ b/command/agent/command.go @@ -75,6 +75,7 @@ func (c *Command) readConfig() *Config { ACL: &ACLConfig{}, Audit: &config.AuditConfig{}, Reporting: &config.ReportingConfig{}, + Eventlog: &Eventlog{}, } flags := flag.NewFlagSet("agent", flag.ContinueOnError) @@ -132,6 +133,10 @@ func (c *Command) readConfig() *Config { flags.BoolVar(&cmdConfig.LogIncludeLocation, "log-include-location", false, "") flags.StringVar(&cmdConfig.NodeName, "node", "", "") + // Eventlog options + flags.BoolVar(&cmdConfig.Eventlog.Enabled, "eventlog", false, "") + flags.StringVar(&cmdConfig.Eventlog.Level, "eventlog-level", "", "") + // Consul options defaultConsul := cmdConfig.defaultConsul() flags.StringVar(&defaultConsul.Auth, "consul-auth", "", "") @@ -489,7 +494,12 @@ func (c *Command) IsValidConfig(config, cmdConfig *Config) bool { return false } if err := config.RPC.Validate(); err != nil { - c.Ui.Error(fmt.Sprintf("rpc block invalid: %v)", err)) + c.Ui.Error(fmt.Sprintf("rpc block invalid: %v", err)) + return false + } + + if err := config.Eventlog.Validate(); err != nil { + c.Ui.Error(fmt.Sprintf("eventlog block invalid: %v", err)) return false } @@ -587,6 +597,7 @@ func SetupLoggers(ui cli.Ui, config *Config) (*gatedwriter.Writer, io.Writer) { if logLevel == "OFF" { config.EnableSyslog = false } + // Check if syslog is enabled if config.EnableSyslog { ui.Output(fmt.Sprintf("Config enable_syslog is `true` with log_level=%v", config.LogLevel)) @@ -598,6 +609,17 @@ func SetupLoggers(ui cli.Ui, config *Config) (*gatedwriter.Writer, io.Writer) { writers = append(writers, newSyslogWriter(l, config.LogJson)) } + // Check if eventlog is enabled + if config.Eventlog != nil && config.Eventlog.Enabled { + l, err := winsvc.NewEventLogger(config.Eventlog.Level) + if err != nil { + ui.Error(fmt.Sprintf("Windows event logger setup failed: %s", err)) + return nil, nil + } + + writers = append(writers, l) + } + // Check if file logging is enabled if config.LogFile != "" { dir, fileName := filepath.Split(config.LogFile) @@ -773,6 +795,8 @@ func (c *Command) AutocompleteFlags() complete.Flags { "-vault-tls-server-name": complete.PredictAnything, "-acl-enabled": complete.PredictNothing, "-acl-replication-token": complete.PredictAnything, + "-eventlog": complete.PredictNothing, + "-eventlog-level": complete.PredictSet("INFO", "WARN", "ERROR"), } } @@ -920,6 +944,10 @@ func (c *Command) Run(args []string) int { return 1 } + // Add events for the eventlog + winsvc.SendEvent(winsvc.NewEvent(winsvc.EventServiceReady)) + defer func() { winsvc.SendEvent(winsvc.NewEvent(winsvc.EventServiceStopped)) }() + // Wait for exit return c.handleSignals() } @@ -1481,6 +1509,14 @@ General Options (clients and servers): -log-include-location Include file and line information in each log line. The default is false. + -eventlog + Enable sending Nomad agent logs to the Windows Event Log. + + -eventlog-level + Specifies the verbosity of logs the Nomad agent outputs. Valid log levels + include ERROR, WARN, or INFO in order of verbosity. Level must be + of equal or less verbosity as defined for the -log-level parameter. + -node= The name of the local agent. This name is used to identify the node in the cluster. The name must be unique per region. The default is diff --git a/command/agent/command_test.go b/command/agent/command_test.go index 5cac87f7f..4f713bc24 100644 --- a/command/agent/command_test.go +++ b/command/agent/command_test.go @@ -77,6 +77,10 @@ func TestCommand_Args(t *testing.T) { []string{"-client", "-node-pool=not@valid"}, "Invalid node pool", }, + { + []string{"-client", "-eventlog-level", "DEBUG"}, + "eventlog.level must be one of INFO, WARN, or ERROR", + }, } for _, tc := range tcases { // Make a new command. We preemptively close the shutdownCh @@ -502,6 +506,33 @@ func TestIsValidConfig(t *testing.T) { }, err: "unknown keyring provider", }, + { + name: "ValidEventlog", + conf: Config{ + DataDir: "/tmp", + Client: &ClientConfig{ + Enabled: true, + }, + Eventlog: &Eventlog{ + Enabled: true, + Level: "INFO", + }, + }, + }, + { + name: "InvalidEventlog", + conf: Config{ + DataDir: "/tmp", + Client: &ClientConfig{ + Enabled: true, + }, + Eventlog: &Eventlog{ + Enabled: true, + Level: "DEBUG", + }, + }, + err: "eventlog.level must be one of INFO, WARN, or ERROR", + }, } for _, tc := range cases { diff --git a/command/agent/config.go b/command/agent/config.go index 2ebc9eb91..263d79bff 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -29,6 +29,7 @@ import ( "github.com/hashicorp/nomad/helper/ipaddr" "github.com/hashicorp/nomad/helper/pointer" "github.com/hashicorp/nomad/helper/users" + "github.com/hashicorp/nomad/helper/winsvc" "github.com/hashicorp/nomad/nomad" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs/config" @@ -199,6 +200,9 @@ type Config struct { // ExtraKeysHCL is used by hcl to surface unexpected keys ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` + + // Configure logging to Windows eventlog + Eventlog *Eventlog `hcl:"eventlog"` } func (c *Config) defaultConsul() *config.ConsulConfig { @@ -1399,6 +1403,60 @@ func (t *Telemetry) Validate() error { return nil } +// Eventlog is the configuration for the Windows Eventlog +type Eventlog struct { + // Enabled controls if Nomad agent logs are sent to the + // Windows eventlog. + Enabled bool `hcl:"enabled"` + // Level of logs to send to eventlog. May be set to higher + // severity than LogLevel but lower level will be ignored. + Level string `hcl:"level"` +} + +// Copy is used to copy the Eventlog configuration +func (e *Eventlog) Copy() *Eventlog { + return &Eventlog{ + Enabled: e.Enabled, + Level: e.Level, + } +} + +// Merge is used to merge Eventlog configurations +func (e *Eventlog) Merge(b *Eventlog) *Eventlog { + if e == nil { + return b + } + + result := *e + + if b == nil { + return &result + } + + if b.Enabled { + result.Enabled = b.Enabled + } + + if b.Level != "" { + result.Level = b.Level + } + + return &result +} + +// Validate validates the eventlog configuration +func (e *Eventlog) Validate() error { + if e == nil { + return nil + } + + if winsvc.EventlogLevelFromString(e.Level) == winsvc.EVENTLOG_LEVEL_UNKNOWN { + return errors.New("eventlog.level must be one of INFO, WARN, or ERROR") + } + + return nil +} + // Ports encapsulates the various ports we bind to for network services. If any // are not specified then the defaults are used instead. type Ports struct { @@ -1732,6 +1790,10 @@ func DefaultConfig() *Config { collectionInterval: 1 * time.Second, DisableAllocationHookMetrics: pointer.Of(false), }, + Eventlog: &Eventlog{ + Enabled: false, + Level: "error", + }, TLSConfig: &config.TLSConfig{}, Sentinel: &config.SentinelConfig{}, Version: version.GetVersion(), @@ -1844,6 +1906,13 @@ func (c *Config) Merge(b *Config) *Config { result.Telemetry = result.Telemetry.Merge(b.Telemetry) } + // Apply the eventlog config + if result.Eventlog == nil && b.Eventlog != nil { + result.Eventlog = b.Eventlog.Copy() + } else if b.Eventlog != nil { + result.Eventlog = result.Eventlog.Merge(b.Eventlog) + } + // Apply the Reporting Config if result.Reporting == nil && b.Reporting != nil { result.Reporting = b.Reporting.Copy() diff --git a/command/agent/config_test.go b/command/agent/config_test.go index 47cddaaa4..6e1b38767 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -48,6 +48,7 @@ func TestConfig_Merge(t *testing.T) { AdvertiseAddrs: &AdvertiseAddrs{}, Sentinel: &config.SentinelConfig{}, Autopilot: &config.AutopilotConfig{}, + Eventlog: &Eventlog{}, } c2 := &Config{ @@ -236,6 +237,10 @@ func TestConfig_Merge(t *testing.T) { }, }, }, + Eventlog: &Eventlog{ + Enabled: true, + Level: "INFO", + }, } c3 := &Config{ @@ -489,6 +494,10 @@ func TestConfig_Merge(t *testing.T) { Enabled: pointer.Of(true), }, }, + Eventlog: &Eventlog{ + Enabled: true, + Level: "ERROR", + }, } result := c0.Merge(c1) @@ -2013,3 +2022,86 @@ func TestConfig_LoadClientNodeMaxAllocs(t *testing.T) { } } + +func TestEventlog_Merge(t *testing.T) { + t.Run("nil rhs merge", func(t *testing.T) { + var c1, c2 *Eventlog + c1 = &Eventlog{ + Enabled: true, + Level: "info", + } + result := c1.Merge(c2) + must.Eq(t, result, c1) + }) + + t.Run("nil lhs merge", func(t *testing.T) { + var c1, c2 *Eventlog + c2 = &Eventlog{ + Enabled: true, + Level: "info", + } + result := c1.Merge(c2) + must.Eq(t, result, c2) + }) + + t.Run("full merge", func(t *testing.T) { + c1 := &Eventlog{ + Enabled: false, + Level: "info", + } + c2 := &Eventlog{ + Enabled: true, + Level: "error", + } + result := c1.Merge(c2) + must.True(t, result.Enabled) + must.Eq(t, result.Level, "error") + }) + + t.Run("enabled merge", func(t *testing.T) { + // NOTE: Can only be enabled, not disabled + c1 := &Eventlog{ + Enabled: true, + } + c2 := &Eventlog{ + Enabled: false, + } + result := c1.Merge(c2) + must.True(t, result.Enabled) + + }) +} + +func TestEventlog_Validate(t *testing.T) { + ci.Parallel(t) + testCases := []struct { + desc string + eventlog *Eventlog + shouldErr bool + }{ + { + desc: "valid level", + eventlog: &Eventlog{Level: "info"}, + }, + { + desc: "invalid level", + eventlog: &Eventlog{Level: "debug"}, + shouldErr: true, + }, + { + desc: "nil eventlog", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ci.Parallel(t) + + if tc.shouldErr { + must.Error(t, tc.eventlog.Validate()) + } else { + must.NoError(t, tc.eventlog.Validate()) + } + }) + } +} diff --git a/helper/winsvc/event.go b/helper/winsvc/event.go new file mode 100644 index 000000000..c5e3967c0 --- /dev/null +++ b/helper/winsvc/event.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +type WindowsEventId uint32 + +//go:generate stringer -trimprefix=Event -output strings_eventid.go -linecomment -type=WindowsEventId +const ( + EventUnknown WindowsEventId = iota // unknown event + EventServiceStarting // service starting + EventServiceReady // service ready + EventServiceStopped // service stopped + EventLogMessage // log message +) + +// NewEvent creates a new Event for the Windows Eventlog +func NewEvent(kind WindowsEventId, opts ...EventOption) Event { + evt := &event{ + kind: kind, + level: EVENTLOG_LEVEL_INFO, + } + + for _, fn := range opts { + fn(evt) + } + + return evt +} + +type Event interface { + Kind() WindowsEventId + Message() string + Level() EventlogLevel +} + +type EventOption func(*event) + +// WithEventMessage sets a custom message for the event +func WithEventMessage(msg string) EventOption { + return func(e *event) { + e.message = msg + } +} + +// WithEventLevel specifies the level used for the event +func WithEventLevel(level EventlogLevel) EventOption { + return func(e *event) { + e.level = level + } +} + +type event struct { + kind WindowsEventId + message string + level EventlogLevel +} + +func (e *event) Kind() WindowsEventId { + return e.kind +} + +func (e *event) Message() string { + if e.message != "" { + return e.message + } + + return e.kind.String() +} + +func (e *event) Level() EventlogLevel { + return e.level +} diff --git a/helper/winsvc/event_logger.go b/helper/winsvc/event_logger.go new file mode 100644 index 000000000..9f2a22482 --- /dev/null +++ b/helper/winsvc/event_logger.go @@ -0,0 +1,106 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "regexp" + "strings" +) + +type EventlogLevel uint8 + +//go:generate stringer -trimprefix=EVENTLOG_LEVEL_ -output strings_event_logger.go -linecomment -type=EventlogLevel +const ( + EVENTLOG_LEVEL_UNKNOWN EventlogLevel = iota + EVENTLOG_LEVEL_INFO + EVENTLOG_LEVEL_WARN + EVENTLOG_LEVEL_ERROR +) + +// EventlogLevelFromString converts a log level string to the correct constant +func EventlogLevelFromString(level string) EventlogLevel { + switch strings.ToUpper(level) { + case EVENTLOG_LEVEL_INFO.String(): + return EVENTLOG_LEVEL_INFO + case EVENTLOG_LEVEL_WARN.String(): + return EVENTLOG_LEVEL_WARN + case EVENTLOG_LEVEL_ERROR.String(): + return EVENTLOG_LEVEL_ERROR + } + + return EVENTLOG_LEVEL_UNKNOWN +} + +var logPattern = regexp.MustCompile(`(?s)\[(ERROR|WARN|INFO)\] (.+)`) + +type Eventlog interface { + Info(uint32, string) error + Warning(uint32, string) error + Error(uint32, string) error + Close() error +} + +type eventLogger struct { + evtLog Eventlog + level EventlogLevel +} + +// Close closes the eventlog +func (e *eventLogger) Close() error { + return e.evtLog.Close() +} + +// Write writes logging message to the eventlog +func (e *eventLogger) Write(p []byte) (int, error) { + matches := logPattern.FindStringSubmatch(string(p)) + + // If no match was found, or the incorrect number of + // elements detected then ignore + if matches == nil || len(matches) != 3 { + return len(p), nil + } + + level := EventlogLevelFromString(matches[1]) + + // If the detected level of the message isn't currently + // allowed then ignore + if !e.allowed(level) { + return len(p), nil + } + + // Still here so send the message to the eventlog + switch level { + case EVENTLOG_LEVEL_INFO: + e.evtLog.Info(uint32(EventLogMessage), matches[2]) + case EVENTLOG_LEVEL_WARN: + e.evtLog.Warning(uint32(EventLogMessage), matches[2]) + case EVENTLOG_LEVEL_ERROR: + e.evtLog.Error(uint32(EventLogMessage), matches[2]) + } + + return len(p), nil +} + +// Check if level is allowed +func (e *eventLogger) allowed(level EventlogLevel) bool { + return level >= e.level +} + +type nullEventlog struct{} + +func (n *nullEventlog) Info(uint32, string) error { + return nil +} + +func (n *nullEventlog) Warning(uint32, string) error { + return nil +} + +func (n *nullEventlog) Error(uint32, string) error { + return nil +} + +func (n *nullEventlog) Close() error { + return nil +} diff --git a/helper/winsvc/event_logger_nonwindows.go b/helper/winsvc/event_logger_nonwindows.go new file mode 100644 index 000000000..1df135bac --- /dev/null +++ b/helper/winsvc/event_logger_nonwindows.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows + +package winsvc + +import ( + "errors" + "io" +) + +// NewEventLogger is a stub for non-Windows platforms to generate +// and error when used. +func NewEventLogger(_ string) (io.WriteCloser, error) { + return nil, errors.New("EventLogger is not supported on this platform") +} diff --git a/helper/winsvc/event_logger_test.go b/helper/winsvc/event_logger_test.go new file mode 100644 index 000000000..247c5b919 --- /dev/null +++ b/helper/winsvc/event_logger_test.go @@ -0,0 +1,114 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "io" + "testing" + + "github.com/shoenig/test/must" +) + +func testEventLogger(e Eventlog, l EventlogLevel) io.WriteCloser { + return &eventLogger{ + level: l, + evtLog: e, + } +} + +func TestEventlogLevelFromString(t *testing.T) { + t.Run("INFO", func(t *testing.T) { + for _, val := range []string{"INFO", "info"} { + l := EventlogLevelFromString(val) + must.Eq(t, EVENTLOG_LEVEL_INFO, l) + } + }) + t.Run("WARN", func(t *testing.T) { + for _, val := range []string{"WARN", "warn"} { + l := EventlogLevelFromString(val) + must.Eq(t, EVENTLOG_LEVEL_WARN, l) + } + }) + t.Run("ERROR", func(t *testing.T) { + for _, val := range []string{"ERROR", "error"} { + l := EventlogLevelFromString(val) + must.Eq(t, EVENTLOG_LEVEL_ERROR, l) + } + }) +} + +func TestEventLogger(t *testing.T) { + defaultmsgs := []string{ + "1970-01-01T16:27:16.116Z [INFO] Information line", + "1970-01-01T16:27:16.116Z [WARN] Warning line", + "1970-01-01T16:27:16.116Z [ERROR] Error line", + } + + testCases := []struct { + desc string + msgs []string + level EventlogLevel + setup func(*MockEventlog) + }{ + { + desc: "basic usage", + level: EVENTLOG_LEVEL_INFO, + setup: func(m *MockEventlog) { + m.ExpectInfo(EventLogMessage, "Information line") + m.ExpectWarning(EventLogMessage, "Warning line") + m.ExpectError(EventLogMessage, "Error line") + }, + }, + { + desc: "higher level", + level: EVENTLOG_LEVEL_ERROR, + setup: func(m *MockEventlog) { + m.ExpectError(EventLogMessage, "Error line") + }, + }, + { + desc: "debug and trace logs", + level: EVENTLOG_LEVEL_INFO, + setup: func(m *MockEventlog) { + m.ExpectInfo(EventLogMessage, "Information line") + m.ExpectWarning(EventLogMessage, "Warning line") + m.ExpectError(EventLogMessage, "Error line") + }, + msgs: append(defaultmsgs, []string{ + "[DEBUG] Debug line", + "[TRACE] Trace line", + }...), + }, + { + desc: "with multi-line logs", + level: EVENTLOG_LEVEL_INFO, + setup: func(m *MockEventlog) { + m.ExpectInfo(EventLogMessage, "Information line") + m.ExpectWarning(EventLogMessage, "Warning line") + m.ExpectError(EventLogMessage, "Error line") + m.ExpectInfo(EventLogMessage, "Information log\nthat includes\nmultiple lines") + m.ExpectWarning(EventLogMessage, "Warning log\nthat includes second line") + }, + msgs: append(defaultmsgs, []string{ + "[INFO] Information log\nthat includes\nmultiple lines", + "[WARN] Warning log\nthat includes second line", + }...), + }, + } + + for _, tc := range testCases { + if len(tc.msgs) < 1 { + tc.msgs = defaultmsgs + } + + el := NewMockEventlog(t) + tc.setup(el) + eventLogger := testEventLogger(el, tc.level) + + for _, msg := range tc.msgs { + eventLogger.Write([]byte(msg)) + } + el.AssertExpectations() + } +} diff --git a/helper/winsvc/event_logger_windows.go b/helper/winsvc/event_logger_windows.go new file mode 100644 index 000000000..2e3d63ddd --- /dev/null +++ b/helper/winsvc/event_logger_windows.go @@ -0,0 +1,24 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "fmt" + "io" + + "golang.org/x/sys/windows/svc/eventlog" +) + +// NewEventLogger creates a new event logger instance +func NewEventLogger(level string) (io.WriteCloser, error) { + evtLog, err := eventlog.Open(WINDOWS_SERVICE_NAME) + if err != nil { + return nil, fmt.Errorf("Failed to open Windows eventlog: %w", err) + } + + return &eventLogger{ + evtLog: evtLog, + level: EventlogLevelFromString(level), + }, nil +} diff --git a/helper/winsvc/event_test.go b/helper/winsvc/event_test.go new file mode 100644 index 000000000..fecc16fb1 --- /dev/null +++ b/helper/winsvc/event_test.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "testing" + + "github.com/shoenig/test/must" +) + +func TestNewEvent(t *testing.T) { + t.Run("simple", func(t *testing.T) { + event := NewEvent(EventServiceReady) + must.Eq(t, EventServiceReady, event.Kind()) + must.Eq(t, EVENTLOG_LEVEL_INFO, event.Level()) + must.Eq(t, EventServiceReady.String(), event.Message()) + }) + + t.Run("WithEventMessage", func(t *testing.T) { + event := NewEvent(EventServiceReady, WithEventMessage("Custom service ready message")) + must.Eq(t, EventServiceReady, event.Kind()) + must.Eq(t, EVENTLOG_LEVEL_INFO, event.Level()) + must.Eq(t, "Custom service ready message", event.Message()) + }) + + t.Run("WithEventLevel", func(t *testing.T) { + event := NewEvent(EventServiceReady, WithEventLevel(EVENTLOG_LEVEL_ERROR)) + must.Eq(t, EventServiceReady, event.Kind()) + must.Eq(t, EVENTLOG_LEVEL_ERROR, event.Level()) + must.Eq(t, EventServiceReady.String(), event.Message()) + }) + + t.Run("multiple options", func(t *testing.T) { + event := NewEvent(EventServiceStopped, + WithEventMessage("Custom service stopped message"), + WithEventLevel(EVENTLOG_LEVEL_WARN), + ) + must.Eq(t, EventServiceStopped, event.Kind()) + must.Eq(t, EVENTLOG_LEVEL_WARN, event.Level()) + must.Eq(t, "Custom service stopped message", event.Message()) + }) +} diff --git a/helper/winsvc/events_nonwindows.go b/helper/winsvc/events_nonwindows.go new file mode 100644 index 000000000..5f2802d0e --- /dev/null +++ b/helper/winsvc/events_nonwindows.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows + +package winsvc + +// SendEvent sends an event to the Windows eventlog +func SendEvent(e Event) {} diff --git a/helper/winsvc/events_windows.go b/helper/winsvc/events_windows.go new file mode 100644 index 000000000..db9d7c287 --- /dev/null +++ b/helper/winsvc/events_windows.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/nomad/helper" +) + +var chanEvents = make(chan Event) + +// SendEvent sends an event to the Windows eventlog +func SendEvent(e Event) { + timer, stop := helper.NewSafeTimer(100 * time.Millisecond) + defer stop() + + select { + case chanEvents <- e: + case <-timer.C: + hclog.L().Error("failed to send event to windows eventlog, timed out", + "event", e) + } +} diff --git a/helper/winsvc/mock_eventlog.go b/helper/winsvc/mock_eventlog.go new file mode 100644 index 000000000..f5099a436 --- /dev/null +++ b/helper/winsvc/mock_eventlog.go @@ -0,0 +1,84 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "testing" + + "github.com/shoenig/test/must" +) + +func NewMockEventlog(t *testing.T) *MockEventlog { + return &MockEventlog{ + t: t, + } +} + +type MockEventlog struct { + infos []mockArgs + warnings []mockArgs + errors []mockArgs + t *testing.T +} + +type mockArgs struct { + winId uint32 + msg string +} + +func (m *MockEventlog) ExpectInfo(v1 WindowsEventId, v2 string) { + m.infos = append(m.infos, mockArgs{uint32(v1), v2}) +} + +func (m *MockEventlog) ExpectWarning(v1 WindowsEventId, v2 string) { + m.warnings = append(m.warnings, mockArgs{uint32(v1), v2}) +} + +func (m *MockEventlog) ExpectError(v1 WindowsEventId, v2 string) { + m.errors = append(m.errors, mockArgs{uint32(v1), v2}) +} + +func (m *MockEventlog) Info(v1 uint32, v2 string) error { + m.t.Helper() + + expectedArgs := m.infos[0] + m.infos = m.infos[1:] + + must.Eq(m.t, expectedArgs.winId, v1, must.Sprint("Incorrect WindowsEventId value")) + must.Eq(m.t, expectedArgs.msg, v2, must.Sprint("Incorrect message value")) + + return nil +} + +func (m *MockEventlog) Warning(v1 uint32, v2 string) error { + m.t.Helper() + + expectedArgs := m.warnings[0] + m.warnings = m.warnings[1:] + + must.Eq(m.t, expectedArgs.winId, v1, must.Sprint("Incorrect WindowsEventId value")) + must.Eq(m.t, expectedArgs.msg, v2, must.Sprint("Incorrect message value")) + + return nil +} + +func (m *MockEventlog) Error(v1 uint32, v2 string) error { + m.t.Helper() + + expectedArgs := m.errors[0] + m.errors = m.errors[1:] + + must.Eq(m.t, expectedArgs.winId, v1, must.Sprint("Incorrect WindowsEventId value")) + must.Eq(m.t, expectedArgs.msg, v2, must.Sprint("Incorrect message value")) + + return nil +} + +func (m *MockEventlog) Close() error { return nil } + +func (m *MockEventlog) AssertExpectations() { + must.SliceEmpty(m.t, m.infos, must.Sprintf("Info expecting %d more invocations", len(m.infos))) + must.SliceEmpty(m.t, m.warnings, must.Sprintf("Warning expecting %d more invocations", len(m.warnings))) + must.SliceEmpty(m.t, m.errors, must.Sprintf("Error expecting %d more invocations", len(m.errors))) +} diff --git a/helper/winsvc/service.go b/helper/winsvc/service.go index 8eb4b9ae9..90262dfd1 100644 --- a/helper/winsvc/service.go +++ b/helper/winsvc/service.go @@ -15,10 +15,10 @@ const ( WINDOWS_SERVICE_STATE_TIMEOUT = "1m" ) -var chanGraceExit = make(chan int) +var chanGraceExit = make(chan struct{}) // ShutdownChannel returns a channel that sends a message that a shutdown // signal has been received for the service. -func ShutdownChannel() <-chan int { +func ShutdownChannel() <-chan struct{} { return chanGraceExit } diff --git a/helper/winsvc/service_windows.go b/helper/winsvc/service_windows.go index c418f726c..630138b1c 100644 --- a/helper/winsvc/service_windows.go +++ b/helper/winsvc/service_windows.go @@ -1,46 +1,84 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -//go:build windows -// +build windows - package winsvc import ( - wsvc "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" ) -type serviceWindows struct{} +// Commands that are currently supported +const SERVICE_ACCEPTED_COMMANDS = svc.AcceptStop | svc.AcceptShutdown + +type serviceWindows struct { + evtLog Eventlog +} func init() { - interactive, err := wsvc.IsAnInteractiveSession() + isSvc, err := svc.IsWindowsService() if err != nil { panic(err) } - // Cannot run as a service when running interactively - if interactive { + // This should only run when running + // as a service + if !isSvc { return } - go wsvc.Run("", serviceWindows{}) + + go executeWindowsService() } // Execute implements the Windows service Handler type. It will be // called at the start of the service, and the service will exit // once Execute completes. -func (serviceWindows) Execute(args []string, r <-chan wsvc.ChangeRequest, s chan<- wsvc.Status) (svcSpecificEC bool, exitCode uint32) { - const accCommands = wsvc.AcceptStop | wsvc.AcceptShutdown - s <- wsvc.Status{State: wsvc.Running, Accepts: accCommands} +func (srv serviceWindows) Execute(args []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { + s <- svc.Status{State: svc.Running, Accepts: SERVICE_ACCEPTED_COMMANDS} + srv.evtLog.Info(uint32(EventServiceStarting), "service starting") +LOOP: for { - c := <-r - switch c.Cmd { - case wsvc.Interrogate: - s <- c.CurrentStatus - case wsvc.Stop, wsvc.Shutdown: - s <- wsvc.Status{State: wsvc.StopPending} - chanGraceExit <- 1 - return false, 0 + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + s <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + srv.evtLog.Info(uint32(EventLogMessage), "service stop requested") + s <- svc.Status{State: svc.StopPending} + close(chanGraceExit) + } + case e := <-chanEvents: + switch e.Level() { + case EVENTLOG_LEVEL_INFO: + srv.evtLog.Info(uint32(e.Kind()), e.Message()) + case EVENTLOG_LEVEL_WARN: + srv.evtLog.Warning(uint32(e.Kind()), e.Message()) + case EVENTLOG_LEVEL_ERROR: + srv.evtLog.Error(uint32(e.Kind()), e.Message()) + } + + if e.Kind() == EventServiceStopped { + break LOOP + } } } return false, 0 } + +func executeWindowsService() { + var evtLog Eventlog + evtLog, err := eventlog.Open(WINDOWS_SERVICE_NAME) + if err != nil { + // Eventlog will only be available if the + // service was properly registered. If the + // service was manually setup, it will likely + // not have been registered with the eventlog + // so it will not be available. In that case + // just stub out the eventlog. + evtLog = &nullEventlog{} + } + defer evtLog.Close() + + svc.Run(WINDOWS_SERVICE_NAME, serviceWindows{evtLog: evtLog}) +} diff --git a/helper/winsvc/strings_event_logger.go b/helper/winsvc/strings_event_logger.go new file mode 100644 index 000000000..b9fe83daa --- /dev/null +++ b/helper/winsvc/strings_event_logger.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -trimprefix=EVENTLOG_LEVEL_ -output strings_event_logger.go -linecomment -type=EventlogLevel"; DO NOT EDIT. + +package winsvc + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[EVENTLOG_LEVEL_UNKNOWN-0] + _ = x[EVENTLOG_LEVEL_INFO-1] + _ = x[EVENTLOG_LEVEL_WARN-2] + _ = x[EVENTLOG_LEVEL_ERROR-3] +} + +const _EventlogLevel_name = "UNKNOWNINFOWARNERROR" + +var _EventlogLevel_index = [...]uint8{0, 7, 11, 15, 20} + +func (i EventlogLevel) String() string { + if i >= EventlogLevel(len(_EventlogLevel_index)-1) { + return "EventlogLevel(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _EventlogLevel_name[_EventlogLevel_index[i]:_EventlogLevel_index[i+1]] +} diff --git a/helper/winsvc/strings_eventid.go b/helper/winsvc/strings_eventid.go new file mode 100644 index 000000000..e66863f18 --- /dev/null +++ b/helper/winsvc/strings_eventid.go @@ -0,0 +1,27 @@ +// Code generated by "stringer -trimprefix=Event -output strings_eventid.go -linecomment -type=WindowsEventId"; DO NOT EDIT. + +package winsvc + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[EventUnknown-0] + _ = x[EventServiceStarting-1] + _ = x[EventServiceReady-2] + _ = x[EventServiceStopped-3] + _ = x[EventLogMessage-4] +} + +const _WindowsEventId_name = "unknown eventservice startingservice readyservice stoppedlog message" + +var _WindowsEventId_index = [...]uint8{0, 13, 29, 42, 57, 68} + +func (i WindowsEventId) String() string { + if i >= WindowsEventId(len(_WindowsEventId_index)-1) { + return "WindowsEventId(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _WindowsEventId_name[_WindowsEventId_index[i]:_WindowsEventId_index[i+1]] +}