Skip to content

Commit 39f3d77

Browse files
authored
Improve uninstall on Windows to remove directory even when uninstall is being executed from same directory (#3120)
* Revert the revert of the uninstall. * Add retry for ERROR_SHARING_VIOLATION. * Add comment.
1 parent 5640390 commit 39f3d77

5 files changed

+299
-35
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: Don't trigger IOC alert on Windows uninstall
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: uninstall
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/elastic/elastic-agent/pull/3014
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/elastic/elastic-agent/issues/2970

internal/pkg/agent/install/uninstall.go

+31-35
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import (
88
"context"
99
"fmt"
1010
"os"
11-
"os/exec"
1211
"path/filepath"
1312
"runtime"
1413
"strings"
14+
"time"
1515

1616
"github.com/kardianos/service"
1717

@@ -78,13 +78,8 @@ func Uninstall(cfgFile, topPath, uninstallToken string) error {
7878
}
7979

8080
// remove existing directory
81-
err = os.RemoveAll(topPath)
81+
err = RemovePath(topPath)
8282
if err != nil {
83-
if runtime.GOOS == "windows" { //nolint:goconst // it is more readable this way
84-
// possible to fail on Windows, because elastic-agent.exe is running from
85-
// this directory.
86-
return nil
87-
}
8883
return errors.New(
8984
err,
9085
fmt.Sprintf("failed to remove installation directory (%s)", paths.Top()),
@@ -95,15 +90,37 @@ func Uninstall(cfgFile, topPath, uninstallToken string) error {
9590
}
9691

9792
// RemovePath helps with removal path where there is a probability
98-
// of running into self which might prevent removal.
99-
// Removal will be initiated 2 seconds after a call.
93+
// of running into an executable running that might prevent removal
94+
// on Windows.
95+
//
96+
// On Windows it is possible that a removal can spuriously error due
97+
// to an ERROR_SHARING_VIOLATION. RemovePath will retry up to 2
98+
// seconds if it keeps getting that error.
10099
func RemovePath(path string) error {
101-
cleanupErr := os.RemoveAll(path)
102-
if cleanupErr != nil && isBlockingOnSelf(cleanupErr) {
103-
delayedRemoval(path)
100+
const arbitraryTimeout = 2 * time.Second
101+
var start time.Time
102+
nextSleep := 1 * time.Millisecond
103+
for {
104+
err := os.RemoveAll(path)
105+
if err == nil {
106+
return nil
107+
}
108+
if isBlockingOnExe(err) {
109+
// try to remove the blocking exe
110+
err = removeBlockingExe(err)
111+
}
112+
if err == nil {
113+
return nil
114+
}
115+
if !isRetryableError(err) {
116+
return err
117+
}
118+
if start.IsZero() {
119+
start = time.Now()
120+
} else if d := time.Since(start) + nextSleep; d >= arbitraryTimeout {
121+
return err
122+
}
104123
}
105-
106-
return cleanupErr
107124
}
108125

109126
func RemoveBut(path string, bestEffort bool, exceptions ...string) error {
@@ -146,27 +163,6 @@ func containsString(str string, a []string, caseSensitive bool) bool {
146163
return false
147164
}
148165

149-
func isBlockingOnSelf(err error) bool {
150-
// cannot remove self, this is expected on windows
151-
// fails with remove {path}}\elastic-agent.exe: Access is denied
152-
return runtime.GOOS == "windows" &&
153-
err != nil &&
154-
strings.Contains(err.Error(), "elastic-agent.exe") &&
155-
strings.Contains(err.Error(), "Access is denied")
156-
}
157-
158-
func delayedRemoval(path string) {
159-
// The installation path will still exists because we are executing from that
160-
// directory. So cmd.exe is spawned that sleeps for 2 seconds (using ping, recommend way from
161-
// from Windows) then rmdir is performed.
162-
//nolint:gosec // it's not tainted
163-
rmdir := exec.Command(
164-
filepath.Join(os.Getenv("windir"), "system32", "cmd.exe"),
165-
"/C", "ping", "-n", "2", "127.0.0.1", "&&", "rmdir", "/s", "/q", path)
166-
_ = rmdir.Start()
167-
168-
}
169-
170166
func uninstallComponents(ctx context.Context, cfgFile string, uninstallToken string) error {
171167
log, err := logger.NewWithLogpLevel("", logp.ErrorLevel, false)
172168
if err != nil {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
func isBlockingOnExe(_ error) bool {
10+
return false
11+
}
12+
13+
func removeBlockingExe(_ error) error {
14+
return nil
15+
}
16+
17+
func isRetryableError(_ error) bool {
18+
return false
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
"errors"
11+
"fmt"
12+
"io/fs"
13+
"syscall"
14+
"unsafe"
15+
16+
"golang.org/x/sys/windows"
17+
)
18+
19+
func isBlockingOnExe(err error) bool {
20+
if err == nil {
21+
return false
22+
}
23+
path, errno := getPathFromError(err)
24+
if path == "" {
25+
return false
26+
}
27+
return errno == syscall.ERROR_ACCESS_DENIED
28+
}
29+
30+
func isRetryableError(err error) bool {
31+
if err == nil {
32+
return false
33+
}
34+
path, errno := getPathFromError(err)
35+
if path == "" {
36+
return false
37+
}
38+
return errno == syscall.ERROR_ACCESS_DENIED || errno == windows.ERROR_SHARING_VIOLATION
39+
}
40+
41+
func removeBlockingExe(blockingErr error) error {
42+
path, _ := getPathFromError(blockingErr)
43+
if path == "" {
44+
return nil
45+
}
46+
47+
// open handle for delete only
48+
h, err := openDeleteHandle(path)
49+
if err != nil {
50+
return fmt.Errorf("failed to open handle for %q: %w", path, err)
51+
}
52+
53+
// rename handle
54+
err = renameHandle(h)
55+
_ = windows.CloseHandle(h)
56+
if err != nil {
57+
return fmt.Errorf("failed to rename handle for %q: %w", path, err)
58+
}
59+
60+
// re-open handle
61+
h, err = openDeleteHandle(path)
62+
if err != nil {
63+
return fmt.Errorf("failed to open handle after rename for %q: %w", path, err)
64+
}
65+
66+
// dispose of the handle
67+
err = disposeHandle(h)
68+
_ = windows.CloseHandle(h)
69+
if err != nil {
70+
return fmt.Errorf("failed to dispose handle for %q: %w", path, err)
71+
}
72+
return nil
73+
}
74+
75+
func getPathFromError(blockingErr error) (string, syscall.Errno) {
76+
var perr *fs.PathError
77+
if errors.As(blockingErr, &perr) {
78+
var errno syscall.Errno
79+
if errors.As(perr.Err, &errno) {
80+
return perr.Path, errno
81+
}
82+
}
83+
return "", 0
84+
}
85+
86+
func openDeleteHandle(path string) (windows.Handle, error) {
87+
wPath, err := windows.UTF16PtrFromString(path)
88+
if err != nil {
89+
return 0, err
90+
}
91+
handle, err := windows.CreateFile(
92+
wPath,
93+
windows.DELETE,
94+
0,
95+
nil,
96+
windows.OPEN_EXISTING,
97+
windows.FILE_ATTRIBUTE_NORMAL,
98+
0,
99+
)
100+
if err != nil {
101+
return 0, err
102+
}
103+
return handle, nil
104+
}
105+
106+
func renameHandle(hHandle windows.Handle) error {
107+
wRename, err := windows.UTF16FromString(":agentrm")
108+
if err != nil {
109+
return err
110+
}
111+
112+
var rename fileRenameInfo
113+
lpwStream := &wRename[0]
114+
rename.FileNameLength = uint32(unsafe.Sizeof(lpwStream))
115+
116+
_, _, _ = windows.NewLazyDLL("kernel32.dll").NewProc("RtlCopyMemory").Call(
117+
uintptr(unsafe.Pointer(&rename.FileName[0])),
118+
uintptr(unsafe.Pointer(lpwStream)),
119+
unsafe.Sizeof(lpwStream),
120+
)
121+
122+
err = windows.SetFileInformationByHandle(
123+
hHandle,
124+
windows.FileRenameInfo,
125+
(*byte)(unsafe.Pointer(&rename)),
126+
uint32(unsafe.Sizeof(rename)+unsafe.Sizeof(lpwStream)),
127+
)
128+
if err != nil {
129+
return err
130+
}
131+
return nil
132+
}
133+
134+
func disposeHandle(hHandle windows.Handle) error {
135+
var deleteFile fileDispositionInfo
136+
deleteFile.DeleteFile = true
137+
138+
err := windows.SetFileInformationByHandle(
139+
hHandle,
140+
windows.FileDispositionInfo,
141+
(*byte)(unsafe.Pointer(&deleteFile)),
142+
uint32(unsafe.Sizeof(deleteFile)),
143+
)
144+
if err != nil {
145+
return err
146+
}
147+
return nil
148+
}
149+
150+
type fileRenameInfo struct {
151+
Union struct {
152+
ReplaceIfExists bool
153+
Flags uint32
154+
}
155+
RootDirectory windows.Handle
156+
FileNameLength uint32
157+
FileName [1]uint16
158+
}
159+
160+
type fileDispositionInfo struct {
161+
DeleteFile bool
162+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
"os"
11+
"os/exec"
12+
"path/filepath"
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
const simpleBlockForever = `
19+
package main
20+
21+
import (
22+
"math"
23+
"time"
24+
)
25+
26+
func main() {
27+
<-time.After(time.Duration(math.MaxInt64))
28+
}
29+
`
30+
31+
func TestRemovePath(t *testing.T) {
32+
dir := filepath.Join(t.TempDir(), "subdir")
33+
err := os.Mkdir(dir, 0644)
34+
require.NoError(t, err)
35+
36+
src := filepath.Join(dir, "main.go")
37+
err = os.WriteFile(src, []byte(simpleBlockForever), 0644)
38+
require.NoError(t, err)
39+
40+
binary := filepath.Join(dir, "main.exe")
41+
cmd := exec.Command("go", "build", "-o", binary, src)
42+
_, err = cmd.CombinedOutput()
43+
require.NoError(t, err)
44+
45+
cmd = exec.Command(binary)
46+
err = cmd.Start()
47+
require.NoError(t, err)
48+
defer func() {
49+
_ = cmd.Process.Kill()
50+
_ = cmd.Wait()
51+
}()
52+
53+
err = RemovePath(dir)
54+
require.NoError(t, err)
55+
}

0 commit comments

Comments
 (0)