Skip to content

Commit 4d3a3f9

Browse files
authored
Add unprivileged upgrade testing for Windows. (#4642)
* Add unprivileged upgrade testing for Windows. * Adjust to 8.14.0-SNAPSHOT as minimum. * Add SeCreateSymbolicLinkPrivilege * Simplify if unprivileged mode is supported. * Fix file access for upgrade. * Fix kill watcher process on Windows in unprivileged mode. * Fix imports. * Fix kill non child process in tests. * Fix lint.
1 parent f09496e commit 4d3a3f9

11 files changed

+132
-41
lines changed

internal/pkg/agent/install/uninstall.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ func killWatcher(pt *progressbar.ProgressBar) error {
391391
errs = errors.Join(errs, fmt.Errorf("failed to load watcher process with pid %d: %w", pid, err))
392392
continue
393393
}
394-
err = proc.Kill()
394+
err = killNoneChildProcess(proc)
395395
if err != nil && !errors.Is(err, os.ErrProcessDone) {
396396
errs = errors.Join(errs, fmt.Errorf("failed to kill watcher process with pid %d: %w", pid, err))
397397
continue

internal/pkg/agent/install/uninstall_unix.go

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
package install
88

9+
import "os"
10+
911
func isBlockingOnExe(_ error) bool {
1012
return false
1113
}
@@ -17,3 +19,10 @@ func removeBlockingExe(_ error) error {
1719
func isRetryableError(_ error) bool {
1820
return false
1921
}
22+
23+
// killNoneChildProcess provides a way of killing a process that is not started as a child of this process.
24+
//
25+
// On Unix systems it just calls the native golang kill.
26+
func killNoneChildProcess(proc *os.Process) error {
27+
return proc.Kill()
28+
}

internal/pkg/agent/install/uninstall_windows.go

+17
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"errors"
1111
"fmt"
1212
"io/fs"
13+
"os"
1314
"syscall"
1415
"unsafe"
1516

@@ -160,3 +161,19 @@ type fileRenameInfo struct {
160161
type fileDispositionInfo struct {
161162
DeleteFile bool
162163
}
164+
165+
// killNoneChildProcess provides a way of killing a process that is not started as a child of this process.
166+
//
167+
// On Windows when running in unprivileged mode the internal way that golang uses DuplicateHandle to perform the kill
168+
// only works when the process is a child of this process.
169+
func killNoneChildProcess(proc *os.Process) error {
170+
h, e := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, uint32(proc.Pid))
171+
if e != nil {
172+
return os.NewSyscallError("OpenProcess", e)
173+
}
174+
defer func() {
175+
_ = syscall.CloseHandle(h)
176+
}()
177+
e = syscall.TerminateProcess(h, 1)
178+
return os.NewSyscallError("TerminateProcess", e)
179+
}

testing/integration/upgrade_fleet_test.go

+2-8
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,8 @@ func testUpgradeFleetManagedElasticAgent(
215215
require.NoError(t, err)
216216

217217
if unprivileged {
218-
if startParsedVersion.Less(*upgradetest.Version_8_13_0) {
219-
t.Skipf("Starting version %s is less than 8.13 and doesn't support --unprivileged", startParsedVersion.String())
220-
}
221-
if endParsedVersion.Less(*upgradetest.Version_8_13_0) {
222-
t.Skipf("Ending version %s is less than 8.13 and doesn't support --unprivileged", endParsedVersion.String())
223-
}
224-
if runtime.GOOS != define.Linux {
225-
t.Skip("Unprivileged mode is currently only supported on Linux")
218+
if !upgradetest.SupportsUnprivileged(startParsedVersion, endParsedVersion) {
219+
t.Skipf("Either starting version %s or ending version %s doesn't support --unprivileged", startParsedVersion.String(), endParsedVersion.String())
226220
}
227221
}
228222

testing/integration/upgrade_standalone_same_commit_test.go

+3-11
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"os"
1919
"path"
2020
"path/filepath"
21-
"runtime"
2221
"strings"
2322
"testing"
2423
"time"
@@ -52,17 +51,10 @@ func TestStandaloneUpgradeSameCommit(t *testing.T) {
5251
t.Skipf("Minimum version for running this test is %q, current version: %q", *upgradetest.Version_8_13_0_SNAPSHOT, currentVersion)
5352
}
5453

55-
unprivilegedAvailable := true
56-
if runtime.GOOS != define.Linux {
57-
// only available on Linux at the moment
58-
unprivilegedAvailable = false
54+
unprivilegedAvailable := false
55+
if upgradetest.SupportsUnprivileged(currentVersion) {
56+
unprivilegedAvailable = true
5957
}
60-
// This is probably redundant: see the skip statement above
61-
if unprivilegedAvailable && currentVersion.Less(*upgradetest.Version_8_13_0) {
62-
// only available if both versions are 8.13+
63-
unprivilegedAvailable = false
64-
}
65-
6658
unPrivilegedString := "unprivileged"
6759
if !unprivilegedAvailable {
6860
unPrivilegedString = "privileged"

testing/integration/upgrade_standalone_test.go

+3-9
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ package integration
99
import (
1010
"context"
1111
"fmt"
12-
"runtime"
1312
"testing"
1413
"time"
1514

@@ -36,14 +35,9 @@ func TestStandaloneUpgrade(t *testing.T) {
3635
require.NoError(t, err)
3736

3837
for _, startVersion := range versionList {
39-
unprivilegedAvailable := true
40-
if runtime.GOOS != define.Linux {
41-
// only available on Linux at the moment
42-
unprivilegedAvailable = false
43-
}
44-
if unprivilegedAvailable && (startVersion.Less(*upgradetest.Version_8_13_0) || endVersion.Less(*upgradetest.Version_8_13_0)) {
45-
// only available if both versions are 8.13+
46-
unprivilegedAvailable = false
38+
unprivilegedAvailable := false
39+
if upgradetest.SupportsUnprivileged(startVersion, endVersion) {
40+
unprivilegedAvailable = true
4741
}
4842
t.Run(fmt.Sprintf("Upgrade %s to %s (privileged)", startVersion, define.Version()), func(t *testing.T) {
4943
testStandaloneUpgrade(t, startVersion, define.Version(), false)

testing/upgradetest/upgrader.go

+36-9
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ import (
1515
"strings"
1616
"time"
1717

18+
"github.com/hectane/go-acl"
1819
"github.com/otiai10/copy"
1920

2021
"github.com/elastic/elastic-agent/internal/pkg/agent/application/upgrade/details"
2122
v1client "github.com/elastic/elastic-agent/pkg/control/v1/client"
2223
v2proto "github.com/elastic/elastic-agent/pkg/control/v2/cproto"
2324
atesting "github.com/elastic/elastic-agent/pkg/testing"
24-
"github.com/elastic/elastic-agent/pkg/testing/define"
2525
"github.com/elastic/elastic-agent/pkg/version"
2626
)
2727

@@ -209,8 +209,7 @@ func PerformUpgrade(
209209
// in the unprivileged is unset we adjust it to use unprivileged when the version allows it
210210
// in the case that its explicitly set then we ensure the version supports it
211211
if upgradeOpts.unprivileged == nil {
212-
if !startVersion.Less(*Version_8_13_0) && !endVersion.Less(*Version_8_13_0) && runtime.GOOS == define.Linux {
213-
// both version support --unprivileged
212+
if SupportsUnprivileged(startVersion, endVersion) {
214213
unprivileged := true
215214
upgradeOpts.unprivileged = &unprivileged
216215
logger.Logf("installation of Elastic Agent will use --unprivileged as both start and end version support --unprivileged mode")
@@ -220,11 +219,8 @@ func PerformUpgrade(
220219
upgradeOpts.unprivileged = &unprivileged
221220
}
222221
} else if *upgradeOpts.unprivileged {
223-
if startVersion.Less(*Version_8_13_0) {
224-
return errors.New("cannot install starting version with --unprivileged (which is default) because the it is older than 8.13")
225-
}
226-
if endVersion.Less(*Version_8_13_0) {
227-
return errors.New("cannot upgrade to ending version as end version doesn't support running with --unprivileged (which is default) because it is older than 8.13")
222+
if !SupportsUnprivileged(startVersion, endVersion) {
223+
return fmt.Errorf("cannot install with forced --unprivileged because either start version %s or end version %s doesn't support --unprivileged mode", startVersion.String(), endVersion.String())
228224
}
229225
}
230226

@@ -574,7 +570,19 @@ func getSourceURI(ctx context.Context, f *atesting.Fixture, unprivileged bool) (
574570
}
575571
if unprivileged {
576572
// move the file to temp directory
577-
dir, err := os.MkdirTemp("", "agent-upgrade-*")
573+
baseTmp := ""
574+
if runtime.GOOS == "windows" {
575+
// `elastic-agent-user` needs to have access to the file, default
576+
// will place this in C:\Users\windows\AppData\Local\Temp\ which
577+
// `elastic-agent-user` doesn't have access.
578+
579+
// create C:\Temp with world read/write to use for temp directory
580+
baseTmp, err = windowsBaseTemp()
581+
if err != nil {
582+
return "", fmt.Errorf("failed to create windows base temp path: %w", err)
583+
}
584+
}
585+
dir, err := os.MkdirTemp(baseTmp, "agent-upgrade-*")
578586
if err != nil {
579587
return "", fmt.Errorf("failed to create temp directory: %w", err)
580588
}
@@ -596,3 +604,22 @@ func getSourceURI(ctx context.Context, f *atesting.Fixture, unprivileged bool) (
596604
}
597605
return "file://" + filepath.Dir(srcPkg), nil
598606
}
607+
608+
func windowsBaseTemp() (string, error) {
609+
baseTmp := "C:\\Temp"
610+
_, err := os.Stat(baseTmp)
611+
if err != nil {
612+
if !errors.Is(err, os.ErrNotExist) {
613+
return "", fmt.Errorf("failed to stat %s: %w", baseTmp, err)
614+
}
615+
err = os.Mkdir(baseTmp, 0777)
616+
if err != nil {
617+
return "", fmt.Errorf("failed to mkdir %s: %w", baseTmp, err)
618+
}
619+
}
620+
err = acl.Chmod(baseTmp, 0777)
621+
if err != nil {
622+
return "", fmt.Errorf("failed to chmod %s: %w", baseTmp, err)
623+
}
624+
return baseTmp, nil
625+
}

testing/upgradetest/versions.go

+16-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"os"
1313
"path/filepath"
14+
"runtime"
1415
"sort"
1516
"strings"
1617

@@ -35,8 +36,8 @@ var (
3536
Version_8_11_0_SNAPSHOT = version.NewParsedSemVer(8, 11, 0, "SNAPSHOT", "")
3637
// Version_8_13_0_SNAPSHOT is the minimum version for testing upgrading agent with the same hash
3738
Version_8_13_0_SNAPSHOT = version.NewParsedSemVer(8, 13, 0, "SNAPSHOT", "")
38-
// Version_8_13_0 is the minimum version for proper unprivileged execution
39-
Version_8_13_0 = version.NewParsedSemVer(8, 13, 0, "", "")
39+
// Version_8_14_0_SNAPSHOT is the minimum version for proper unprivileged execution on all platforms
40+
Version_8_14_0_SNAPSHOT = version.NewParsedSemVer(8, 14, 0, "SNAPSHOT", "")
4041

4142
// ErrNoSnapshot is returned when a requested snapshot is not on the version list.
4243
ErrNoSnapshot = errors.New("failed to find a snapshot on the version list")
@@ -254,3 +255,16 @@ func EnsureSnapshot(version string) string {
254255
}
255256
return version
256257
}
258+
259+
// SupportsUnprivileged returns true when the version supports unprivileged mode.
260+
func SupportsUnprivileged(versions ...*version.ParsedSemVer) bool {
261+
for _, ver := range versions {
262+
if ver.Less(*Version_8_13_0_SNAPSHOT) {
263+
return false
264+
}
265+
if runtime.GOOS != define.Linux && ver.Less(*Version_8_14_0_SNAPSHOT) {
266+
return false
267+
}
268+
}
269+
return true
270+
}

testing/upgradetest/watcher.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func WaitForNoWatcher(ctx context.Context, timeout time.Duration, interval time.
101101
for _, pid := range pids {
102102
proc, err := os.FindProcess(pid)
103103
if err == nil {
104-
_ = proc.Kill()
104+
_ = killNoneChildProcess(proc)
105105
}
106106
}
107107
// next interval ticker will check for no watcher and exit

testing/upgradetest/watcher_unix.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 upgradetest
8+
9+
import "os"
10+
11+
// killNoneChildProcess provides a way of killing a process that is not started as a child of this process.
12+
//
13+
// On Unix systems it just calls the native golang kill.
14+
func killNoneChildProcess(proc *os.Process) error {
15+
return proc.Kill()
16+
}
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 upgradetest
8+
9+
import (
10+
"os"
11+
"syscall"
12+
)
13+
14+
// killNoneChildProcess provides a way of killing a process that is not started as a child of this process.
15+
//
16+
// On Windows when running in unprivileged mode the internal way that golang uses DuplicateHandle to perform the kill
17+
// only works when the process is a child of this process.
18+
func killNoneChildProcess(proc *os.Process) error {
19+
h, e := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, uint32(proc.Pid))
20+
if e != nil {
21+
return os.NewSyscallError("OpenProcess", e)
22+
}
23+
defer func() {
24+
_ = syscall.CloseHandle(h)
25+
}()
26+
e = syscall.TerminateProcess(h, 1)
27+
return os.NewSyscallError("TerminateProcess", e)
28+
}

0 commit comments

Comments
 (0)