[cli] Add windows service commands (#26442)

Adds a new `windows` command which is available when running on
a Windows hosts. The command includes two new subcommands:

* `service install`
* `service uninstall`

The `service install` command will install the called binary into
the Windows program files directory, create a new Windows service,
setup configuration and data directories, and register the service
with the Window eventlog. If the service and/or binary already
exist, the service will be stopped, service and eventlog updated
if needed, binary replaced, and the service started again.

The `service uninstall` command will stop the service, remove the
Windows service, and deregister the service with the eventlog. It
will not remove the configuration/data directory nor will it remove
the installed binary.
This commit is contained in:
Chris Roberts
2025-08-25 10:07:24 -07:00
parent 61c36bdef7
commit c3dcdb5413
17 changed files with 1587 additions and 4 deletions

View File

@@ -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{

35
command/windows.go Normal file
View File

@@ -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 <subcommand> [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 }

View File

@@ -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 <subcommand> [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 }

View File

@@ -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 <dir>
Directory to hold the Nomad agent configuration. Defaults
to "{{.ProgramFiles}}\HashiCorp\nomad\bin"
-data-dir <dir>
Directory to hold the Nomad agent state. Defaults
to "{{.ProgramData}}\HashiCorp\nomad\data"
-install-dir <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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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()
})
}
}