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/.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/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/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/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/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/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..90262dfd1 100644 --- a/helper/winsvc/service.go +++ b/helper/winsvc/service.go @@ -3,10 +3,22 @@ package winsvc -var chanGraceExit = make(chan int) +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 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]] +} 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 +} 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" + } + ] } ]