mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
[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:
3
.changelog/26441.txt
Normal file
3
.changelog/26441.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
agent: Allow agent logging to the Windows Event Log
|
||||
```
|
||||
3
.changelog/26442.txt
Normal file
3
.changelog/26442.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
cli: Add commands for installing and uninstalling Windows system service
|
||||
```
|
||||
@@ -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
35
command/windows.go
Normal 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 }
|
||||
41
command/windows_service.go
Normal file
41
command/windows_service.go
Normal 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 }
|
||||
365
command/windows_service_install.go
Normal file
365
command/windows_service_install.go
Normal 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
|
||||
}
|
||||
391
command/windows_service_install_test.go
Normal file
391
command/windows_service_install_test.go
Normal 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)
|
||||
}
|
||||
121
command/windows_service_uninstall.go
Normal file
121
command/windows_service_uninstall.go
Normal 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
|
||||
}
|
||||
146
command/windows_service_uninstall_test.go
Normal file
146
command/windows_service_uninstall_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
320
helper/winsvc/mock_windows_service.go
Normal file
320
helper/winsvc/mock_windows_service.go
Normal file
@@ -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)))
|
||||
}
|
||||
@@ -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>`: 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
|
||||
|
||||
22
website/content/commands/windows/index.mdx
Normal file
22
website/content/commands/windows/index.mdx
Normal file
@@ -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 <subcommand> [options]`
|
||||
|
||||
Run `nomad windows <subcommand> -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
|
||||
50
website/content/commands/windows/service-install.mdx
Normal file
50
website/content/commands/windows/service-install.mdx
Normal file
@@ -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 <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.
|
||||
|
||||
## General options
|
||||
|
||||
@include 'general_options.mdx'
|
||||
22
website/content/commands/windows/service-uninstall.mdx
Normal file
22
website/content/commands/windows/service-uninstall.mdx
Normal file
@@ -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.
|
||||
@@ -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<string|string>: nil)` - Specifies
|
||||
user-defined headers to add to the HTTP API responses.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user