Skip to content

Commit 3afc799

Browse files
paskalumputun
authored andcommitted
executor: fix context-cancellation leaks, local copy bugs, drop dead options
- sshRun: buffer the done channel so the command goroutine can exit after a ctx-cancel return instead of blocking forever (goroutine leak). - sftpUpload/sftpDownload: build the sftp client over an ssh session we hold (newSftpSession), and on ctx-cancel close that session to abort the in-flight transfer from outside the sftp mutex, then drain the copy goroutine before returning. newSftpSession drains the session stderr pipe in a goroutine (RequestSubsystem never starts the copy that Session.Stderr would use, so unread stderr would stall the channel). The cancel drain is bounded by a grace timeout so an app-level wedge cannot hang the deploy; a transport-level wedge is left to a follow-up. - sftpDownload now downloads into a temp file in the destination dir and renames over the destination only on success, so a canceled or failed download no longer truncates or destroys an existing local file (newly reachable now that a download actually aborts mid-copy). - Local.Upload: multi-file glob + mkdir creates the destination directory itself, and honors context cancellation between files. - Local.Download: pass the caller context through. - Connector.String: cap the private-key slice with min(). - Remove dead options never read by any executor: Checksum from UpDownOpts and SyncOpts, and Force from SyncOpts, plus the config sync 'force' field (undocumented no-op) and its schema entry. Behavior change: under strict YAML a playbook that set 'sync: {force: true}' will now fail to parse.
1 parent a700ebd commit 3afc799

10 files changed

Lines changed: 283 additions & 45 deletions

File tree

pkg/config/command.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ type SyncInternal struct {
6868
Dest string `yaml:"dst" toml:"dst"` // destination must be a directory
6969
Delete bool `yaml:"delete" toml:"delete"` // delete files in destination that are not in source
7070
Exclude []string `yaml:"exclude" toml:"exclude"` // exclude files matching these patterns
71-
Force bool `yaml:"force" toml:"force"` // force sync even if source and destination are the same
7271
}
7372

7473
// DeleteInternal defines delete command, implemented internally

pkg/executor/connector.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,5 +177,7 @@ func (c *Connector) sshConfig(user, privateKeyPath string) (*ssh.ClientConfig, n
177177
}
178178

179179
func (c *Connector) String() string {
180-
return fmt.Sprintf("ssh connector with private key %s.., timeout %v, agent %v", c.privateKey[:8], c.timeout, c.enableAgent)
180+
// cap the slice so it does not run past the end for short or empty (agent-only) keys
181+
key := c.privateKey[:min(len(c.privateKey), 8)]
182+
return fmt.Sprintf("ssh connector with private key %s.., timeout %v, agent %v", key, c.timeout, c.enableAgent)
181183
}

pkg/executor/connector_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,31 @@ import (
55
"testing"
66
"time"
77

8+
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
)
1011

12+
func TestConnector_String(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
key string
16+
wantSubstr string
17+
}{
18+
{"agent-only empty key", "", "private key .., "},
19+
{"short key", "abc", "private key abc.., "},
20+
{"exactly eight", "12345678", "private key 12345678.., "},
21+
{"long key path", "/home/user/.ssh/id_rsa", "private key /home/us.., "},
22+
}
23+
for _, tc := range tests {
24+
t.Run(tc.name, func(t *testing.T) {
25+
c := &Connector{privateKey: tc.key, timeout: time.Second}
26+
var got string
27+
assert.NotPanics(t, func() { got = c.String() }, "String must not panic on short or empty keys")
28+
assert.Contains(t, got, tc.wantSubstr, "String must show at most the first 8 key chars")
29+
})
30+
}
31+
}
32+
1133
func TestConnector_Connect(t *testing.T) {
1234
ctx := context.Background()
1335
hostAndPort, teardown := startTestContainer(t)

pkg/executor/executor.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,15 @@ type RunOpts struct {
2727

2828
// UpDownOpts is a struct for upload and download options.
2929
type UpDownOpts struct {
30-
Mkdir bool // create remote directory if it does not exist
31-
Checksum bool // compare checksums of local and remote files, default is size and modtime
32-
Force bool // overwrite existing files on remote
33-
Exclude []string // exclude files matching the given patterns
30+
Mkdir bool // create remote directory if it does not exist
31+
Force bool // overwrite existing files on remote
32+
Exclude []string // exclude files matching the given patterns
3433
}
3534

3635
// SyncOpts is a struct for sync options.
3736
type SyncOpts struct {
38-
Delete bool // delete extra files on remote
39-
Exclude []string // exclude files matching the given patterns
40-
Checksum bool // compare checksums of local and remote files, default is size and modtime
41-
Force bool // overwrite existing files on remote
37+
Delete bool // delete extra files on remote
38+
Exclude []string // exclude files matching the given patterns
4239
}
4340

4441
// DeleteOpts is a struct for delete options.

pkg/executor/local.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func (l *Local) Run(ctx context.Context, cmd string, _ *RunOpts) (out []string,
6767
}
6868

6969
// Upload just copy file from one place to another
70-
func (l *Local) Upload(_ context.Context, src, dst string, opts *UpDownOpts) (err error) {
70+
func (l *Local) Upload(ctx context.Context, src, dst string, opts *UpDownOpts) (err error) {
7171

7272
// check if the local parameter contains a glob pattern
7373
matches, err := filepath.Glob(src)
@@ -88,12 +88,20 @@ func (l *Local) Upload(_ context.Context, src, dst string, opts *UpDownOpts) (er
8888
}
8989

9090
if mkdir {
91-
if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
92-
return fmt.Errorf("can't create local dir %s: %w", filepath.Dir(dst), err)
91+
// with multiple matches dst is treated as a directory, so create it; otherwise create its parent
92+
mkdirTarget := filepath.Dir(dst)
93+
if len(matches) > 1 {
94+
mkdirTarget = dst
95+
}
96+
if err = os.MkdirAll(mkdirTarget, 0o750); err != nil {
97+
return fmt.Errorf("can't create local dir %s: %w", mkdirTarget, err)
9398
}
9499
}
95100

96101
for _, match := range matches {
102+
if err := ctx.Err(); err != nil { // honor cancellation between files
103+
return err
104+
}
97105
relPath, e := filepath.Rel(filepath.Dir(src), match)
98106
if e != nil {
99107
return fmt.Errorf("failed to build relative path for %s: %w", match, e)
@@ -139,8 +147,8 @@ func (l *Local) Upload(_ context.Context, src, dst string, opts *UpDownOpts) (er
139147
}
140148

141149
// Download just copy file from one place to another
142-
func (l *Local) Download(_ context.Context, src, dst string, opts *UpDownOpts) (err error) {
143-
return l.Upload(context.Background(), src, dst, opts) // same as upload for local
150+
func (l *Local) Download(ctx context.Context, src, dst string, opts *UpDownOpts) (err error) {
151+
return l.Upload(ctx, src, dst, opts) // same as upload for local
144152
}
145153

146154
// Sync directories from src to dst

pkg/executor/local_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,52 @@ func TestDeleteWithExclude(t *testing.T) {
866866
}
867867
}
868868

869+
func TestLocal_UploadMultiGlobMkdir(t *testing.T) {
870+
srcDir := t.TempDir()
871+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o644))
872+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "b.txt"), []byte("b"), 0o644))
873+
874+
// dst directory does not exist yet; with multiple matches it must be created as a directory
875+
dst := filepath.Join(t.TempDir(), "sub", "dest")
876+
l := &Local{}
877+
err := l.Upload(context.Background(), filepath.Join(srcDir, "*.txt"), dst, &UpDownOpts{Mkdir: true})
878+
require.NoError(t, err)
879+
880+
assert.FileExists(t, filepath.Join(dst, "a.txt"))
881+
assert.FileExists(t, filepath.Join(dst, "b.txt"))
882+
}
883+
884+
func TestLocal_UploadCanceledContext(t *testing.T) {
885+
srcDir := t.TempDir()
886+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o644))
887+
888+
ctx, cancel := context.WithCancel(context.Background())
889+
cancel() // already canceled
890+
891+
l := &Local{}
892+
err := l.Upload(ctx, filepath.Join(srcDir, "*.txt"), t.TempDir(), &UpDownOpts{Mkdir: true})
893+
require.ErrorIs(t, err, context.Canceled, "upload should honor a canceled context")
894+
}
895+
896+
func TestLocal_UploadMultiFileCanceled(t *testing.T) {
897+
srcDir := t.TempDir()
898+
for _, n := range []string{"a.txt", "b.txt", "c.txt"} {
899+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, n), []byte(n), 0o644))
900+
}
901+
902+
ctx, cancel := context.WithCancel(context.Background())
903+
cancel() // canceled before the per-file loop runs
904+
905+
dst := t.TempDir()
906+
l := &Local{}
907+
err := l.Upload(ctx, filepath.Join(srcDir, "*.txt"), dst, &UpDownOpts{Mkdir: true})
908+
require.ErrorIs(t, err, context.Canceled, "multi-file upload should honor a canceled context")
909+
910+
entries, err := os.ReadDir(dst)
911+
require.NoError(t, err)
912+
assert.Empty(t, entries, "no files should be copied once the context is canceled")
913+
}
914+
869915
func TestClose(t *testing.T) {
870916
l := &Local{}
871917
err := l.Close()

0 commit comments

Comments
 (0)