Skip to content

Commit 13573cb

Browse files
authored
⭐ use in-place engine auto-updates on windows (#6825)
* ⭐ use in-place engine auto-updates on windows Signed-off-by: Dominik Richter <dominik.richter@gmail.com> * 🟢 leftover cleanup + return deferred errors --------- Signed-off-by: Dominik Richter <dominik.richter@gmail.com>
1 parent 2fcb7d5 commit 13573cb

File tree

6 files changed

+326
-0
lines changed

6 files changed

+326
-0
lines changed

apps/mql/mql.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import (
1717
func main() {
1818
defer health.ReportPanic("mql", mql.Version, mql.Build)
1919

20+
// Clean up leftover .old binary from a previous in-place update (Windows).
21+
selfupdate.CleanupOldBinary()
22+
2023
// Normalize --auto-update flag to handle both "--auto-update false" and "--auto-update=false" formats
2124
// This must happen before any argument parsing (self-update check or cobra)
2225
normalizeAutoUpdateFlag()

cli/selfupdate/inplace.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) Mondoo, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package selfupdate
5+
6+
import (
7+
"context"
8+
"io"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"time"
13+
14+
"github.com/cockroachdb/errors"
15+
"github.com/rs/zerolog/log"
16+
)
17+
18+
// verifyBinary runs "<binary> version" with auto-update disabled and a 5-second
19+
// timeout. It returns an error if the binary fails to execute or exits non-zero.
20+
// This is called BEFORE any rename so the original binary is never touched if
21+
// the staged binary is broken.
22+
func verifyBinary(binaryPath string) error {
23+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
24+
defer cancel()
25+
26+
cmd := exec.CommandContext(ctx, binaryPath, "version")
27+
cmd.Env = append(os.Environ(), EnvAutoUpdate+"=false")
28+
29+
if err := cmd.Run(); err != nil {
30+
return errors.Wrap(err, "staged binary failed verification")
31+
}
32+
return nil
33+
}
34+
35+
// swapBinaryInPlace replaces the currently running binary with the staged binary.
36+
// It renames the running executable to <name>.old, then copies the staged binary
37+
// to the original path. If the copy fails, the rename is rolled back.
38+
// Returns the original executable path for re-exec.
39+
func swapBinaryInPlace(stagedBinaryPath string) (string, error) {
40+
originalPath, err := os.Executable()
41+
if err != nil {
42+
return "", errors.Wrap(err, "failed to get current executable path")
43+
}
44+
originalPath, err = filepath.EvalSymlinks(originalPath)
45+
if err != nil {
46+
return "", errors.Wrap(err, "failed to resolve executable symlinks")
47+
}
48+
49+
return swapBinaryInPlaceFrom(stagedBinaryPath, originalPath)
50+
}
51+
52+
// swapBinaryInPlaceFrom is the testable core of swapBinaryInPlace. It takes
53+
// the resolved original path explicitly instead of calling os.Executable().
54+
func swapBinaryInPlaceFrom(stagedBinaryPath, originalPath string) (string, error) {
55+
// If already running from the same path, no swap needed.
56+
stagedAbs, err := filepath.Abs(stagedBinaryPath)
57+
if err == nil {
58+
if resolved, err2 := filepath.EvalSymlinks(stagedAbs); err2 == nil {
59+
stagedAbs = resolved
60+
}
61+
if stagedAbs == originalPath {
62+
return originalPath, nil
63+
}
64+
}
65+
66+
oldPath := originalPath + ".old"
67+
68+
// On Windows os.Rename fails if the destination already exists. Remove a
69+
// leftover .old file from a previous swap that wasn't cleaned up (e.g.,
70+
// crash, or file was still locked at cleanup time).
71+
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
72+
return "", errors.Wrap(err, "failed to remove leftover .old binary")
73+
}
74+
75+
// Rename the running binary out of the way (safe on Windows for a running exe).
76+
if err := os.Rename(originalPath, oldPath); err != nil {
77+
return "", errors.Wrap(err, "failed to rename running binary to .old")
78+
}
79+
80+
// Copy the staged binary to the original path (cross-volume safe).
81+
if err := copyFile(stagedBinaryPath, originalPath); err != nil {
82+
// Roll back: move .old back to original.
83+
if rbErr := os.Rename(oldPath, originalPath); rbErr != nil {
84+
log.Error().Err(rbErr).Msg("in-place swap: rollback failed, manual recovery may be needed")
85+
}
86+
return "", errors.Wrap(err, "failed to copy staged binary to original path")
87+
}
88+
89+
log.Debug().
90+
Str("original", originalPath).
91+
Str("staged", stagedBinaryPath).
92+
Msg("in-place swap: binary replaced successfully")
93+
94+
return originalPath, nil
95+
}
96+
97+
// CleanupOldBinary removes a leftover <exe>.old file from a previous in-place
98+
// update. This is best-effort: on Windows the file may still be locked by the
99+
// previous process, so failures are logged and ignored.
100+
func CleanupOldBinary() {
101+
if !inPlaceUpdateEnabled {
102+
return
103+
}
104+
105+
exePath, err := os.Executable()
106+
if err != nil {
107+
return
108+
}
109+
exePath, err = filepath.EvalSymlinks(exePath)
110+
if err != nil {
111+
return
112+
}
113+
114+
oldPath := exePath + ".old"
115+
if _, err := os.Stat(oldPath); err != nil {
116+
return // nothing to clean up
117+
}
118+
119+
if err := os.Remove(oldPath); err != nil {
120+
log.Debug().Err(err).Str("path", oldPath).Msg("in-place swap: could not remove old binary")
121+
} else {
122+
log.Debug().Str("path", oldPath).Msg("in-place swap: cleaned up old binary")
123+
}
124+
}
125+
126+
// copyFile copies src to dst, creating dst with the same permissions as src.
127+
func copyFile(src, dst string) (err error) {
128+
srcInfo, err := os.Stat(src)
129+
if err != nil {
130+
return err
131+
}
132+
133+
in, err := os.Open(src)
134+
if err != nil {
135+
return err
136+
}
137+
defer in.Close()
138+
139+
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
140+
if err != nil {
141+
return err
142+
}
143+
defer func() {
144+
if cerr := out.Close(); cerr != nil && err == nil {
145+
err = cerr
146+
}
147+
}()
148+
149+
_, err = io.Copy(out, in)
150+
return err
151+
}

cli/selfupdate/inplace_disabled.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (c) Mondoo, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build !windows
5+
6+
package selfupdate
7+
8+
const inPlaceUpdateEnabled = false

cli/selfupdate/inplace_enabled.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (c) Mondoo, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build windows
5+
6+
package selfupdate
7+
8+
const inPlaceUpdateEnabled = true

cli/selfupdate/inplace_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) Mondoo, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package selfupdate
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestSwapBinaryInPlace(t *testing.T) {
16+
t.Run("swaps binary and creates .old file", func(t *testing.T) {
17+
dir := t.TempDir()
18+
originalPath := filepath.Join(dir, "mybinary")
19+
stagedPath := filepath.Join(dir, "staged", "mybinary")
20+
21+
// Create the "original" binary.
22+
require.NoError(t, os.WriteFile(originalPath, []byte("original-content"), 0o755))
23+
24+
// Create the "staged" binary in a subdirectory.
25+
require.NoError(t, os.MkdirAll(filepath.Dir(stagedPath), 0o755))
26+
require.NoError(t, os.WriteFile(stagedPath, []byte("new-content"), 0o755))
27+
28+
// Patch os.Executable for the test by calling swapBinaryInPlaceFrom directly.
29+
resultPath, err := swapBinaryInPlaceFrom(stagedPath, originalPath)
30+
require.NoError(t, err)
31+
assert.Equal(t, originalPath, resultPath)
32+
33+
// The original path should now contain the staged content.
34+
content, err := os.ReadFile(originalPath)
35+
require.NoError(t, err)
36+
assert.Equal(t, "new-content", string(content))
37+
38+
// The .old file should contain the original content.
39+
oldContent, err := os.ReadFile(originalPath + ".old")
40+
require.NoError(t, err)
41+
assert.Equal(t, "original-content", string(oldContent))
42+
})
43+
44+
t.Run("rolls back on copy failure", func(t *testing.T) {
45+
dir := t.TempDir()
46+
originalPath := filepath.Join(dir, "mybinary")
47+
48+
// Create the "original" binary.
49+
require.NoError(t, os.WriteFile(originalPath, []byte("original-content"), 0o755))
50+
51+
// Staged path does not exist — copy will fail.
52+
stagedPath := filepath.Join(dir, "nonexistent", "mybinary")
53+
54+
_, err := swapBinaryInPlaceFrom(stagedPath, originalPath)
55+
require.Error(t, err)
56+
assert.Contains(t, err.Error(), "failed to copy staged binary")
57+
58+
// After rollback, the original should be restored.
59+
content, err := os.ReadFile(originalPath)
60+
require.NoError(t, err)
61+
assert.Equal(t, "original-content", string(content))
62+
63+
// .old should not remain after rollback.
64+
_, err = os.Stat(originalPath + ".old")
65+
assert.True(t, os.IsNotExist(err))
66+
})
67+
68+
t.Run("succeeds with leftover .old file", func(t *testing.T) {
69+
dir := t.TempDir()
70+
originalPath := filepath.Join(dir, "mybinary")
71+
stagedPath := filepath.Join(dir, "staged", "mybinary")
72+
73+
// Create the "original" binary and a leftover .old from a previous swap.
74+
require.NoError(t, os.WriteFile(originalPath, []byte("original-content"), 0o755))
75+
require.NoError(t, os.WriteFile(originalPath+".old", []byte("stale-old"), 0o755))
76+
77+
// Create the "staged" binary.
78+
require.NoError(t, os.MkdirAll(filepath.Dir(stagedPath), 0o755))
79+
require.NoError(t, os.WriteFile(stagedPath, []byte("new-content"), 0o755))
80+
81+
resultPath, err := swapBinaryInPlaceFrom(stagedPath, originalPath)
82+
require.NoError(t, err)
83+
assert.Equal(t, originalPath, resultPath)
84+
85+
// Original path has the new content.
86+
content, err := os.ReadFile(originalPath)
87+
require.NoError(t, err)
88+
assert.Equal(t, "new-content", string(content))
89+
90+
// .old now contains the previous original, not the stale leftover.
91+
oldContent, err := os.ReadFile(originalPath + ".old")
92+
require.NoError(t, err)
93+
assert.Equal(t, "original-content", string(oldContent))
94+
})
95+
96+
t.Run("no-op when staged equals original", func(t *testing.T) {
97+
dir := t.TempDir()
98+
binaryPath := filepath.Join(dir, "mybinary")
99+
require.NoError(t, os.WriteFile(binaryPath, []byte("content"), 0o755))
100+
101+
resultPath, err := swapBinaryInPlaceFrom(binaryPath, binaryPath)
102+
require.NoError(t, err)
103+
assert.Equal(t, binaryPath, resultPath)
104+
105+
// No .old file should be created.
106+
_, err = os.Stat(binaryPath + ".old")
107+
assert.True(t, os.IsNotExist(err))
108+
})
109+
}
110+
111+
func TestCopyFile(t *testing.T) {
112+
dir := t.TempDir()
113+
src := filepath.Join(dir, "src")
114+
dst := filepath.Join(dir, "dst")
115+
116+
require.NoError(t, os.WriteFile(src, []byte("hello"), 0o755))
117+
118+
require.NoError(t, copyFile(src, dst))
119+
120+
content, err := os.ReadFile(dst)
121+
require.NoError(t, err)
122+
assert.Equal(t, "hello", string(content))
123+
124+
info, err := os.Stat(dst)
125+
require.NoError(t, err)
126+
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
127+
}
128+
129+
func TestCleanupOldBinary(t *testing.T) {
130+
// CleanupOldBinary is a no-op on non-Windows because inPlaceUpdateEnabled is false.
131+
// We just verify it doesn't panic.
132+
CleanupOldBinary()
133+
}

cli/selfupdate/selfupdate.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,19 @@ func CheckAndUpdate(cfg Config) (bool, error) {
176176
Str("path", binaryPath).
177177
Msg("self-update: successfully installed new version, re-executing")
178178

179+
// On Windows, swap the binary in-place so the firewall rule for the
180+
// original path keeps working (no second firewall prompt).
181+
if inPlaceUpdateEnabled {
182+
if err := verifyBinary(binaryPath); err != nil {
183+
return false, errors.Wrap(err, "new binary verification failed")
184+
}
185+
originalPath, err := swapBinaryInPlace(binaryPath)
186+
if err != nil {
187+
return false, errors.Wrap(err, "in-place swap failed")
188+
}
189+
binaryPath = originalPath
190+
}
191+
179192
// Re-execute with the new binary
180193
if err := ExecUpdatedBinary(binaryPath, os.Args); err != nil {
181194
return false, errors.Wrap(err, "failed to re-execute with updated binary")
@@ -237,6 +250,16 @@ func execLocalIfNewer(binPath, binName, currentVersion string) (bool, error) {
237250
Str("path", localBinary).
238251
Msg("self-update: switching to local binary")
239252

253+
// On Windows, swap the binary in-place so the firewall rule stays valid.
254+
// No extra verification needed: getLocalBinaryVersion already ran the binary.
255+
if inPlaceUpdateEnabled {
256+
originalPath, err := swapBinaryInPlace(localBinary)
257+
if err != nil {
258+
return false, errors.Wrap(err, "in-place swap failed")
259+
}
260+
localBinary = originalPath
261+
}
262+
240263
// Exec to the newer local binary
241264
if err := ExecUpdatedBinary(localBinary, os.Args); err != nil {
242265
return false, errors.Wrap(err, "failed to exec local binary")

0 commit comments

Comments
 (0)