Skip to content

Commit 82efe13

Browse files
[uninstall] ensure service is stopped on windows (#4224)
* [uninstall] ensure service is stopped on windows the kardianos service manager doesn't distinguish between 'Stopped' and 'StopPending' on Windows, so we need to query to make sure the service is really stopped. Otherwise we can try to remove the files while the service is still running. * Fixing linter issue + missing arg * Update wait_service_windows.go * incorporate feedback and add integration test * add changelog fragment --------- Co-authored-by: Pierre HILBERT <pierre.hilbert@elastic.co>
1 parent 4c9d865 commit 82efe13

File tree

5 files changed

+186
-2
lines changed

5 files changed

+186
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Kind can be one of:
2+
# - breaking-change: a change to previously-documented behavior
3+
# - deprecation: functionality that is being removed in a later release
4+
# - bug-fix: fixes a problem in a previous version
5+
# - enhancement: extends functionality but does not break or fix existing behavior
6+
# - feature: new functionality
7+
# - known-issue: problems that we are aware of in a given version
8+
# - security: impacts on the security of a product or a user’s deployment.
9+
# - upgrade: important information for someone upgrading from a prior version
10+
# - other: does not fit into any of the other categories
11+
kind: bug-fix
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: On Windows make sure the service is stopped before uninstalling.
15+
16+
# Long description; in case the summary is not enough to describe the change
17+
# this field accommodate a description without length limits.
18+
# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
19+
#description:
20+
21+
# Affected component; a word indicating the component this changeset affects.
22+
component: elastic-agent
23+
24+
# PR URL; optional; the PR number that added the changeset.
25+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
26+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
27+
# Please provide it if you are adding a fragment for a different PR.
28+
#pr: https://github.com/owner/repo/1234
29+
30+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
31+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
32+
#issue: https://github.com/owner/repo/1234

internal/pkg/agent/install/uninstall.go

+14-2
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,25 @@ func Uninstall(cfgFile, topPath, uninstallToken string, log *logp.Logger, pt *pr
5454
if status == service.StatusRunning {
5555
err := svc.Stop()
5656
if err != nil {
57-
pt.Describe("Failed to stop service")
57+
pt.Describe("Failed to issue stop service")
5858
return aerrors.New(
5959
err,
60-
fmt.Sprintf("failed to stop service (%s)", paths.ServiceName),
60+
fmt.Sprintf("failed to issue stop service (%s)", paths.ServiceName),
6161
aerrors.M("service", paths.ServiceName))
6262
}
6363
}
64+
// The kardianos service manager can't tell the difference
65+
// between 'Stopped' and 'StopPending' on Windows, so make
66+
// sure the service is stopped.
67+
err = isStopped(30*time.Second, 250*time.Millisecond, paths.ServiceName)
68+
if err != nil {
69+
pt.Describe("Failed to complete stop of service")
70+
return aerrors.New(
71+
err,
72+
fmt.Sprintf("failed to complete stop service (%s)", paths.ServiceName),
73+
aerrors.M("service", paths.ServiceName))
74+
}
75+
6476
pt.Describe("Successfully stopped service")
6577

6678
// kill any running watcher
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
//go:build !windows
6+
7+
package install
8+
9+
import (
10+
"time"
11+
)
12+
13+
// isStopped waits until the service has stopped. On non Windows
14+
// systems this isn't necessary so just return.
15+
func isStopped(timeout time.Duration, interval time.Duration, service string) error {
16+
return nil
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License;
3+
// you may not use this file except in compliance with the Elastic License.
4+
5+
//go:build windows
6+
7+
package install
8+
9+
import (
10+
"fmt"
11+
"time"
12+
13+
"golang.org/x/sys/windows/svc"
14+
"golang.org/x/sys/windows/svc/mgr"
15+
)
16+
17+
// isStopped queries the Windows service manager to see if the state
18+
// of the service is stopped. It will repeat the query every
19+
// 'interval' until the 'timeout' is reached. It returns nil if the
20+
// system is stopped within the timeout period. An error is returned
21+
// if the service doesn't stop before the timeout or if there are
22+
// errors communicating with the service manager.
23+
func isStopped(timeout time.Duration, interval time.Duration, service string) error {
24+
var err error
25+
var status svc.Status
26+
27+
m, err := mgr.Connect()
28+
if err != nil {
29+
return fmt.Errorf("failed to connect to service manager: %w", err)
30+
}
31+
defer func() {
32+
_ = m.Disconnect()
33+
}()
34+
35+
s, err := m.OpenService(service)
36+
if err != nil {
37+
return fmt.Errorf("failed to open service (%s): %w", service, err)
38+
}
39+
defer s.Close()
40+
41+
ticker := time.NewTicker(interval)
42+
defer ticker.Stop()
43+
timer := time.NewTimer(timeout)
44+
defer timer.Stop()
45+
46+
for {
47+
select {
48+
case <-ticker.C:
49+
status, err = s.Query()
50+
if err != nil {
51+
return fmt.Errorf("error querying service (%s): %w", service, err)
52+
}
53+
if status.State == svc.Stopped {
54+
return nil
55+
}
56+
case <-timer.C:
57+
return fmt.Errorf("timed out after %s waiting for service (%s) to stop, last state was: %d", timeout, service, status.State)
58+
}
59+
}
60+
}

testing/integration/install_test.go

+63
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package integration
88

99
import (
1010
"context"
11+
"fmt"
1112
"math/rand"
1213
"os"
1314
"os/exec"
@@ -167,6 +168,68 @@ func TestInstallWithBasePath(t *testing.T) {
167168
}
168169
}
169170

171+
// TestRepeatedInstallUninstall will install then uninstall the agent
172+
// repeatedly. This test exists because of a number of race
173+
// conditions that have occurred in the uninstall process. Current
174+
// testing shows each iteration takes around 16 seconds.
175+
func TestRepeatedInstallUninstall(t *testing.T) {
176+
define.Require(t, define.Requirements{
177+
Group: Default,
178+
// We require sudo for this test to run
179+
// `elastic-agent install` (even though it will
180+
// be installed as non-root).
181+
Sudo: true,
182+
183+
// It's not safe to run this test locally as it
184+
// installs Elastic Agent.
185+
Local: false,
186+
})
187+
188+
maxRunTime := 2 * time.Minute
189+
iterations := 100
190+
for i := 0; i < iterations; i++ {
191+
t.Run(fmt.Sprintf("%s-%d", t.Name(), i), func(t *testing.T) {
192+
193+
var defaultBasePath string
194+
switch runtime.GOOS {
195+
case "darwin":
196+
defaultBasePath = `/Library`
197+
case "linux":
198+
defaultBasePath = `/opt`
199+
case "windows":
200+
defaultBasePath = `C:\Program Files`
201+
}
202+
203+
topPath := filepath.Join(defaultBasePath, "Elastic", "Agent")
204+
// Get path to Elastic Agent executable
205+
fixture, err := define.NewFixture(t, define.Version())
206+
require.NoError(t, err)
207+
208+
ctx, cancel := testcontext.WithDeadline(t, context.Background(), time.Now().Add(maxRunTime))
209+
defer cancel()
210+
211+
// Prepare the Elastic Agent so the binary is extracted and ready to use.
212+
err = fixture.Prepare(ctx)
213+
require.NoError(t, err)
214+
215+
// Run `elastic-agent install`. We use `--force` to prevent interactive
216+
// execution.
217+
opts := &atesting.InstallOpts{Force: true}
218+
out, err := fixture.Install(ctx, opts)
219+
if err != nil {
220+
t.Logf("install output: %s", out)
221+
require.NoError(t, err)
222+
}
223+
224+
// Check that Agent was installed in default base path
225+
checkInstallSuccess(t, topPath, opts.IsUnprivileged(runtime.GOOS))
226+
t.Run("check agent package version", testAgentPackageVersion(ctx, fixture, true))
227+
out, err = fixture.Uninstall(ctx, &atesting.UninstallOpts{Force: true})
228+
require.NoErrorf(t, err, "uninstall failed: %s", err)
229+
})
230+
}
231+
}
232+
170233
func checkInstallSuccess(t *testing.T, topPath string, unprivileged bool) {
171234
t.Helper()
172235
_, err := os.Stat(topPath)

0 commit comments

Comments
 (0)