-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcmd.go
More file actions
146 lines (127 loc) · 4.05 KB
/
Copy pathcmd.go
File metadata and controls
146 lines (127 loc) · 4.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package iago
import (
"bytes"
"context"
"errors"
"io"
"strings"
)
// CmdRunner defines an interface for running commands on remote hosts.
// This interface is based on the "exec.Cmd" struct.
type CmdRunner interface {
Run(cmd string) error
RunContext(ctx context.Context, cmd string) error
Start(cmd string) error
Wait() error
StdinPipe() (io.WriteCloser, error)
StdoutPipe() (io.ReadCloser, error)
StderrPipe() (io.ReadCloser, error)
}
// Shell runs a shell command.
type Shell struct {
Command string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
// Apply runs the shell command on the host.
func (sa Shell) Apply(ctx context.Context, host Host) (err error) {
cmd, err := host.NewCommand()
if err != nil {
return err
}
goroutines := 0
errChan := make(chan error)
defer func() {
for range goroutines {
// Drain the error channel; nil errors are discarded by Join.
err = errors.Join(err, <-errChan)
}
}()
if sa.Stdin != nil {
in, err := cmd.StdinPipe()
if err != nil {
return err
}
defer safeClose(in, &err, io.EOF)
go pipe(in, sa.Stdin, errChan)
goroutines++
}
if sa.Stdout != nil {
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
defer safeClose(out, &err, io.EOF)
go pipe(sa.Stdout, out, errChan)
goroutines++
}
if sa.Stderr != nil {
errOut, err := cmd.StderrPipe()
if err != nil {
return err
}
defer safeClose(errOut, &err, io.EOF)
go pipe(sa.Stderr, errOut, errChan)
goroutines++
}
err = cmd.RunContext(ctx, sa.Command)
if err != nil && err != io.EOF {
return err
}
return nil
}
func pipe(dst io.Writer, src io.Reader, errChan chan error) {
_, err := io.Copy(dst, src)
errChan <- err
}
// Output runs cmd on host as a shell command and returns its captured
// standard output. It is a convenience wrapper around [Shell] for the common
// case of wanting a command's output as a string rather than streaming it to
// a caller-provided writer.
func Output(ctx context.Context, host Host, cmd string) (string, error) {
var buf bytes.Buffer
err := Shell{Command: cmd, Stdout: &buf}.Apply(ctx, host)
return buf.String(), err
}
// Quote wraps s in single quotes so it is safe to embed as one argument in a
// [Shell] command run on a POSIX shell. An embedded single quote is escaped
// using the `'\''` idiom: end the quoted string, emit an escaped quote, and
// resume quoting.
func Quote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// ExitStatus is implemented by an error carrying a remote process's exit
// status, such as the golang.org/x/crypto/ssh package's *ssh.ExitError
// returned by [Shell.Apply] when the remote command runs to completion but
// exits non-zero. errors.AsType[iago.ExitStatus](err) extracts it from a
// wrapped error (for example a [TaskError] from [Group.Run]) without the
// caller importing golang.org/x/crypto/ssh, and distinguishes "the command
// ran and exited non-zero" from "the command never completed" (a dial
// failure, a dropped connection, and the like, none of which implement it).
type ExitStatus interface {
error
ExitStatus() int
}
// FileExists reports whether path exists on host and is not a directory,
// checked with a POSIX `test -f`. It returns false, nil when the remote
// command determines the path fails the test (exit status 1); any other
// failure (a transport error, a missing shell) is returned as an error.
func FileExists(ctx context.Context, host Host, path string) (bool, error) {
return pathTest(ctx, host, "-f", path)
}
// DirExists reports whether path is a directory on host, checked with a
// POSIX `test -d`. See [FileExists] for the exit-status contract.
func DirExists(ctx context.Context, host Host, path string) (bool, error) {
return pathTest(ctx, host, "-d", path)
}
func pathTest(ctx context.Context, host Host, flag, path string) (bool, error) {
err := Shell{Command: "test " + flag + " " + Quote(path)}.Apply(ctx, host)
if err == nil {
return true, nil
}
if exitErr, ok := errors.AsType[ExitStatus](err); ok && exitErr.ExitStatus() == 1 {
return false, nil
}
return false, err
}