From 48d91dc1f973d61c7b6882d6b09009514e253f12 Mon Sep 17 00:00:00 2001 From: Chris Roberts Date: Tue, 5 Aug 2025 13:50:26 -0700 Subject: [PATCH 1/3] [winsvc] Add interfaces for Windows services and service manager Provides interfaces to the Windows service manager and Windows services. These interfaces support creating new Windows services, deleting Windows services, configuring Windows services, and registering/deregistering services with Windows Eventlog. A path helper is included to support expansion of paths using a subset of known folder IDs. A privileged helper is included to check that the process is currently being executed with elevated privileges, which are required for managing Windows services and modifying the registry. --- .copywrite.hcl | 1 + helper/winsvc/path_nonwindows.go | 22 + helper/winsvc/path_windows.go | 206 ++++++ helper/winsvc/path_windows_test.go | 203 ++++++ helper/winsvc/privileged_nonwindows.go | 11 + helper/winsvc/privileged_windows.go | 11 + helper/winsvc/service.go | 12 + helper/winsvc/windows_service.go | 75 +++ helper/winsvc/windows_service_nonwindows.go | 15 + helper/winsvc/windows_service_windows.go | 256 ++++++++ helper/winsvc/windows_service_windows_test.go | 598 ++++++++++++++++++ 11 files changed, 1410 insertions(+) create mode 100644 helper/winsvc/path_nonwindows.go create mode 100644 helper/winsvc/path_windows.go create mode 100644 helper/winsvc/path_windows_test.go create mode 100644 helper/winsvc/privileged_nonwindows.go create mode 100644 helper/winsvc/privileged_windows.go create mode 100644 helper/winsvc/windows_service.go create mode 100644 helper/winsvc/windows_service_nonwindows.go create mode 100644 helper/winsvc/windows_service_windows.go create mode 100644 helper/winsvc/windows_service_windows_test.go diff --git a/.copywrite.hcl b/.copywrite.hcl index 0dcd50545..a8d137a17 100644 --- a/.copywrite.hcl +++ b/.copywrite.hcl @@ -13,6 +13,7 @@ project { "ui/node_modules", "pnpm-workspace.yaml", "pnpm-lock.yaml", + "helper/winsvc/strings_*.go", // Enterprise files do not fall under the open source licensing. CE-ENT // merge conflicts might happen here, please be sure to put new CE diff --git a/helper/winsvc/path_nonwindows.go b/helper/winsvc/path_nonwindows.go new file mode 100644 index 000000000..4c2f9b772 --- /dev/null +++ b/helper/winsvc/path_nonwindows.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows + +package winsvc + +import "errors" + +func NewWindowsPaths() WindowsPaths { + return &windowsPaths{} +} + +type windowsPaths struct{} + +func (w *windowsPaths) Expand(string) (string, error) { + return "", errors.New("Windows path expansion not supported on this platform") +} + +func (w *windowsPaths) CreateDirectory(string, bool) error { + return errors.New("Windows directory creation not supported on this platform") +} diff --git a/helper/winsvc/path_windows.go b/helper/winsvc/path_windows.go new file mode 100644 index 000000000..101c3a2a5 --- /dev/null +++ b/helper/winsvc/path_windows.go @@ -0,0 +1,206 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + "sync" + "text/template" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +func NewWindowsPaths() WindowsPaths { + return &windowsPaths{} +} + +type windowsPaths struct { + SystemRoot string + SystemDrive string + ProgramData string + ProgramFiles string + loadErr error + o sync.Once +} + +func (w *windowsPaths) Expand(path string) (string, error) { + if err := w.load(); err != nil { + return "", err + } + + tmpl := template.New("expansion").Option("missingkey=error") + tmpl, err := tmpl.Parse(path) + if err != nil { + return "", err + } + result := new(bytes.Buffer) + if err := tmpl.Execute(result, w); err != nil { + return "", err + } + + return result.String(), nil +} + +func (w *windowsPaths) CreateDirectory(path string, restrict_on_create bool) error { + s, err := os.Stat(path) + + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + if err == nil { + // Directory exists so nothing to do + if s.IsDir() { + return nil + } + + return fmt.Errorf("path exists and is not directory - %s", path) + } + + // NOTE: mode ignored on Windows. If directory should + // be restricted, an ACL will be applied below. + if err := os.MkdirAll(path, 0o000); err != nil { + return err + } + + // Since the directory was just created, apply access + // restrictions if requested + if restrict_on_create { + if err := setDirectoryPermissions(path); err != nil { + return err + } + } + + return nil +} + +func getUserGroupSIDs() (usid *windows.SID, gsid *windows.SID, err error) { + // NOTE: this token is a pseudo-token and does not + // need to be closed + token := windows.GetCurrentProcessToken() + + userToken, err := token.GetTokenUser() + if err != nil { + return + } + usid = userToken.User.Sid + + userGroup, err := token.GetTokenPrimaryGroup() + if err != nil { + return + } + gsid = userGroup.PrimaryGroup + + return +} + +func setDirectoryPermissions(path string) error { + // Grab the user and group SID for who is running the process + userSid, groupSid, err := getUserGroupSIDs() + if err != nil { + return err + } + + // Generate a SID for the administators group + gsid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return err + } + + // Create an ACL with an ACE for user SID and an ACE for the + // administrators group SID, both of which are granted full + // access. No other ACEs are provided which restricts access + // from non-administrators + dacl, err := windows.ACLFromEntries( + []windows.EXPLICIT_ACCESS{ + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: windows.TRUSTEE{ + MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE, + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(userSid), + }, + }, + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: windows.TRUSTEE{ + MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE, + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(gsid), + }, + }, + }, nil, + ) + if err != nil { + return err + } + + // Apply the ACL to the directory + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION| + windows.GROUP_SECURITY_INFORMATION| + windows.DACL_SECURITY_INFORMATION| + windows.PROTECTED_DACL_SECURITY_INFORMATION, + userSid, groupSid, dacl, nil); err != nil { + return err + } + + return nil +} + +func (w *windowsPaths) load() error { + w.o.Do(func() { + w.SystemDrive = os.Getenv("SystemDrive") + if w.SystemDrive == "" { + w.loadErr = fmt.Errorf("cannot detect Windows SystemDrive path") + return + } + w.SystemRoot = strings.ReplaceAll(os.Getenv("SystemDrive"), "SystemDrive", w.SystemDrive) + + w.ProgramData = os.Getenv("ProgramData") + if w.ProgramData == "" { + pdKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.QUERY_VALUE) + if err == nil { + if pdVal, _, err := pdKey.GetStringValue("ProgramData"); err == nil { + w.ProgramData = pdVal + } + } + } + if w.ProgramData == "" { + w.loadErr = fmt.Errorf("cannot detect Windows ProgramData path") + return + } + w.ProgramData = strings.ReplaceAll(w.ProgramData, "SystemDrive", w.SystemDrive) + + w.ProgramFiles = os.Getenv("ProgramFiles") + if w.ProgramFiles == "" { + pdKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows\CurrentVersion`, registry.QUERY_VALUE) + if err == nil { + if pdVal, _, err := pdKey.GetStringValue("ProgramFilesDir"); err == nil { + w.ProgramFiles = pdVal + } + } + } + if w.ProgramFiles == "" { + w.loadErr = fmt.Errorf("cannot detect Windows ProgramFiles path") + return + } + w.ProgramFiles = strings.ReplaceAll(w.ProgramFiles, "SystemDrive", w.SystemDrive) + }) + + return w.loadErr +} diff --git a/helper/winsvc/path_windows_test.go b/helper/winsvc/path_windows_test.go new file mode 100644 index 000000000..1cea8546f --- /dev/null +++ b/helper/winsvc/path_windows_test.go @@ -0,0 +1,203 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" + "golang.org/x/sys/windows" +) + +func TestCreateDirectory(t *testing.T) { + ci.Parallel(t) + testDir := t.TempDir() + + t.Run("create", func(t *testing.T) { + // NOTE: parallel is not set here to force parent + // to wait for subtests to complete + t.Run("unrestricted", func(t *testing.T) { + ci.Parallel(t) + path := filepath.Join(testDir, t.Name()) + + err := NewWindowsPaths().CreateDirectory(path, false) + must.NoError(t, err) + + dacl := getDirectoryDACL(t, path) + + // When not applying restrictions on the new directory, all + // ACEs will be inherited from the parent + for i := range dacl.AceCount { + ace := &windows.ACCESS_ALLOWED_ACE{} + must.NoError(t, windows.GetAce(dacl, uint32(i), &ace), must.Sprint("failed to load ACE")) + must.Eq(t, windows.INHERITED_ACCESS_ENTRY, ace.Header.AceFlags&windows.INHERITED_ACCESS_ENTRY, + must.Sprint("ACE is not inherited")) + } + }) + + t.Run("restricted", func(t *testing.T) { + ci.Parallel(t) + path := filepath.Join(testDir, t.Name()) + + err := NewWindowsPaths().CreateDirectory(path, true) + must.NoError(t, err) + + dacl := getDirectoryDACL(t, path) + matches := map[string]struct{}{} + + // When restrictions are applied on the new directory, all + // ACEs will be directly applied. + for i := range dacl.AceCount { + ace := &windows.ACCESS_ALLOWED_ACE{} + must.NoError(t, windows.GetAce(dacl, uint32(i), &ace), must.Sprint("failed to load ACE")) + must.NotEq(t, windows.INHERITED_ACCESS_ENTRY, ace.Header.AceFlags&windows.INHERITED_ACCESS_ENTRY, + must.Sprint("ACE should not be inherited")) + + if ace.Mask&windows.GENERIC_ALL == windows.GENERIC_ALL { + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + matches[sid.String()] = struct{}{} + } + } + + // All privileges should be set for user and administrators groups + adminGroupSID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + must.NoError(t, err, must.Sprint("failed to create well known administrators group SID")) + userSID, _, err := getUserGroupSIDs() + must.NoError(t, err, must.Sprint("failed to get user SID")) + + must.NotNil(t, matches[userSID.String()], must.Sprint("missing user ACE with GENERIC_ALL")) + must.NotNil(t, matches[adminGroupSID.String()], + must.Sprint("missing administrators group ACE with GENERIC_ALL")) + + must.Eq(t, 2, len(matches), must.Sprint("unexpected GENERIC_ALL ACEs found")) + }) + + t.Run("unrestricted already exists", func(t *testing.T) { + ci.Parallel(t) + path := filepath.Join(testDir, t.Name()) + must.NoError(t, os.MkdirAll(path, 0o000)) + + err := NewWindowsPaths().CreateDirectory(path, false) + must.NoError(t, err) + + dacl := getDirectoryDACL(t, path) + + // No restrictions are applied, so check that all ACEs + // are inherited from parent + for i := range dacl.AceCount { + ace := &windows.ACCESS_ALLOWED_ACE{} + must.NoError(t, windows.GetAce(dacl, uint32(i), &ace), must.Sprint("failed to load ACE")) + must.Eq(t, windows.INHERITED_ACCESS_ENTRY, ace.Header.AceFlags&windows.INHERITED_ACCESS_ENTRY, + must.Sprint("ACE is not inherited")) + } + }) + + t.Run("restricted already exists", func(t *testing.T) { + ci.Parallel(t) + path := filepath.Join(testDir, t.Name()) + must.NoError(t, os.MkdirAll(path, 0o000)) + + err := NewWindowsPaths().CreateDirectory(path, true) + must.NoError(t, err) + + dacl := getDirectoryDACL(t, path) + + // When the directory already exists, restrictions should not + // be applied so validate that all ACEs are inherited + for i := range dacl.AceCount { + ace := &windows.ACCESS_ALLOWED_ACE{} + must.NoError(t, windows.GetAce(dacl, uint32(i), &ace), must.Sprint("failed to load ACE")) + must.Eq(t, windows.INHERITED_ACCESS_ENTRY, ace.Header.AceFlags&windows.INHERITED_ACCESS_ENTRY, + must.Sprint("ACE is not inherited")) + } + }) + }) +} + +func TestExpand(t *testing.T) { + t.Run("SystemDrive", func(t *testing.T) { + t.Run("default", func(t *testing.T) { + result, err := NewWindowsPaths().Expand(`{{.SystemDrive}}/testing`) + must.NoError(t, err) + must.StrNotContains(t, result, "{{.SystemDrive}}") + }) + t.Run("custom environment variable", func(t *testing.T) { + t.Setenv("SystemDrive", `z:`) + result, err := NewWindowsPaths().Expand(`{{.SystemDrive}}\testing`) + must.NoError(t, err) + must.Eq(t, `z:\testing`, result) + }) + t.Run("unset environment variable", func(t *testing.T) { + t.Setenv("SystemDrive", "") + _, err := NewWindowsPaths().Expand(`{{.SystemDrive}}\testing`) + must.ErrorContains(t, err, "cannot detect Windows SystemDrive path") + }) + }) + + t.Run("ProgramData", func(t *testing.T) { + t.Run("default", func(t *testing.T) { + result, err := NewWindowsPaths().Expand(`{{.ProgramData}}/testing`) + must.NoError(t, err) + must.StrNotContains(t, result, "{{.ProgramData}}") + }) + t.Run("custom environment variable", func(t *testing.T) { + t.Setenv("ProgramData", `z:`) + result, err := NewWindowsPaths().Expand(`{{.ProgramData}}\testing`) + must.NoError(t, err) + must.Eq(t, `z:\testing`, result) + }) + t.Run("unset environment variable", func(t *testing.T) { + t.Setenv("ProgramData", "") + result, err := NewWindowsPaths().Expand(`{{.ProgramData}}\testing`) + must.NoError(t, err) + must.StrNotContains(t, result, "{{.ProgramData}}") // should be pulled from registry + }) + }) + + t.Run("ProgramFiles", func(t *testing.T) { + t.Run("default", func(t *testing.T) { + result, err := NewWindowsPaths().Expand(`{{.ProgramFiles}}/testing`) + must.NoError(t, err) + must.StrNotContains(t, result, "{{.ProgramFiles}}") + }) + t.Run("custom environment variable", func(t *testing.T) { + t.Setenv("ProgramFiles", `z:`) + result, err := NewWindowsPaths().Expand(`{{.ProgramFiles}}\testing`) + must.NoError(t, err) + must.Eq(t, `z:\testing`, result) + }) + t.Run("unset environment variable", func(t *testing.T) { + t.Setenv("ProgramFiles", "") + result, err := NewWindowsPaths().Expand(`{{.ProgramFiles}}\testing`) + must.NoError(t, err) + must.StrNotContains(t, result, "{{.ProgramFiles}}") // should be pulled from registry + }) + }) + + t.Run("missing key", func(t *testing.T) { + _, err := NewWindowsPaths().Expand(`{{.Unknown}}\testing`) + must.ErrorContains(t, err, "can't evaluate field") + }) +} + +func getDirectoryDACL(t *testing.T, path string) *windows.ACL { + t.Helper() + + s, err := os.Stat(path) + must.NoError(t, err) + must.True(t, s.IsDir(), must.Sprint("expected path to be a directory")) + + info, err := windows.GetNamedSecurityInfo(path, + windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + must.NoError(t, err, must.Sprint("failed to get path security information")) + + dacl, _, err := info.DACL() + must.NoError(t, err, must.Sprint("failed to get path ACL")) + + return dacl +} diff --git a/helper/winsvc/privileged_nonwindows.go b/helper/winsvc/privileged_nonwindows.go new file mode 100644 index 000000000..e365a8f5b --- /dev/null +++ b/helper/winsvc/privileged_nonwindows.go @@ -0,0 +1,11 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows + +package winsvc + +// IsPrivilegedProcess checks if current process is a privileged windows process +func IsPrivilegedProcess() bool { + return false +} diff --git a/helper/winsvc/privileged_windows.go b/helper/winsvc/privileged_windows.go new file mode 100644 index 000000000..cbdad9a9e --- /dev/null +++ b/helper/winsvc/privileged_windows.go @@ -0,0 +1,11 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import "golang.org/x/sys/windows" + +// IsPrivilegedProcess checks if current process is a privileged windows process +func IsPrivilegedProcess() bool { + return windows.GetCurrentProcessToken().IsElevated() +} diff --git a/helper/winsvc/service.go b/helper/winsvc/service.go index df15971c9..8eb4b9ae9 100644 --- a/helper/winsvc/service.go +++ b/helper/winsvc/service.go @@ -3,6 +3,18 @@ package winsvc +const ( + WINDOWS_SERVICE_NAME = "nomad" + WINDOWS_SERVICE_DISPLAY_NAME = "HashiCorp Nomad" + WINDOWS_SERVICE_DESCRIPTION = "Workload scheduler and orchestrator - https://nomadproject.io" + WINDOWS_INSTALL_BIN_DIRECTORY = `{{.ProgramFiles}}\HashiCorp\nomad\bin` + WINDOWS_INSTALL_APPDATA_DIRECTORY = `{{.ProgramData}}\HashiCorp\nomad` + + // Number of seconds to wait for a + // service to reach a desired state + WINDOWS_SERVICE_STATE_TIMEOUT = "1m" +) + var chanGraceExit = make(chan int) // ShutdownChannel returns a channel that sends a message that a shutdown diff --git a/helper/winsvc/windows_service.go b/helper/winsvc/windows_service.go new file mode 100644 index 000000000..3d999a834 --- /dev/null +++ b/helper/winsvc/windows_service.go @@ -0,0 +1,75 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +type ServiceStartType uint32 + +// extracted from https://pkg.go.dev/golang.org/x/sys@v0.35.0/windows/svc/mgr#StartManual +const ( + StartManual ServiceStartType = 3 + StartAutomatic ServiceStartType = 2 + StartDisabled ServiceStartType = 4 +) + +type WindowsServiceConfiguration struct { + StartType ServiceStartType + DisplayName string + Description string + BinaryPathName string +} + +type WindowsPaths interface { + // Expand expands the path defined by the template. Supports + // values for: + // - SystemDrive + // - SystemRoot + // - ProgramData + // - ProgramFiles + Expand(path string) (string, error) + + // Creates a new directory if it does not exist. If directory + // is created and restrict_on_create is true, a restrictive + // ACL is applied. + CreateDirectory(path string, restrict_on_create bool) error +} + +type WindowsService interface { + // Name returns the name of the service + Name() string + // Configure applies the configuration to the Windows service. + // NOTE: Full configuration applied so empty values will remove existing values. + Configure(config WindowsServiceConfiguration) error + // Start starts the Windows service and waits for the + // service to be running. + Start() error + // Stop requests the service to stop and waits for the + // service to stop. + Stop() error + // Close closes the connection to the Windows service. + Close() error + // Delete deletes the Windows service. + Delete() error + // IsRunning returns if the service is currently running. + IsRunning() (bool, error) + // IsStopped returns if the service is currently stopped. + IsStopped() (bool, error) + // EnableEventlog will add or update the Windows Eventlog + // configuration for the service. It will set supported + // events as info, warning, and error. + EnableEventlog() error + // DisableEventlog will remove the Windows Eventlog configuration + // for the service. + DisableEventlog() error +} + +type WindowsServiceManager interface { + // IsServiceRegistered returns if the service is a registered Windows service. + IsServiceRegistered(name string) (bool, error) + // GetService opens and returns the named service. + GetService(name string) (WindowsService, error) + // CreateService creates a new Windows service. + CreateService(name, binaryPath string, config WindowsServiceConfiguration) (WindowsService, error) + // Close closes Windows service manager connection. + Close() error +} diff --git a/helper/winsvc/windows_service_nonwindows.go b/helper/winsvc/windows_service_nonwindows.go new file mode 100644 index 000000000..6e7c615e5 --- /dev/null +++ b/helper/winsvc/windows_service_nonwindows.go @@ -0,0 +1,15 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows + +package winsvc + +import ( + "errors" +) + +// NewWindowsServiceManager returns an error +func NewWindowsServiceManager() (WindowsServiceManager, error) { + return nil, errors.New("Windows service manager is not supported on this platform") +} diff --git a/helper/winsvc/windows_service_windows.go b/helper/winsvc/windows_service_windows.go new file mode 100644 index 000000000..d060ed1d4 --- /dev/null +++ b/helper/winsvc/windows_service_windows.go @@ -0,0 +1,256 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/signal" + "reflect" + "slices" + "time" + + "github.com/hashicorp/nomad/helper" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +// Base registry path for eventlog registrations +const EVENTLOG_REGISTRY_PATH = `SYSTEM\CurrentControlSet\Services\EventLog\Application` + +// Registry value name for supported event types +const EVENTLOG_SUPPORTED_EVENTS_KEY = "TypesSupported" + +// Event types registered as supported +const EVENTLOG_SUPPORTED_EVENTS uint32 = eventlog.Error | eventlog.Warning | eventlog.Info + +// NewWindowsServiceManager creates a new instance of the wrapper +// to interact with the Windows service manager. +func NewWindowsServiceManager() (WindowsServiceManager, error) { + m, err := mgr.Connect() + if err != nil { + return nil, err + } + + return &windowsServiceManager{manager: m}, nil +} + +type windowsServiceManager struct { + manager *mgr.Mgr +} + +func (m *windowsServiceManager) IsServiceRegistered(name string) (bool, error) { + list, err := m.manager.ListServices() + if err != nil { + return false, err + } + + if slices.Contains(list, name) { + return true, nil + } + + return false, nil +} + +func (m *windowsServiceManager) GetService(name string) (WindowsService, error) { + service, err := m.manager.OpenService(name) + if err != nil { + return nil, err + } + + return &windowsService{service: service}, nil +} + +func (m *windowsServiceManager) CreateService(name, bin string, config WindowsServiceConfiguration) (WindowsService, error) { + wsvc, err := m.manager.CreateService(name, bin, mgr.Config{}) + if err != nil { + return nil, err + } + + service := &windowsService{service: wsvc} + + // Only apply configuration if configuration is provided + if !reflect.ValueOf(config).IsZero() { + if err := service.Configure(config); err != nil { + return nil, err + } + } + + return service, nil +} + +func (m *windowsServiceManager) Close() error { + return m.manager.Disconnect() +} + +type windowsService struct { + service *mgr.Service +} + +func (s *windowsService) Name() string { + return s.service.Name +} + +func (s *windowsService) Configure(config WindowsServiceConfiguration) error { + serviceCfg, err := s.service.Config() + if err != nil { + return err + } + + serviceCfg.StartType = uint32(config.StartType) + serviceCfg.DisplayName = config.DisplayName + serviceCfg.Description = config.Description + serviceCfg.BinaryPathName = config.BinaryPathName + + if err := s.service.UpdateConfig(serviceCfg); err != nil { + return err + } + + return nil +} + +func (s *windowsService) Start() error { + if running, _ := s.IsRunning(); running { + return nil + } + + if err := s.service.Start(); err != nil { + return err + } + + if err := waitFor(context.Background(), s.IsRunning); err != nil { + return err + } + + return nil +} + +func (s *windowsService) Stop() error { + if stopped, _ := s.IsStopped(); stopped { + return nil + } + + if _, err := s.service.Control(svc.Stop); err != nil { + return err + } + + if err := waitFor(context.Background(), s.IsStopped); err != nil { + return err + } + + return nil +} + +func (s *windowsService) Close() error { + return s.service.Close() +} + +func (s *windowsService) Delete() error { + return s.service.Delete() +} + +func (s *windowsService) IsRunning() (bool, error) { + return s.isService(svc.Running) +} + +func (s *windowsService) IsStopped() (bool, error) { + return s.isService(svc.Stopped) +} + +func (s *windowsService) EnableEventlog() error { + // Check if the service is already setup in the eventlog + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + EVENTLOG_REGISTRY_PATH+`\`+s.Name(), + registry.ALL_ACCESS, + ) + + // If it could not be opened, assume error is caused + // due to nonexistence. If it was for some other reason + // the error will be encountered again when attempting to + // create. + if err != nil { + if err := eventlog.InstallAsEventCreate(s.Name(), EVENTLOG_SUPPORTED_EVENTS); err != nil { + return err + } + } else { + defer key.Close() + + // Since the service is already registered, just + // ensure it is properly configured. Currently + // that just means the supported events. + val, _, err := key.GetIntegerValue(EVENTLOG_SUPPORTED_EVENTS_KEY) + if err != nil || uint32(val) != EVENTLOG_SUPPORTED_EVENTS { + if err := key.SetDWordValue(EVENTLOG_SUPPORTED_EVENTS_KEY, EVENTLOG_SUPPORTED_EVENTS); err != nil { + return err + } + } + } + + return nil +} + +func (s *windowsService) DisableEventlog() error { + // Check if the service is currently enabled in the eventlog + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + EVENTLOG_REGISTRY_PATH+`\`+s.Name(), + registry.READ, + ) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + defer key.Close() + + return eventlog.Remove(s.Name()) +} + +func (s *windowsService) isService(state svc.State) (bool, error) { + status, err := s.service.Query() + if err != nil { + return false, err + } + + return status.State == state, nil +} + +func waitFor(ctx context.Context, condition func() (bool, error)) error { + d, err := time.ParseDuration(WINDOWS_SERVICE_STATE_TIMEOUT) + if err != nil { + return err + } + + // Setup a deadline + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(d)) + defer cancel() + // Watch for any interrupts + ctx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + + pauseDur := time.Millisecond * 250 + t, timerStop := helper.NewSafeTimer(pauseDur) + defer timerStop() + + for { + t.Reset(pauseDur) + + complete, err := condition() + if err != nil { + return err + } + + if complete { + return nil + } + + select { + case <-ctx.Done(): + return fmt.Errorf("timeout exceeded waiting for process") + case <-t.C: + } + } +} diff --git a/helper/winsvc/windows_service_windows_test.go b/helper/winsvc/windows_service_windows_test.go new file mode 100644 index 000000000..99d766981 --- /dev/null +++ b/helper/winsvc/windows_service_windows_test.go @@ -0,0 +1,598 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "context" + "io/fs" + "testing" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +func TestWindowsServiceManager(t *testing.T) { + ci.Parallel(t) + + t.Run("IsServiceRegistered", func(t *testing.T) { + ci.Parallel(t) + t.Run("service does not exist", func(t *testing.T) { + ci.Parallel(t) + _, manager := makeManagers(t) + + result, err := manager.IsServiceRegistered("fake-service-name") + must.NoError(t, err, must.Sprint("check should not error")) + must.False(t, result, must.Sprint("service should not exist")) + }) + + t.Run("service does exist", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + result, err := manager.IsServiceRegistered(serviceName) + must.NoError(t, err, must.Sprint("check should not error")) + must.True(t, result, must.Sprint("service should exist")) + }) + }) + + t.Run("GetService", func(t *testing.T) { + ci.Parallel(t) + t.Run("service does not exist", func(t *testing.T) { + ci.Parallel(t) + _, manager := makeManagers(t) + _, err := manager.GetService("fake-service-name") + must.ErrorContains(t, err, "specified service does not exist", + must.Sprint("error should be generated when service does not exist")) + }) + + t.Run("service does exist", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err) + defer srv.Close() + must.Eq(t, serviceName, srv.Name(), must.Sprint("service name does not match")) + }) + }) + + t.Run("CreateService", func(t *testing.T) { + ci.Parallel(t) + t.Run("service does not exist", func(t *testing.T) { + ci.Parallel(t) + serviceName := generateServiceName() + m, manager := makeManagers(t) + + srv, err := manager.CreateService(serviceName, `c:\stub`, WindowsServiceConfiguration{}) + must.NoError(t, err) + defer srv.Close() + defer deleteStubService(t, m, serviceName) + + must.Eq(t, serviceName, srv.Name(), must.Sprint("new service name is incorrect")) + }) + + t.Run("service does exist", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + _, err := manager.CreateService(serviceName, `c:\stub`, WindowsServiceConfiguration{}) + must.ErrorContains(t, err, "service already exists", must.Sprint("service creation should fail")) + }) + + t.Run("with configuration", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateServiceName() + srv, err := manager.CreateService(serviceName, `c:\stub`, + WindowsServiceConfiguration{DisplayName: "testing service", StartType: StartDisabled}) + must.NoError(t, err, must.Sprint("service should be created")) + defer srv.Close() + defer deleteStubService(t, m, serviceName) + + directSrv, err := m.OpenService(serviceName) + must.NoError(t, err, must.Sprint("direct service connection should succeed")) + defer directSrv.Close() + + config, err := directSrv.Config() + must.NoError(t, err, must.Sprint("configuration should be available from service")) + must.Eq(t, "testing service", config.DisplayName, must.Sprint("new service name does not match")) + }) + }) +} + +// This is a simple service available in Windows. It will +// be used to locate the executable so a test service can +// be created using it that will allow proper start/stop +// testing. +const TEST_WINDOWS_SERVICE = "SNMPTrap" + +func TestWindowsService(t *testing.T) { + ci.Parallel(t) + + mg, _ := makeManagers(t) + snmpSvc, err := mg.OpenService(TEST_WINDOWS_SERVICE) + must.NoError(t, err) + defer snmpSvc.Close() + snmpConfig, err := snmpSvc.Config() + must.NoError(t, err) + binPath := snmpConfig.BinaryPathName + + t.Run("Name", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err) + defer srv.Close() + + must.Eq(t, serviceName, srv.Name(), must.Sprint("service name does not match")) + }) + + t.Run("Configure", func(t *testing.T) { + ci.Parallel(t) + t.Run("valid configuration", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be available")) + err = srv.Configure(WindowsServiceConfiguration{ + StartType: StartDisabled, + DisplayName: "testing display name", + BinaryPathName: `c:\stub -with -arguments`, + }) + must.NoError(t, err, must.Sprint("valid configuration should not error")) + directSrv, err := m.OpenService(serviceName) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + config, err := directSrv.Config() + must.NoError(t, err, must.Sprint("direct service config should be available")) + must.Eq(t, "testing display name", config.DisplayName, must.Sprint("display name does not match")) + must.Eq(t, `c:\stub -with -arguments`, config.BinaryPathName, must.Sprint("binary path name does not match")) + }) + + t.Run("invalid configuration", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + srv, err := manager.GetService(serviceName) + + must.NoError(t, err, must.Sprint("service should be available")) + err = srv.Configure(WindowsServiceConfiguration{ + DisplayName: "testing display name", + BinaryPathName: `c:\stub -with -arguments`, + }) + must.ErrorContains(t, err, "parameter is incorrect", must.Sprint("invalid configuration should error")) + }) + }) + + t.Run("Start", func(t *testing.T) { + ci.Parallel(t) + t.Run("when stopped", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Stopped { + _, err := directSrv.Control(svc.Stop) + must.NoError(t, err, must.Sprint("direct stop should not fail")) + err = waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Stopped, nil + }) + must.NoError(t, err, must.Sprint("service must be stopped")) + } + must.NoError(t, runnableSvc.Start(), must.Sprint("service should start without error")) + status, err = directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + must.Eq(t, status.State, svc.Running, must.Sprint("service should be running")) + }) + + t.Run("when running", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Running { + must.NoError(t, directSrv.Start(), must.Sprint("direct start should not fail")) + err := waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Running, nil + }) + must.NoError(t, err, must.Sprint("service must be running")) + } + must.NoError(t, runnableSvc.Start(), must.Sprint("service should start without error")) + status, err = directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + must.Eq(t, status.State, svc.Running, must.Sprint("service should be running")) + }) + }) + + t.Run("Stop", func(t *testing.T) { + ci.Parallel(t) + t.Run("when stopped", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Stopped { + _, err := directSrv.Control(svc.Stop) + must.NoError(t, err, must.Sprint("direct stop should not fail")) + err = waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Stopped, nil + }) + must.NoError(t, err, must.Sprint("service must be stopped")) + } + must.NoError(t, runnableSvc.Stop(), must.Sprint("service should stop without error")) + status, err = directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + must.Eq(t, status.State, svc.Stopped, must.Sprint("service should be stopped")) + }) + + t.Run("when running", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Running { + must.NoError(t, directSrv.Start(), must.Sprint("direct start should not fail")) + err := waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Running, nil + }) + must.NoError(t, err, must.Sprint("service must be running")) + } + must.NoError(t, runnableSvc.Stop(), must.Sprint("service should stop without error")) + status, err = directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + must.Eq(t, status.State, svc.Stopped, must.Sprint("service should be stopped")) + }) + }) + + t.Run("Delete", func(t *testing.T) { + ci.Parallel(t) + t.Run("when service exists", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + + serviceName := generateStubService(t, m) + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be avaialble")) + defer srv.Close() + + must.NoError(t, srv.Delete(), must.Sprint("service should be deleted")) + }) + + t.Run("when service does not exist", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + + serviceName := generateStubService(t, m) + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be avaialble")) + defer srv.Close() + // Delete the service directly + directSrv, err := m.OpenService(serviceName) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + must.NoError(t, directSrv.Delete(), must.Sprint("service should be deleted")) + + must.ErrorContains(t, srv.Delete(), "marked for deletion", + must.Sprint("service should have already been deleted")) + }) + }) + + t.Run("IsRunning", func(t *testing.T) { + ci.Parallel(t) + t.Run("when service is not running", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Stopped { + _, err := directSrv.Control(svc.Stop) + must.NoError(t, err, must.Sprint("direct stop should not fail")) + err = waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Stopped, nil + }) + must.NoError(t, err, must.Sprint("service must be stopped")) + } + + srv, err := manager.GetService(directSrv.Name) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + result, err := srv.IsRunning() + must.NoError(t, err, must.Sprint("running check should not error")) + must.False(t, result, must.Sprint("should not show service as running")) + }) + + t.Run("when service is running", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Running { + must.NoError(t, directSrv.Start(), must.Sprint("direct start should not fail")) + err := waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Running, nil + }) + must.NoError(t, err, must.Sprint("service must be running")) + } + srv, err := manager.GetService(directSrv.Name) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + result, err := srv.IsRunning() + must.NoError(t, err, must.Sprint("running check should not error")) + must.True(t, result, must.Sprint("should show service as running")) + }) + }) + + t.Run("IsStopped", func(t *testing.T) { + ci.Parallel(t) + t.Run("when service is not running", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Stopped { + _, err := directSrv.Control(svc.Stop) + must.NoError(t, err, must.Sprint("direct stop should not fail")) + err = waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Stopped, nil + }) + must.NoError(t, err, must.Sprint("service must be stopped")) + } + + srv, err := manager.GetService(directSrv.Name) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + result, err := srv.IsStopped() + must.NoError(t, err, must.Sprint("running check should not error")) + must.True(t, result, must.Sprint("should show service as stopped")) + }) + + t.Run("when service is running", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + runnableSvc := runnableService(t, manager, binPath) + directSrv, err := m.OpenService(runnableSvc.Name()) + must.NoError(t, err, must.Sprint("direct service should be available")) + defer directSrv.Close() + + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service status should be available")) + if status.State != svc.Running { + must.NoError(t, directSrv.Start(), must.Sprint("direct start should not fail")) + err := waitFor(context.Background(), func() (bool, error) { + status, err := directSrv.Query() + must.NoError(t, err, must.Sprint("direct service should be queryable")) + return status.State == svc.Running, nil + }) + must.NoError(t, err, must.Sprint("service must be running")) + } + srv, err := manager.GetService(directSrv.Name) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + result, err := srv.IsStopped() + must.NoError(t, err, must.Sprint("running check should not error")) + must.False(t, result, must.Sprint("should not show service as stopped")) + }) + }) + + t.Run("EnableEventLog", func(t *testing.T) { + ci.Parallel(t) + t.Run("when service is not registered", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + + must.NoError(t, srv.EnableEventlog(), must.Sprint("could not enable eventlog")) + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + EVENTLOG_REGISTRY_PATH+`\`+serviceName, + registry.READ, + ) + must.NoError(t, err, must.Sprint("registry key should be available")) + defer key.Close() + val, _, err := key.GetIntegerValue(EVENTLOG_SUPPORTED_EVENTS_KEY) + must.NoError(t, err, must.Sprint("registry key value should be available")) + must.Eq(t, EVENTLOG_SUPPORTED_EVENTS, uint32(val), must.Sprint("registry value should match")) + }) + + t.Run("when service is already registered", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + must.NoError(t, srv.EnableEventlog(), must.Sprint("could not enable eventlog")) + // Modify value in registry + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + EVENTLOG_REGISTRY_PATH+`\`+serviceName, + registry.ALL_ACCESS, + ) + err = key.SetDWordValue(EVENTLOG_SUPPORTED_EVENTS_KEY, 1) + must.NoError(t, err, must.Sprint("could not modify registry value")) + + // Now enable and verify value is correct + must.NoError(t, srv.EnableEventlog(), must.Sprint("failed to enable eventlog")) + val, _, err := key.GetIntegerValue(EVENTLOG_SUPPORTED_EVENTS_KEY) + must.NoError(t, err, must.Sprint("registry value should be available")) + must.Eq(t, EVENTLOG_SUPPORTED_EVENTS, uint32(val), must.Sprint("registry value should match")) + }) + }) + + t.Run("DisableEventLog", func(t *testing.T) { + ci.Parallel(t) + t.Run("when service is not registered", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + + must.NoError(t, srv.DisableEventlog(), must.Sprint("eventlog disable should not error")) + }) + + t.Run("when service is registered", func(t *testing.T) { + ci.Parallel(t) + m, manager := makeManagers(t) + serviceName := generateStubService(t, m) + + srv, err := manager.GetService(serviceName) + must.NoError(t, err, must.Sprint("service should be available")) + defer srv.Close() + must.NoError(t, srv.EnableEventlog(), must.Sprint("eventlog enable should not error")) + + must.NoError(t, srv.DisableEventlog(), must.Sprint("eventlog disable should not error")) + _, err = registry.OpenKey(registry.LOCAL_MACHINE, + EVENTLOG_REGISTRY_PATH+`\`+serviceName, + registry.READ, + ) + must.ErrorIs(t, err, fs.ErrNotExist, must.Sprint("registry key should no longer exist")) + }) + }) +} + +func generateServiceName() string { + id, err := uuid.GenerateUUID() + if err != nil { + panic(err) + } + return id[:5] +} + +func generateStubService(t *testing.T, m *mgr.Mgr) string { + t.Helper() + + id := generateServiceName() + _, err := m.CreateService(id, `c:\stub`, mgr.Config{}) + must.NoError(t, err, must.Sprint("failed to generate stub service")) + + t.Cleanup(func() { deleteStubService(t, m, id) }) + + return id +} + +func deleteStubService(t *testing.T, m *mgr.Mgr, svcId string) { + t.Helper() + + srvc, err := m.OpenService(svcId) + if err != nil { + // If the service doesn't exist, then deletion is done so not + // an error. Otherwise, force an error. + must.ErrorContains(t, err, "service does not exist", must.Sprint("failed to open service")) + return + } + status, err := srvc.Query() + must.NoError(t, err, must.Sprint("failed to query service")) + if status.State != svc.Stopped { + status, err = srvc.Control(svc.Stop) + must.NoError(t, err, must.Sprint("failed to stop service")) + err := waitFor(context.Background(), func() (bool, error) { + status, err := srvc.Query() + must.NoError(t, err, must.Sprint("failed to query service")) + return status.State == svc.Stopped, nil + }) + must.NoError(t, err, must.Sprintf("could not stop service for deletion - %s", svcId)) + } + if err := srvc.Delete(); err != nil { + must.ErrorContains(t, err, "service has been marked for deletion", must.Sprint("failed to delete service")) + } +} + +func makeManagers(t *testing.T) (*mgr.Mgr, WindowsServiceManager) { + t.Helper() + + winM, err := NewWindowsServiceManager() + must.NoError(t, err, must.Sprint("failed to create service manager")) + + m, err := mgr.Connect() + must.NoError(t, err, must.Sprint("failed to connect to windows service manager")) + + t.Cleanup(func() { + winM.Close() + m.Disconnect() + }) + + return m, winM +} + +func runnableService(t *testing.T, m WindowsServiceManager, binPath string) WindowsService { + t.Helper() + + runnableSvc, err := m.CreateService(generateServiceName(), binPath, + WindowsServiceConfiguration{StartType: StartManual, BinaryPathName: binPath}) + must.NoError(t, err, must.Sprint("failed to create runnable service")) + + t.Cleanup(func() { runnableSvc.Close() }) + + return runnableSvc +} From 61c36bdef7419f5d074f89525a14a830749c0575 Mon Sep 17 00:00:00 2001 From: Chris Roberts Date: Tue, 5 Aug 2025 14:37:58 -0700 Subject: [PATCH 2/3] [winsvc] Add support for Windows Eventlog (#26441) Defines a `winsvc.Event` type which can be sent using the `winsvc.SendEvent` function. If nomad is running on Windows and can send to the Windows Eventlog the event will be sent. Initial event types are defined for starting, ready, stopped, and log message. The `winsvc.EventLogger` provides an `io.WriteCloser` that can be included in the logger's writers collection. It will extract the log level from log lines and write them appropriately to the eventlog. The eventlog only supports error, warning, and info levels so messages with other levels will be ignored. A new configuration block is included for enabling logging to the eventlog. Logging must be enabled with the `log_level` option and the `eventlog.level` value can then be of the same or higher severity. --- command/agent/command.go | 38 +++++++- command/agent/command_test.go | 31 ++++++ command/agent/config.go | 69 ++++++++++++++ command/agent/config_test.go | 92 ++++++++++++++++++ helper/winsvc/event.go | 73 +++++++++++++++ helper/winsvc/event_logger.go | 106 +++++++++++++++++++++ helper/winsvc/event_logger_nonwindows.go | 17 ++++ helper/winsvc/event_logger_test.go | 114 +++++++++++++++++++++++ helper/winsvc/event_logger_windows.go | 24 +++++ helper/winsvc/event_test.go | 43 +++++++++ helper/winsvc/events_nonwindows.go | 9 ++ helper/winsvc/events_windows.go | 26 ++++++ helper/winsvc/mock_eventlog.go | 84 +++++++++++++++++ helper/winsvc/service.go | 4 +- helper/winsvc/service_windows.go | 78 ++++++++++++---- helper/winsvc/strings_event_logger.go | 26 ++++++ helper/winsvc/strings_eventid.go | 27 ++++++ 17 files changed, 838 insertions(+), 23 deletions(-) create mode 100644 helper/winsvc/event.go create mode 100644 helper/winsvc/event_logger.go create mode 100644 helper/winsvc/event_logger_nonwindows.go create mode 100644 helper/winsvc/event_logger_test.go create mode 100644 helper/winsvc/event_logger_windows.go create mode 100644 helper/winsvc/event_test.go create mode 100644 helper/winsvc/events_nonwindows.go create mode 100644 helper/winsvc/events_windows.go create mode 100644 helper/winsvc/mock_eventlog.go create mode 100644 helper/winsvc/strings_event_logger.go create mode 100644 helper/winsvc/strings_eventid.go 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]] +} From c3dcdb5413f55fa23a4608b18a648f951c065aad Mon Sep 17 00:00:00 2001 From: Chris Roberts Date: Mon, 25 Aug 2025 10:07:24 -0700 Subject: [PATCH 3/3] [cli] Add windows service commands (#26442) Adds a new `windows` command which is available when running on a Windows hosts. The command includes two new subcommands: * `service install` * `service uninstall` The `service install` command will install the called binary into the Windows program files directory, create a new Windows service, setup configuration and data directories, and register the service with the Window eventlog. If the service and/or binary already exist, the service will be stopped, service and eventlog updated if needed, binary replaced, and the service started again. The `service uninstall` command will stop the service, remove the Windows service, and deregister the service with the eventlog. It will not remove the configuration/data directory nor will it remove the installed binary. --- .changelog/26441.txt | 3 + .changelog/26442.txt | 3 + command/commands.go | 27 ++ command/windows.go | 35 ++ command/windows_service.go | 41 ++ command/windows_service_install.go | 365 ++++++++++++++++ command/windows_service_install_test.go | 391 ++++++++++++++++++ command/windows_service_uninstall.go | 121 ++++++ command/windows_service_uninstall_test.go | 146 +++++++ helper/winsvc/mock_windows_service.go | 320 ++++++++++++++ website/content/commands/agent.mdx | 6 + website/content/commands/windows/index.mdx | 22 + .../commands/windows/service-install.mdx | 50 +++ .../commands/windows/service-uninstall.mdx | 22 + website/content/docs/configuration/index.mdx | 10 + .../deploy/production/windows-service.mdx | 12 +- website/data/commands-nav-data.json | 17 + 17 files changed, 1587 insertions(+), 4 deletions(-) create mode 100644 .changelog/26441.txt create mode 100644 .changelog/26442.txt create mode 100644 command/windows.go create mode 100644 command/windows_service.go create mode 100644 command/windows_service_install.go create mode 100644 command/windows_service_install_test.go create mode 100644 command/windows_service_uninstall.go create mode 100644 command/windows_service_uninstall_test.go create mode 100644 helper/winsvc/mock_windows_service.go create mode 100644 website/content/commands/windows/index.mdx create mode 100644 website/content/commands/windows/service-install.mdx create mode 100644 website/content/commands/windows/service-uninstall.mdx diff --git a/.changelog/26441.txt b/.changelog/26441.txt new file mode 100644 index 000000000..436afbf2f --- /dev/null +++ b/.changelog/26441.txt @@ -0,0 +1,3 @@ +```release-note:improvement +agent: Allow agent logging to the Windows Event Log +``` diff --git a/.changelog/26442.txt b/.changelog/26442.txt new file mode 100644 index 000000000..91691cf61 --- /dev/null +++ b/.changelog/26442.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: Add commands for installing and uninstalling Windows system service +``` diff --git a/command/commands.go b/command/commands.go index 14ec45b67..62f62b3e3 100644 --- a/command/commands.go +++ b/command/commands.go @@ -5,7 +5,9 @@ package command import ( "fmt" + "maps" "os" + "runtime" "github.com/hashicorp/cli" "github.com/hashicorp/nomad/command/agent" @@ -1339,6 +1341,31 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { }, } + if runtime.GOOS == "windows" { + maps.Copy(all, map[string]cli.CommandFactory{ + "windows": func() (cli.Command, error) { + return &WindowsCommand{ + Meta: meta, + }, nil + }, + "windows service": func() (cli.Command, error) { + return &WindowsServiceCommand{ + Meta: meta, + }, nil + }, + "windows service install": func() (cli.Command, error) { + return &WindowsServiceInstallCommand{ + Meta: meta, + }, nil + }, + "windows service uninstall": func() (cli.Command, error) { + return &WindowsServiceUninstallCommand{ + Meta: meta, + }, nil + }, + }) + } + deprecated := map[string]cli.CommandFactory{ "client-config": func() (cli.Command, error) { return &DeprecatedCommand{ diff --git a/command/windows.go b/command/windows.go new file mode 100644 index 000000000..f86166367 --- /dev/null +++ b/command/windows.go @@ -0,0 +1,35 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/hashicorp/cli" +) + +type WindowsCommand struct { + Meta +} + +func (c *WindowsCommand) Help() string { + helpText := ` +Usage: nomad windows [options] + + This command groups subcommands for managing Nomad as a system service on Windows. + + Service:: + + $ nomad windows service + + Refer to the individual subcommand help for detailed usage information. +` + return strings.TrimSpace(helpText) +} + +func (c *WindowsCommand) Name() string { return "windows" } + +func (c *WindowsCommand) Synopsis() string { return "Manage Nomad as a system service on Windows" } + +func (c *WindowsCommand) Run(_ []string) int { return cli.RunResultHelp } diff --git a/command/windows_service.go b/command/windows_service.go new file mode 100644 index 000000000..f67df7de4 --- /dev/null +++ b/command/windows_service.go @@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/hashicorp/cli" +) + +type WindowsServiceCommand struct { + Meta +} + +func (c *WindowsServiceCommand) Help() string { + helpText := ` +Usage: nomad windows service [options] + + This command groups subcommands for managing Nomad as a system service on Windows. + + Install: + + $ nomad windows service install + + Uninstall: + + $ nomad windows service uninstall + + Refer to the individual subcommand help for detailed usage information. +` + return strings.TrimSpace(helpText) +} + +func (c *WindowsServiceCommand) Name() string { return "windows service" } + +func (c *WindowsServiceCommand) Synopsis() string { + return "Manage nomad as a system service on Windows" +} + +func (c *WindowsServiceCommand) Run(_ []string) int { return cli.RunResultHelp } diff --git a/command/windows_service_install.go b/command/windows_service_install.go new file mode 100644 index 000000000..612c5011b --- /dev/null +++ b/command/windows_service_install.go @@ -0,0 +1,365 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/nomad/helper/winsvc" + "github.com/posener/complete" +) + +type windowsInstallOpts struct { + configDir, dataDir, installDir, binaryPath string + reinstall bool +} + +type WindowsServiceInstallCommand struct { + Meta + serviceManagerFn func() (winsvc.WindowsServiceManager, error) + privilegedCheckFn func() bool + winPaths winsvc.WindowsPaths +} + +func (c *WindowsServiceInstallCommand) Synopsis() string { + return "Install the Nomad Windows system service" +} + +func (c *WindowsServiceInstallCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetDefault), + complete.Flags{ + "-config-dir": complete.PredictDirs("*"), + "-data-dir": complete.PredictDirs("*"), + "-install-dir": complete.PredictDirs("*"), + "-reinstall": complete.PredictNothing, + }) +} + +func (c *WindowsServiceInstallCommand) Name() string { return "windows service install" } + +func (c *WindowsServiceInstallCommand) Help() string { + helpText := ` +Usage: nomad windows service install [options] + + This command installs Nomad as a Windows system service. + +General Options: + +` + generalOptionsUsage(usageOptsDefault) + ` + +Service Install Options: + + -config-dir + Directory to hold the Nomad agent configuration. Defaults + to "{{.ProgramFiles}}\HashiCorp\nomad\bin" + + -data-dir + Directory to hold the Nomad agent state. Defaults + to "{{.ProgramData}}\HashiCorp\nomad\data" + + -install-dir + Directory to install the Nomad binary. Defaults + to "{{.ProgramData}}\HashiCorp\nomad\config" + + -reinstall + Allow the nomad Windows service to be stopped during install. +` + return strings.TrimSpace(helpText) +} + +func (c *WindowsServiceInstallCommand) Run(args []string) int { + opts := &windowsInstallOpts{} + + flags := c.Meta.FlagSet(c.Name(), FlagSetDefault) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.StringVar(&opts.configDir, "config-dir", "", "") + flags.StringVar(&opts.dataDir, "data-dir", "", "") + flags.StringVar(&opts.installDir, "install-dir", "", "") + flags.BoolVar(&opts.reinstall, "reinstall", false, "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + if args = flags.Args(); len(args) > 0 { + c.Ui.Error("This command takes no arguments") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Set helper functions to defaults if unset + if c.winPaths == nil { + c.winPaths = winsvc.NewWindowsPaths() + } + if c.serviceManagerFn == nil { + c.serviceManagerFn = winsvc.NewWindowsServiceManager + } + if c.privilegedCheckFn == nil { + c.privilegedCheckFn = winsvc.IsPrivilegedProcess + } + + // Check that command is being run with elevated permissions + if !c.privilegedCheckFn() { + c.Ui.Error("Service install must be run with Administator privileges") + return 1 + } + + c.Ui.Output("Installing nomad as a Windows service...") + + m, err := c.serviceManagerFn() + if err != nil { + c.Ui.Error(fmt.Sprintf("Could not connect to Windows service manager - %s", err)) + return 1 + } + defer m.Close() + + if err := c.performInstall(m, opts); err != nil { + c.Ui.Error(fmt.Sprintf("Service install failed: %s", err)) + return 1 + } + + c.Ui.Info("Successfully installed nomad Windows service") + return 0 +} + +func (c *WindowsServiceInstallCommand) performInstall(m winsvc.WindowsServiceManager, opts *windowsInstallOpts) error { + // Check if the nomad service has already been + // registered. If so the service needs to be + // stopped before proceeding with the install. + exists, err := m.IsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME) + if err != nil { + return fmt.Errorf("unable to check for existing service - %w", err) + } + + if exists { + nmdSvc, err := m.GetService(winsvc.WINDOWS_SERVICE_NAME) + if err != nil { + return fmt.Errorf("could not get existing service to stop - %w", err) + } + + // If the service is running and the reinstall + // option was not set, return an error + running, err := nmdSvc.IsRunning() + if err != nil { + return fmt.Errorf("unable to determine service state - %w", err) + } + if running && !opts.reinstall { + return fmt.Errorf("service is already running. Please run the\ncommand again with -reinstall to stop the service and install.") + } + + if running { + c.Ui.Output(" Stopping existing nomad service") + if err := nmdSvc.Stop(); err != nil { + return fmt.Errorf("unable to stop existing service - %w", err) + } + } + } + + // Install the nomad binary into the system + if err = c.binaryInstall(opts); err != nil { + return fmt.Errorf("binary install failed - %w", err) + } + + c.Ui.Output(fmt.Sprintf(" Nomad binary installed to: %s", opts.binaryPath)) + + // Create a configuration directory and add + // a basic configuration file if no configuration + // currently exists + if err = c.configInstall(opts); err != nil { + return fmt.Errorf("configuration install failed - %w", err) + } + + c.Ui.Output(fmt.Sprintf(" Nomad configuration directory: %s", opts.configDir)) + c.Ui.Output(fmt.Sprintf(" Nomad agent data directory: %s", opts.configDir)) + + // Now let's install that service + if err := c.serviceInstall(m, opts); err != nil { + return fmt.Errorf("service install failed - %w", err) + } + + return nil +} + +func (c *WindowsServiceInstallCommand) serviceInstall(m winsvc.WindowsServiceManager, opts *windowsInstallOpts) error { + var err error + var srvc winsvc.WindowsService + cmd := fmt.Sprintf("%s agent -config %s", opts.binaryPath, opts.configDir) + + exists, err := m.IsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME) + if err != nil { + return fmt.Errorf("service registration check failed - %w", err) + } + + // If the service already exists, open it and update. Otherwise + // create a new service. + if exists { + srvc, err = m.GetService(winsvc.WINDOWS_SERVICE_NAME) + if err != nil { + return fmt.Errorf("unable to get existing service - %w", err) + } + defer srvc.Close() + if err := srvc.Configure(winsvc.WindowsServiceConfiguration{ + StartType: winsvc.StartAutomatic, + DisplayName: winsvc.WINDOWS_SERVICE_DISPLAY_NAME, + Description: winsvc.WINDOWS_SERVICE_DESCRIPTION, + BinaryPathName: cmd, + }); err != nil { + return fmt.Errorf("unable to configure service - %w", err) + } + } else { + srvc, err = m.CreateService(winsvc.WINDOWS_SERVICE_NAME, opts.binaryPath, + winsvc.WindowsServiceConfiguration{ + StartType: winsvc.StartAutomatic, + DisplayName: winsvc.WINDOWS_SERVICE_DISPLAY_NAME, + Description: winsvc.WINDOWS_SERVICE_DESCRIPTION, + BinaryPathName: cmd, + }, + ) + if err != nil { + return fmt.Errorf("unable to create service - %w", err) + } + defer srvc.Close() + } + + // Enable the service in the Windows eventlog + if err := srvc.EnableEventlog(); err != nil { + return fmt.Errorf("could not configure eventlog - %w", err) + } + + // Ensure the service is stopped + if err := srvc.Stop(); err != nil { + return fmt.Errorf("could not stop service - %w", err) + } + + // Start the service so the new binary is in use + if err := srvc.Start(); err != nil { + return fmt.Errorf("could not start service - %w", err) + } + + return nil +} + +func (c *WindowsServiceInstallCommand) configInstall(opts *windowsInstallOpts) error { + // If the config or data directory are unset, default them + if opts.configDir == "" { + opts.configDir = filepath.Join(winsvc.WINDOWS_INSTALL_APPDATA_DIRECTORY, "config") + } + if opts.dataDir == "" { + opts.dataDir = filepath.Join(winsvc.WINDOWS_INSTALL_APPDATA_DIRECTORY, "data") + } + + var err error + if opts.configDir, err = c.winPaths.Expand(opts.configDir); err != nil { + return fmt.Errorf("cannot generate configuration path - %s", err) + } + if opts.dataDir, err = c.winPaths.Expand(opts.dataDir); err != nil { + return fmt.Errorf("cannot generate data path - %s", err) + } + + // Ensure directories exist + if err = c.winPaths.CreateDirectory(opts.configDir, true); err != nil { + return fmt.Errorf("cannot create configuration directory - %s", err) + } + + if err = c.winPaths.CreateDirectory(opts.dataDir, true); err != nil { + return fmt.Errorf("cannot create data directory - %s", err) + } + + // Check if any configuration files exist + matches, _ := filepath.Glob(filepath.Join(opts.configDir, "*")) + if len(matches) < 1 { + f, err := os.Create(filepath.Join(opts.configDir, "config.hcl")) + if err != nil { + return fmt.Errorf("could not create default configuration file - %s", err) + } + fmt.Fprintf(f, strings.TrimSpace(` +# Full configuration options can be found at https://developer.hashicorp.com/nomad/docs/configuration + +data_dir = "%s" +bind_addr = "0.0.0.0" + +server { + # license_path is required for Nomad Enterprise as of Nomad v1.1.1+ + #license_path = "%s\license.hclic" + enabled = true + bootstrap_expect = 1 +} + +client { + enabled = true + servers = ["127.0.0.1"] +} + +log_level = "WARN" +eventlog { + enabled = true + level = "ERROR" +} +`), strings.ReplaceAll(opts.dataDir, `\`, `\\`), strings.ReplaceAll(opts.configDir, `\`, `\\`)) + f.Close() + c.Ui.Output(fmt.Sprintf(" Added initial configuration file: %s", f.Name())) + } + + return nil +} + +func (c *WindowsServiceInstallCommand) binaryInstall(opts *windowsInstallOpts) error { + // Get the path to the currently executing nomad. This + // will be installed for the service to call. + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot detect current nomad path - %s", err) + } + + // Build the needed paths + if opts.installDir == "" { + opts.installDir = winsvc.WINDOWS_INSTALL_BIN_DIRECTORY + } + opts.installDir, err = c.winPaths.Expand(opts.installDir) + if err != nil { + return fmt.Errorf("cannot generate binary install path - %s", err) + } + opts.binaryPath = filepath.Join(opts.installDir, "nomad.exe") + + // Ensure the install directory exists + if err = c.winPaths.CreateDirectory(opts.installDir, false); err != nil { + return fmt.Errorf("could not create binary install directory - %s", err) + } + + // Create a new copy of the current binary to install + exeFile, err := os.Open(exePath) + if err != nil { + return fmt.Errorf("cannot open current nomad path for install - %s", err) + } + defer exeFile.Close() + + // Copy into a temporary file which can then be moved + // into the correct location. + dstFile, err := os.CreateTemp(os.TempDir(), "nomad*") + if err != nil { + return fmt.Errorf("cannot create copy - %s", err) + } + defer dstFile.Close() + + if _, err = io.Copy(dstFile, exeFile); err != nil { + return fmt.Errorf("cannot write nomad binary for install - %s", err) + } + dstFile.Close() + + // With a copy ready to be moved into place, ensure that + // the path is clear then move the file. + if err = os.RemoveAll(opts.binaryPath); err != nil { + return fmt.Errorf("cannot remove existing nomad binary install - %s", err) + } + + if err = os.Rename(dstFile.Name(), opts.binaryPath); err != nil { + return fmt.Errorf("cannot install new nomad binary - %s", err) + } + + return nil +} diff --git a/command/windows_service_install_test.go b/command/windows_service_install_test.go new file mode 100644 index 000000000..a1120be40 --- /dev/null +++ b/command/windows_service_install_test.go @@ -0,0 +1,391 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "text/template" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/helper/winsvc" + "github.com/shoenig/test/must" +) + +func TestWindowsServiceInstallCommand_Run(t *testing.T) { + t.Parallel() + + freshInstallFn := func(m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectCreateService(winsvc.WINDOWS_SERVICE_NAME, "nomad.exe", + winsvc.WindowsServiceConfiguration{}, srv, nil) + srv.ExpectEnableEventlog(nil) + srv.ExpectStop(nil) + srv.ExpectStart(nil) + } + upgradeInstallFn := func(m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectIsRunning(false, nil) + srv.ExpectConfigure(winsvc.WindowsServiceConfiguration{}, nil) + srv.ExpectEnableEventlog(nil) + srv.ExpectStop(nil) + srv.ExpectStart(nil) + } + + testCases := []struct { + desc string + args []string + privilegeFn func() bool + setup func(string, *winsvc.MockWindowsServiceManager) + after func(string) + output string + errOutput string + status int + }{ + { + desc: "fresh install success", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + freshInstallFn(m) + }, + output: "Success", + status: 0, + }, + { + desc: "fresh install writes config", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + freshInstallFn(m) + }, + after: func(dir string) { + must.FileExists(t, filepath.Join(dir, "programdata/HashiCorp/nomad/config/config.hcl")) + }, + output: "initial configuration file", + }, + { + desc: "fresh install binary file", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + freshInstallFn(m) + }, + after: func(dir string) { + must.FileExists(t, filepath.Join(dir, "programfiles/HashiCorp/nomad/bin/nomad.exe")) + }, + output: "binary installed", + }, + { + desc: "fresh install configuration already exists", + setup: func(dir string, m *winsvc.MockWindowsServiceManager) { + cdir := filepath.Join(dir, "programdata/HashiCorp/nomad/config") + err := os.MkdirAll(cdir, 0o755) + must.NoError(t, err) + f, err := os.Create(filepath.Join(cdir, "custom.hcl")) + must.NoError(t, err) + f.Close() + freshInstallFn(m) + }, + after: func(dir string) { + must.FileNotExists(t, filepath.Join(dir, "programdata/HashiCorp/nomad/config/config.hcl")) + }, + }, + { + desc: "fresh install binary already exists", + setup: func(dir string, m *winsvc.MockWindowsServiceManager) { + cdir := filepath.Join(dir, "programfiles/HashiCorp/nomad/bin") + err := os.MkdirAll(cdir, 0o755) + must.NoError(t, err) + // create empty binary file + f, err := os.Create(filepath.Join(cdir, "nomad.exe")) + must.NoError(t, err) + f.Close() + freshInstallFn(m) + }, + after: func(dir string) { + s, err := os.Stat(filepath.Join(dir, "programfiles/HashiCorp/nomad/bin/nomad.exe")) + must.NoError(t, err) + // ensure binary file is not empty + must.NonZero(t, s.Size()) + }, + }, + { + desc: "upgrade install success", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + upgradeInstallFn(m) + }, + output: "Success", + status: 0, + }, + { + desc: "with arguments", + args: []string{"any", "value"}, + errOutput: "takes no arguments", + status: 1, + }, + { + desc: "with -install-dir", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + freshInstallFn(m) + }, + args: []string{"-install-dir", "{{.ProgramFiles}}/custom/bin"}, + after: func(dir string) { + _, err := os.Stat(filepath.Join(dir, "programfiles/custom/bin/nomad.exe")) + must.NoError(t, err) + }, + }, + { + desc: "with -config-dir", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + freshInstallFn(m) + }, + args: []string{"-config-dir", "{{.ProgramData}}/custom/nomad-configuration"}, + after: func(dir string) { + _, err := os.Stat(filepath.Join(dir, "programdata/custom/nomad-configuration")) + must.NoError(t, err) + }, + }, + { + desc: "with -data-dir", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + freshInstallFn(m) + }, + args: []string{"-data-dir", "{{.ProgramData}}/custom/nomad-data"}, + after: func(dir string) { + _, err := os.Stat(filepath.Join(dir, "programdata/custom/nomad-data")) + must.NoError(t, err) + }, + }, + { + desc: "service registered check failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, errors.New("lookup failure")) + }, + errOutput: "unable to check for existing service", + status: 1, + }, + { + desc: "service registered check failure during service install", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, errors.New("lookup failure")) + }, + errOutput: "registration check failed", + status: 1, + }, + { + desc: "get existing service to stop failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, nil, errors.New("service get failure")) + }, + errOutput: "could not get existing service", + status: 1, + }, + { + desc: "stop existing service failure", + args: []string{"-reinstall"}, + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectIsRunning(true, nil) + srv.ExpectStop(errors.New("cannot stop")) + }, + errOutput: "unable to stop existing service", + status: 1, + }, + { + desc: "get existing service to configure failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectIsRunning(false, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, nil, errors.New("service get failure")) + }, + errOutput: "unable to get existing service", + status: 1, + }, + { + desc: "configure service failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + srv.ExpectIsRunning(false, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectConfigure(winsvc.WindowsServiceConfiguration{}, errors.New("configure failure")) + }, + errOutput: "unable to configure service", + status: 1, + }, + { + desc: "create service failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectCreateService(winsvc.WINDOWS_SERVICE_NAME, "nomad.exe", winsvc.WindowsServiceConfiguration{}, nil, errors.New("create service failure")) + }, + errOutput: "unable to create service", + status: 1, + }, + { + desc: "eventlog setup failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectCreateService(winsvc.WINDOWS_SERVICE_NAME, "nomad.exe", winsvc.WindowsServiceConfiguration{}, srv, nil) + srv.ExpectEnableEventlog(errors.New("eventlog configure failure")) + }, + errOutput: "could not configure eventlog", + status: 1, + }, + { + desc: "service stop pre-start failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectCreateService(winsvc.WINDOWS_SERVICE_NAME, "nomad.exe", winsvc.WindowsServiceConfiguration{}, srv, nil) + srv.ExpectEnableEventlog(nil) + srv.ExpectStop(errors.New("service stop failure")) + }, + errOutput: "could not stop service", + status: 1, + }, + { + desc: "service start failure", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + m.ExpectCreateService(winsvc.WINDOWS_SERVICE_NAME, "nomad.exe", winsvc.WindowsServiceConfiguration{}, srv, nil) + srv.ExpectEnableEventlog(nil) + srv.ExpectStop(nil) + srv.ExpectStart(errors.New("service start failure")) + }, + errOutput: "could not start service", + status: 1, + }, + { + desc: "upgrade without -reinstall and service running", + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectIsRunning(true, nil) + }, + errOutput: "again with -reinstall", + status: 1, + }, + { + desc: "upgrade with -reinstall and service running", + args: []string{"-reinstall"}, + setup: func(_ string, m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectIsRunning(true, nil) + srv.ExpectStop(nil) + srv.ExpectConfigure(winsvc.WindowsServiceConfiguration{}, nil) + srv.ExpectEnableEventlog(nil) + srv.ExpectStop(nil) + srv.ExpectStart(nil) + }, + }, + { + desc: "not running as administator", + privilegeFn: func() bool { return false }, + errOutput: "must be run with Administator privileges", + status: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + testDir := t.TempDir() + + ui := cli.NewMockUi() + mgr := winsvc.NewMockWindowsServiceManager(t) + if tc.setup != nil { + tc.setup(testDir, mgr) + } + + pfn := tc.privilegeFn + if pfn == nil { + pfn = func() bool { return true } + } + + cmd := &WindowsServiceInstallCommand{ + Meta: Meta{Ui: ui}, + serviceManagerFn: func() (winsvc.WindowsServiceManager, error) { + return mgr, nil + }, + winPaths: createWinPaths(testDir), + privilegedCheckFn: pfn, + } + result := cmd.Run(tc.args) + + out := ui.OutputWriter.String() + outErr := ui.ErrorWriter.String() + must.Eq(t, result, tc.status) + if tc.output != "" { + must.StrContains(t, out, tc.output) + } + if tc.errOutput != "" { + must.StrContains(t, outErr, tc.errOutput) + } + if tc.after != nil { + tc.after(testDir) + } + + mgr.AssertExpectations() + }) + } +} + +func createWinPaths(rootDir string) winsvc.WindowsPaths { + return &testWindowsPaths{ + SystemRoot: filepath.Join(rootDir, "systemroot"), + SystemDrive: rootDir, + ProgramData: filepath.Join(rootDir, "programdata"), + ProgramFiles: filepath.Join(rootDir, "programfiles"), + } +} + +type testWindowsPaths struct { + SystemRoot string + SystemDrive string + ProgramData string + ProgramFiles string +} + +func (t *testWindowsPaths) Expand(path string) (string, error) { + tmpl, err := template.New("expansion").Option("missingkey=error").Parse(path) + if err != nil { + return "", err + } + result := new(bytes.Buffer) + if err := tmpl.Execute(result, t); err != nil { + return "", err + } + + return strings.ReplaceAll(result.String(), `\`, "/"), nil +} + +func (t *testWindowsPaths) CreateDirectory(path string, _ bool) error { + return os.MkdirAll(path, 0o755) +} diff --git a/command/windows_service_uninstall.go b/command/windows_service_uninstall.go new file mode 100644 index 000000000..a0573cdf4 --- /dev/null +++ b/command/windows_service_uninstall.go @@ -0,0 +1,121 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/helper/winsvc" + "github.com/posener/complete" +) + +type WindowsServiceUninstallCommand struct { + Meta + serviceManagerFn func() (winsvc.WindowsServiceManager, error) + privilegedCheckFn func() bool +} + +func (c *WindowsServiceUninstallCommand) AutoCompleteFlags() complete.Flags { + return c.Meta.AutocompleteFlags(FlagSetDefault) +} + +func (c *WindowsServiceUninstallCommand) Synopsis() string { + return "Uninstall the nomad Windows system service" +} + +func (c *WindowsServiceUninstallCommand) Name() string { return "windows service uninstall" } + +func (c *WindowsServiceUninstallCommand) Help() string { + helpText := ` +Usage: nomad windows service uninstall [options] + + This command uninstalls nomad as a Windows system service. + +General Options: + +` + generalOptionsUsage(usageOptsDefault) + return strings.TrimSpace(helpText) +} + +func (c *WindowsServiceUninstallCommand) Run(args []string) int { + flags := c.Meta.FlagSet(c.Name(), FlagSetDefault) + flags.Usage = func() { c.Ui.Output(c.Help()) } + + if err := flags.Parse(args); err != nil { + return 1 + } + + if args = flags.Args(); len(args) > 0 { + c.Ui.Error("This command takes no arguments") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Set helper functions to default if unset + if c.serviceManagerFn == nil { + c.serviceManagerFn = winsvc.NewWindowsServiceManager + } + if c.privilegedCheckFn == nil { + c.privilegedCheckFn = winsvc.IsPrivilegedProcess + } + + // Check that command is being run with elevated permissions + if !c.privilegedCheckFn() { + c.Ui.Error("Service uninstall must be run with Administator privileges") + return 1 + } + + c.Ui.Output("Uninstalling nomad Windows service...") + + m, err := c.serviceManagerFn() + if err != nil { + c.Ui.Error(fmt.Sprintf("Could not connect to Windows service manager - %s", err)) + return 1 + } + defer m.Close() + + if err := c.performUninstall(m); err != nil { + c.Ui.Error(fmt.Sprintf("Service uninstall failed: %s", err)) + return 1 + } + + c.Ui.Info("Successfully uninstalled nomad Windows service") + return 0 +} + +func (c *WindowsServiceUninstallCommand) performUninstall(m winsvc.WindowsServiceManager) error { + // Check that the nomad service is currently registered + exists, err := m.IsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME) + if err != nil { + return fmt.Errorf("unable to check for existing service - %w", err) + } + + if !exists { + return nil + } + + // Grab the service and ensure the service is stopped + srvc, err := m.GetService(winsvc.WINDOWS_SERVICE_NAME) + if err != nil { + return fmt.Errorf("could not get existing service - %w", err) + } + defer srvc.Close() + + if err := srvc.Stop(); err != nil { + return fmt.Errorf("unable to stop service - %w", err) + } + + // Remove the service from the event log + if err := srvc.DisableEventlog(); err != nil { + return fmt.Errorf("could not remove eventlog configuration - %w", err) + } + + // Finally, delete the service + if err := srvc.Delete(); err != nil { + return fmt.Errorf("could not delete service - %w", err) + } + + return nil +} diff --git a/command/windows_service_uninstall_test.go b/command/windows_service_uninstall_test.go new file mode 100644 index 000000000..f927982b6 --- /dev/null +++ b/command/windows_service_uninstall_test.go @@ -0,0 +1,146 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "errors" + "testing" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/helper/winsvc" + "github.com/shoenig/test/must" +) + +func TestWindowsServiceUninstallCommand_Run(t *testing.T) { + testCases := []struct { + desc string + args []string + privilegeFn func() bool + setup func(*winsvc.MockWindowsServiceManager) + output string + errOutput string + status int + }{ + { + desc: "service installed", + setup: func(m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectStop(nil) + srv.ExpectDisableEventlog(nil) + srv.ExpectDelete(nil) + }, + output: "uninstalled nomad", + }, + { + desc: "service not installed", + setup: func(m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, nil) + }, + output: "uninstalled nomad", + }, + { + desc: "service registration check failure", + setup: func(m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, false, errors.New("registered check failure")) + }, + errOutput: "unable to check for existing service", + status: 1, + }, + { + desc: "get service failure", + setup: func(m *winsvc.MockWindowsServiceManager) { + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, nil, errors.New("get service failure")) + }, + errOutput: "could not get existing service", + status: 1, + }, + { + desc: "service stop failure", + setup: func(m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectStop(errors.New("service stop failure")) + }, + errOutput: "unable to stop service", + status: 1, + }, + { + desc: "disable eventlog failure", + setup: func(m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectStop(nil) + srv.ExpectDisableEventlog(errors.New("disable eventlog failure")) + }, + errOutput: "could not remove eventlog configuration", + status: 1, + }, + { + desc: "delete service failure", + setup: func(m *winsvc.MockWindowsServiceManager) { + srv := m.NewMockWindowsService() + m.ExpectIsServiceRegistered(winsvc.WINDOWS_SERVICE_NAME, true, nil) + m.ExpectGetService(winsvc.WINDOWS_SERVICE_NAME, srv, nil) + srv.ExpectStop(nil) + srv.ExpectDisableEventlog(nil) + srv.ExpectDelete(errors.New("service delete failure")) + }, + errOutput: "could not delete service", + status: 1, + }, + { + desc: "with arguments", + args: []string{"any", "value"}, + errOutput: "command takes no arguments", + status: 1, + }, + { + desc: "not running as administator", + privilegeFn: func() bool { return false }, + errOutput: "must be run with Administator privileges", + status: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ui := cli.NewMockUi() + mgr := winsvc.NewMockWindowsServiceManager(t) + if tc.setup != nil { + tc.setup(mgr) + } + + pfn := tc.privilegeFn + if pfn == nil { + pfn = func() bool { return true } + } + + cmd := &WindowsServiceUninstallCommand{ + Meta: Meta{Ui: ui}, + serviceManagerFn: func() (winsvc.WindowsServiceManager, error) { + return mgr, nil + }, + privilegedCheckFn: pfn, + } + result := cmd.Run(tc.args) + + out := ui.OutputWriter.String() + outErr := ui.ErrorWriter.String() + must.Eq(t, result, tc.status) + if tc.output != "" { + must.StrContains(t, out, tc.output) + } + if tc.errOutput != "" { + must.StrContains(t, outErr, tc.errOutput) + } + + mgr.AssertExpectations() + }) + } +} diff --git a/helper/winsvc/mock_windows_service.go b/helper/winsvc/mock_windows_service.go new file mode 100644 index 000000000..74f046a88 --- /dev/null +++ b/helper/winsvc/mock_windows_service.go @@ -0,0 +1,320 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package winsvc + +import ( + "reflect" + "testing" + + "github.com/shoenig/test/must" +) + +func NewMockWindowsServiceManager(t *testing.T) *MockWindowsServiceManager { + t.Helper() + + m := &MockWindowsServiceManager{t: t} + return m +} + +func NewMockWindowsService(t *testing.T) *MockWindowsService { + t.Helper() + + m := &MockWindowsService{t: t} + return m +} + +type MockWindowsServiceManager struct { + services []*MockWindowsService + isServiceRegistereds []isServiceRegistered + getServices []getService + createServices []createService + t *testing.T +} + +type isServiceRegistered struct { + name string + result bool + err error +} + +type getService struct { + name string + result WindowsService + err error +} + +type createService struct { + name, binaryPath string + config WindowsServiceConfiguration + result WindowsService + err error +} + +func (m *MockWindowsServiceManager) NewMockWindowsService() *MockWindowsService { + w := NewMockWindowsService(m.t) + m.services = append(m.services, w) + return w +} + +func (m *MockWindowsServiceManager) ExpectIsServiceRegistered(name string, result bool, err error) { + m.isServiceRegistereds = append(m.isServiceRegistereds, isServiceRegistered{name, result, err}) +} + +func (m *MockWindowsServiceManager) ExpectGetService(name string, result WindowsService, err error) { + m.getServices = append(m.getServices, getService{name, result, err}) +} + +func (m *MockWindowsServiceManager) ExpectCreateService(name, binaryPath string, config WindowsServiceConfiguration, result WindowsService, err error) { + m.createServices = append(m.createServices, createService{name, binaryPath, config, result, err}) +} + +func (m *MockWindowsServiceManager) IsServiceRegistered(name string) (bool, error) { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.isServiceRegistereds, + must.Sprint("Unexpected call to IsServiceRegistered")) + call := m.isServiceRegistereds[0] + m.isServiceRegistereds = m.isServiceRegistereds[1:] + must.Eq(m.t, call.name, name, + must.Sprint("IsServiceRegistered received incorrect argument")) + + return call.result, call.err +} + +func (m *MockWindowsServiceManager) GetService(name string) (WindowsService, error) { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.getServices, + must.Sprint("Unexpected call to GetService")) + call := m.getServices[0] + m.getServices = m.getServices[1:] + must.Eq(m.t, call.name, name, + must.Sprint("GetService received incorrect argument")) + + return call.result, call.err +} + +func (m *MockWindowsServiceManager) CreateService(name, binaryPath string, config WindowsServiceConfiguration) (WindowsService, error) { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.createServices, + must.Sprint("Unexpected call to CreateService")) + call := m.createServices[0] + m.createServices = m.createServices[1:] + must.Eq(m.t, call.name, name, + must.Sprint("CreateService received incorrect argument")) + must.StrContains(m.t, binaryPath, call.binaryPath, + must.Sprint("CreateService received incorrect argument")) + + if !reflect.ValueOf(call.config).IsZero() { + must.Eq(m.t, call.config, config, + must.Sprint("CreateService received incorrect argument")) + } + + return call.result, call.err +} + +func (m *MockWindowsServiceManager) Close() error { return nil } + +func (m *MockWindowsServiceManager) AssertExpectations() { + m.t.Helper() + must.SliceEmpty(m.t, m.isServiceRegistereds, + must.Sprintf("IsServiceRegistered expecting %d more invocations", len(m.isServiceRegistereds))) + must.SliceEmpty(m.t, m.getServices, + must.Sprintf("GetService expecting %d more invocations", len(m.getServices))) + must.SliceEmpty(m.t, m.createServices, + must.Sprintf("CreateService expecting %d more invocations", len(m.createServices))) + + for _, srv := range m.services { + srv.AssertExpectations() + } +} + +type MockWindowsService struct { + names []string + configures []configure + starts []error + stops []error + deletes []error + isRunnings []iscall + isStoppeds []iscall + enableEventlogs []error + disableEventlogs []error + + t *testing.T +} + +type configure struct { + config WindowsServiceConfiguration + err error +} + +type iscall struct { + result bool + err error +} + +func (m *MockWindowsService) ExpectName(result string) { + m.names = append(m.names, result) +} + +func (m *MockWindowsService) ExpectConfigure(config WindowsServiceConfiguration, err error) { + m.configures = append(m.configures, configure{config, err}) +} + +func (m *MockWindowsService) ExpectStart(err error) { + m.starts = append(m.starts, err) +} + +func (m *MockWindowsService) ExpectStop(err error) { + m.stops = append(m.stops, err) +} + +func (m *MockWindowsService) ExpectDelete(err error) { + m.deletes = append(m.deletes, err) +} + +func (m *MockWindowsService) ExpectIsRunning(result bool, err error) { + m.isRunnings = append(m.isRunnings, iscall{result, err}) +} + +func (m *MockWindowsService) ExpectIsStopped(result bool, err error) { + m.isStoppeds = append(m.isStoppeds, iscall{result, err}) +} + +func (m *MockWindowsService) ExpectEnableEventlog(err error) { + m.enableEventlogs = append(m.enableEventlogs, err) +} + +func (m *MockWindowsService) ExpectDisableEventlog(err error) { + m.disableEventlogs = append(m.disableEventlogs, err) +} + +func (m *MockWindowsService) Name() string { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.names, + must.Sprint("Unexpected call to Name")) + name := m.names[0] + m.names = m.names[1:] + + return name +} + +func (m *MockWindowsService) Configure(config WindowsServiceConfiguration) error { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.configures, + must.Sprint("Unexpected call to Configure")) + call := m.configures[0] + m.configures = m.configures[1:] + if !reflect.ValueOf(call.config).IsZero() { + must.Eq(m.t, call.config, config, + must.Sprint("Configure received incorrect argument")) + } + + return call.err +} + +func (m *MockWindowsService) Start() error { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.starts, + must.Sprint("Unexpected call to Start")) + err := m.starts[0] + m.starts = m.starts[1:] + + return err +} + +func (m *MockWindowsService) Stop() error { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.stops, + must.Sprint("Unexpected call to Stop")) + err := m.stops[0] + m.stops = m.stops[1:] + + return err +} + +func (m *MockWindowsService) Delete() error { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.deletes, + must.Sprint("Unexpected call to Delete")) + err := m.deletes[0] + m.deletes = m.deletes[1:] + + return err +} + +func (m *MockWindowsService) IsRunning() (bool, error) { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.isRunnings, + must.Sprint("Unexpected call to IsRunning")) + call := m.isRunnings[0] + m.isRunnings = m.isRunnings[1:] + + return call.result, call.err +} + +func (m *MockWindowsService) IsStopped() (bool, error) { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.isStoppeds, + must.Sprint("Unexpected call to IsStopped")) + call := m.isStoppeds[0] + m.isStoppeds = m.isStoppeds[1:] + + return call.result, call.err +} + +func (m *MockWindowsService) EnableEventlog() error { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.enableEventlogs, + must.Sprint("Unexpected call to EnableEventlog")) + err := m.enableEventlogs[0] + m.enableEventlogs = m.enableEventlogs[1:] + + return err +} + +func (m *MockWindowsService) DisableEventlog() error { + m.t.Helper() + + must.SliceNotEmpty(m.t, m.disableEventlogs, + must.Sprint("Unexpected call to DisableEventlog")) + err := m.disableEventlogs[0] + m.disableEventlogs = m.disableEventlogs[1:] + + return err +} + +func (m *MockWindowsService) Close() error { return nil } + +func (m *MockWindowsService) AssertExpectations() { + m.t.Helper() + + must.SliceEmpty(m.t, m.names, + must.Sprintf("Name expecting %d more invocations", len(m.names))) + must.SliceEmpty(m.t, m.configures, + must.Sprintf("Configure expecting %d more invocations", len(m.configures))) + must.SliceEmpty(m.t, m.starts, + must.Sprintf("Start expecting %d more invocations", len(m.starts))) + must.SliceEmpty(m.t, m.stops, + must.Sprintf("Stop expecting %d more invocations", len(m.stops))) + must.SliceEmpty(m.t, m.deletes, + must.Sprintf("Delete expecting %d more invocations", len(m.deletes))) + must.SliceEmpty(m.t, m.isRunnings, + must.Sprintf("IsRunning expecting %d more invocations", len(m.isRunnings))) + must.SliceEmpty(m.t, m.isStoppeds, + must.Sprintf("IsStopped expecting %d more invocations", len(m.isStoppeds))) + must.SliceEmpty(m.t, m.enableEventlogs, + must.Sprintf("EnableEventlog expecting %d more invocations", len(m.enableEventlogs))) + must.SliceEmpty(m.t, m.disableEventlogs, + must.Sprintf("DisableEventlog expecting %d more invocations", len(m.disableEventlogs))) +} diff --git a/website/content/commands/agent.mdx b/website/content/commands/agent.mdx index 94f6b5fd7..ebc840ab1 100644 --- a/website/content/commands/agent.mdx +++ b/website/content/commands/agent.mdx @@ -129,6 +129,10 @@ You may, however, may pass the following configuration options as CLI arguments: - `-encrypt`: Set the Serf encryption key. See the [Encryption Overview][] for more details. +- `-eventlog`: Equivalent to the [eventlog.enabled][] config option. + +- `-eventlog-level`: Equivalent to the [eventlog.level][] config option. + - `-join=
`: Address of another agent to join upon starting up. This can be specified multiple times to specify multiple agents to join. @@ -231,6 +235,8 @@ You may, however, may pass the following configuration options as CLI arguments: [datacenter]: /nomad/docs/configuration#datacenter [enabled]: /nomad/docs/configuration/acl#enabled [encryption overview]: /nomad/docs/secure/traffic/gossip-encryption +[eventlog.enabled]: /nomad/docs/configuration#eventlog_enabled +[eventlog.level]: /nomad/docs/configuration#eventlog_level [key_file]: /nomad/docs/configuration/consul#key_file [log_include_location]: /nomad/docs/configuration#log_include_location [log_json]: /nomad/docs/configuration#log_json diff --git a/website/content/commands/windows/index.mdx b/website/content/commands/windows/index.mdx new file mode 100644 index 000000000..ea165c3f3 --- /dev/null +++ b/website/content/commands/windows/index.mdx @@ -0,0 +1,22 @@ +--- +layout: docs +page_title: 'nomad windows command reference' +description: | + The `nomad windows` command interacts with the Windows host platform. Install or uninstall Nomad as a Windows service. +--- + +# `nomad windows` command reference + +Use the `windows` command to interact with Windows host platforms. + +## Usage + +Usage: `nomad windows [options]` + +Run `nomad windows -h` for help on that subcommand. The following subcommands are available: + +- [`windows service install`][install] - Install Nomad as a Windows service +- [`windows service uninstall`][uninstall] - Uninstall Nomad as a Windows service + +[install]: /nomad/commands/windows/service-install +[uninstall]: /nomad/commands/windows/service-uninstall diff --git a/website/content/commands/windows/service-install.mdx b/website/content/commands/windows/service-install.mdx new file mode 100644 index 000000000..d10904bac --- /dev/null +++ b/website/content/commands/windows/service-install.mdx @@ -0,0 +1,50 @@ +--- +layout: docs +page_title: 'nomad windows service install command reference' +description: | + The `nomad windows service install` command installs the Nomad binary + and creates a Windows service. +--- + +# `nomad windows service install` command reference + +The `windows service install` command installs the Nomad binary and +creates a Windows service. + +## Usage + +```plaintext +nomad windows service install +``` + +The `windows service install` command installs the `nomad` binary used to +run this command, creates a data and configuration directory, writes a basic +Nomad configuration file, creates a Windows service to run Nomad, and +registers the service with Windows Event Log. + +If Nomad has been previously installed using this command, subsequent +executions will do the following: + +1. Stop the service if it is running +1. Install the currently executing nomad binary +1. Ensure data and configuration directories exist +1. Write a configuration file if no configuration files are found +1. Update the service if needed +1. Update the Event Log configuration if needed. + +## Options + +- `-config-dir `: Directory to hold the Nomad agent configuration. + Defaults to "{{.ProgramFiles}}\HashiCorp\nomad\bin" + +- `-data-dir `: Directory to hold the Nomad agent state. Defaults + to "{{.ProgramData}}\HashiCorp\nomad\data" + +- `-install-dir `: Directory to install the Nomad binary. Defaults + to "{{.ProgramData}}\HashiCorp\nomad\config" + +- `-reinstall`: Allow the nomad Windows service to be stopped during install. + +## General options + +@include 'general_options.mdx' diff --git a/website/content/commands/windows/service-uninstall.mdx b/website/content/commands/windows/service-uninstall.mdx new file mode 100644 index 000000000..f2f560f65 --- /dev/null +++ b/website/content/commands/windows/service-uninstall.mdx @@ -0,0 +1,22 @@ +--- +layout: docs +page_title: 'nomad windows service uninstall command reference' +description: | + The `nomad windows service uninstall` command removes the Nomad + Windows service. +--- + +# `nomad windows service uninstall` command reference + +The `windows service uninstall` command removes the Nomad Windows service. + +## Usage + +```plaintext +nomad windows service uninstall +``` + +The `windows service uninstall` command stops the Nomad service if +it is currently running, deregisters the service with the Windows Event Log, +and removes the Windows service. This command does not remove the installed +Nomad binary or the data and configuration directories. diff --git a/website/content/docs/configuration/index.mdx b/website/content/docs/configuration/index.mdx index fce0cb238..5842eb6f7 100644 --- a/website/content/docs/configuration/index.mdx +++ b/website/content/docs/configuration/index.mdx @@ -176,6 +176,16 @@ testing. This option only works on Unix based systems. The log level inherits from the Nomad agent log set in `log_level` +- `eventlog` - This is a nested object that configures the behavior with + with Windows Event Log. The following parameters are available: + + - `enabled` - Enable sending Nomad agent logs to the Windows Event Log. + + - `level` - `(string: "ERROR")` - Specifies the verbosity of logs the Nomad + agent outputs. Valid log levels include `ERROR`, `WARN`, or `INFO` in + increasing order of verbosity. Level must be of equal or less verbosity as + defined for the [`log_level`](#log_level) parameter. + - `http_api_response_headers` `(map: nil)` - Specifies user-defined headers to add to the HTTP API responses. diff --git a/website/content/docs/deploy/production/windows-service.mdx b/website/content/docs/deploy/production/windows-service.mdx index 1b8fa6eac..a56c7ab56 100644 --- a/website/content/docs/deploy/production/windows-service.mdx +++ b/website/content/docs/deploy/production/windows-service.mdx @@ -7,9 +7,12 @@ description: |- # Install Nomad as a Windows service -Nomad can be run as a native Windows service. In order to do this, you will need -to register the Nomad application with the Windows Service Control Manager using -[`sc.exe`], configure Nomad to log to a file, and then start the Nomad service. +You may run Nomad as a native Windows service. Use the [windows service install][] +command to install Nomad and create the Windows service. + +You may also set up the Nomad Windows service manually. Use [`sc.exe`] to register +the Nomad application with the Windows Service Control Manager, configure Nomad to +log to a file, and then start the Nomad service. ~> **Note:** These steps should be run in a PowerShell session with Administrator capabilities. @@ -23,7 +26,7 @@ argument should include the fully qualified path to the Nomad executable and any arguments to the nomad command: agent, -config, etc. ```plaintext -sc.exe create "Nomad" binPath="«full path to nomad.exe» agent -config=«path to config file or directory»" start= auto +sc.exe create "Nomad" binPath="«full path to nomad.exe» agent -config=«path to config file or directory»" start=auto [SC] CreateService SUCCESS ``` @@ -93,3 +96,4 @@ restart of Nomad service is not sufficient. [`sc.exe`]: https://msdn.microsoft.com/en-us/library/windows/desktop/ms682107(v=vs.85).aspx [download]: /nomad/downloads [logging]: /nomad/docs/configuration#log_file +[windows service install]: /nomad/docs/commands/windows/service-install diff --git a/website/data/commands-nav-data.json b/website/data/commands-nav-data.json index d0c37d599..6e9b0a794 100644 --- a/website/data/commands-nav-data.json +++ b/website/data/commands-nav-data.json @@ -1053,5 +1053,22 @@ "path": "volume/status" } ] + }, + { + "title": "windows", + "routes": [ + { + "title": "Overview", + "path": "windows" + }, + { + "title": "service install", + "path": "windows/service-install" + }, + { + "title": "service uninstall", + "path": "windows/service-uninstall" + } + ] } ]