master
go 240 lines 8.29 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package ndexec
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "time"
16
17 "github.com/netdata/netdata/go/plugins/logger"
18 "github.com/netdata/netdata/go/plugins/pkg/buildinfo"
19 )
20
21 const stderrLimit = 8 << 10 // 8 KiB
22
23 // Runner holds helper paths for execution.
24 type runner struct {
25 ndRunPath string
26 ndSudoPath string
27 }
28
29 func newRunnerFromBuildinfo() *runner {
30 var sfx string
31 if runtime.GOOS == "windows" {
32 sfx = ".exe"
33 }
34 return &runner{
35 ndRunPath: filepath.Join(buildinfo.NetdataBinDir, "nd-run"+sfx),
36 ndSudoPath: filepath.Join(buildinfo.PluginsDir, "ndsudo"+sfx),
37 }
38 }
39
40 var defaultRunner = newRunnerFromBuildinfo()
41
42 // RunUnprivileged runs binPath via nd-run with a timeout.
43 // Returns stdout. On error, wraps the original error and includes a trimmed stderr snippet.
44 func RunUnprivileged(log *logger.Logger, timeout time.Duration, binPath string, args ...string) ([]byte, error) {
45 out, _, err := RunUnprivilegedWithCmd(log, timeout, binPath, args...)
46 return out, err
47 }
48
49 // RunNDSudo runs cmd via ndsudo with a timeout.
50 // Returns stdout. On error, wraps the original error and includes a trimmed stderr snippet
51 func RunNDSudo(log *logger.Logger, timeout time.Duration, cmd string, args ...string) ([]byte, error) {
52 out, _, err := RunNDSudoWithCmd(log, timeout, cmd, args...)
53 return out, err
54 }
55
56 // RunUnprivilegedWithCmd runs binPath via nd-run and also returns the formatted command string.
57 func RunUnprivilegedWithCmd(log *logger.Logger, timeout time.Duration, binPath string, args ...string) ([]byte, string, error) {
58 argv := append([]string{binPath}, args...)
59 out, cmd, _, err := defaultRunner.run(log, timeout, "", defaultRunner.ndRunPath, "RunUnprivileged", nil, argv...)
60 return out, cmd, err
61 }
62
63 // RunNDSudoWithCmd runs cmd via ndsudo and also returns the formatted command string.
64 func RunNDSudoWithCmd(log *logger.Logger, timeout time.Duration, cmd string, args ...string) ([]byte, string, error) {
65 argv := append([]string{cmd}, args...)
66 out, formatted, _, err := defaultRunner.run(log, timeout, "", defaultRunner.ndSudoPath, "RunNDSudo", nil, argv...)
67 return out, formatted, err
68 }
69
70 // RunUnprivilegedWithEnv runs binPath via nd-run with a custom environment.
71 func RunUnprivilegedWithEnv(log *logger.Logger, timeout time.Duration, env []string, binPath string, args ...string) ([]byte, error) {
72 out, _, err := RunUnprivilegedWithEnvCmd(log, timeout, env, binPath, args...)
73 return out, err
74 }
75
76 // RunUnprivilegedWithEnvCmd runs binPath via nd-run with a custom environment and returns the formatted command string.
77 func RunUnprivilegedWithEnvCmd(log *logger.Logger, timeout time.Duration, env []string, binPath string, args ...string) ([]byte, string, error) {
78 argv := append([]string{binPath}, args...)
79 out, cmd, _, err := defaultRunner.run(log, timeout, "", defaultRunner.ndRunPath, "RunUnprivileged", env, argv...)
80 return out, cmd, err
81 }
82
83 // RunOptions configure nd-run/ndsudo execution helpers.
84 type RunOptions struct {
85 Env []string
86 Dir string
87 }
88
89 // RunUnprivilegedWithOptions runs binPath via nd-run honoring the provided options.
90 func RunUnprivilegedWithOptions(log *logger.Logger, timeout time.Duration, opts RunOptions, binPath string, args ...string) ([]byte, error) {
91 out, _, err := RunUnprivilegedWithOptionsCmd(log, timeout, opts, binPath, args...)
92 return out, err
93 }
94
95 // RunUnprivilegedWithOptionsCmd is RunUnprivilegedWithOptions plus the formatted command string.
96 func RunUnprivilegedWithOptionsCmd(log *logger.Logger, timeout time.Duration, opts RunOptions, binPath string, args ...string) ([]byte, string, error) {
97 argv := append([]string{binPath}, args...)
98 out, cmd, _, err := defaultRunner.run(log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
99 return out, cmd, err
100 }
101
102 // RunUnprivilegedWithOptionsUsage runs binPath via nd-run and returns stdout, formatted command, resource usage and error.
103 func RunUnprivilegedWithOptionsUsage(log *logger.Logger, timeout time.Duration, opts RunOptions, binPath string, args ...string) ([]byte, string, ResourceUsage, error) {
104 argv := append([]string{binPath}, args...)
105 return defaultRunner.run(log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
106 }
107
108 // RunUnprivilegedWithOptionsUsageContext runs binPath via nd-run using the caller
109 // context as the ownership boundary for cancellation and stop/reload propagation.
110 func RunUnprivilegedWithOptionsUsageContext(
111 ctx context.Context,
112 log *logger.Logger,
113 timeout time.Duration,
114 opts RunOptions,
115 binPath string,
116 args ...string,
117 ) ([]byte, string, ResourceUsage, error) {
118 argv := append([]string{binPath}, args...)
119 return defaultRunner.runContext(ctx, log, timeout, opts.Dir, defaultRunner.ndRunPath, "RunUnprivileged", opts.Env, argv...)
120 }
121
122 // SetRunnerPathsForTests overrides the nd-run and ndsudo helper paths.
123 // It is intended for test environments that need to stub the helpers.
124 func SetRunnerPathsForTests(ndRunPath, ndSudoPath string) {
125 if ndRunPath != "" {
126 defaultRunner.ndRunPath = ndRunPath
127 }
128 if ndSudoPath != "" {
129 defaultRunner.ndSudoPath = ndSudoPath
130 }
131 }
132
133 // RunDirect runs binPath directly with a timeout, without any wrapper (nd-run/ndsudo).
134 // Returns stdout. On error, includes the command string and a trimmed stderr snippet.
135 func RunDirect(log *logger.Logger, timeout time.Duration, binPath string, args ...string) ([]byte, error) {
136 out, cmd, _, err := RunDirectWithOptionsUsageContext(context.Background(), log, timeout, RunOptions{}, binPath, args...)
137 if err != nil {
138 return out, fmt.Errorf("'%s' execution failed: %w", cmd, err)
139 }
140 return out, nil
141 }
142
143 // RunDirectWithOptionsUsageContext runs binPath directly using the caller context
144 // while honoring the provided environment and working-directory options.
145 func RunDirectWithOptionsUsageContext(
146 ctx context.Context,
147 log *logger.Logger,
148 timeout time.Duration,
149 opts RunOptions,
150 binPath string,
151 args ...string,
152 ) ([]byte, string, ResourceUsage, error) {
153 return defaultRunner.runContext(ctx, log, timeout, opts.Dir, binPath, "RunDirect", opts.Env, args...)
154 }
155
156 // FindBinary searches for a binary by trying names in PATH first,
157 // then checking defaultPaths on the filesystem.
158 // Returns the first found path, or an error if not found.
159 func FindBinary(names []string, defaultPaths []string) (string, error) {
160 for _, name := range names {
161 if path, err := exec.LookPath(name); err == nil {
162 return path, nil
163 }
164 }
165
166 for _, path := range defaultPaths {
167 if fi, err := os.Stat(path); err == nil && !fi.IsDir() {
168 return path, nil
169 }
170 }
171
172 if len(names) == 0 {
173 return "", fmt.Errorf("executable not found in default locations")
174 }
175 return "", fmt.Errorf("executable not found in PATH (%s) or default locations", strings.Join(names, ", "))
176 }
177
178 func (r *runner) run(log *logger.Logger, timeout time.Duration, dir string, helperPath, label string, env []string, argv ...string) ([]byte, string, ResourceUsage, error) {
179 return r.runContext(context.Background(), log, timeout, dir, helperPath, label, env, argv...)
180 }
181
182 func (r *runner) runContext(
183 ctx context.Context,
184 log *logger.Logger,
185 timeout time.Duration,
186 dir string,
187 helperPath, label string,
188 env []string,
189 argv ...string,
190 ) ([]byte, string, ResourceUsage, error) {
191 if ctx == nil {
192 ctx = context.Background()
193 }
194 if timeout > 0 {
195 var cancel context.CancelFunc
196 ctx, cancel = context.WithTimeout(ctx, timeout)
197 defer cancel()
198 }
199
200 ex := exec.CommandContext(ctx, helperPath, argv...) // argv comes from trusted sources; no shell, args passed separately
201 configureCommandCancellation(ex)
202 if dir != "" {
203 ex.Dir = dir
204 }
205 if len(env) > 0 {
206 ex.Env = env
207 }
208
209 if log != nil {
210 log.Debugf("executing: %v", ex)
211 }
212
213 var stderr bytes.Buffer
214 ex.Stderr = &stderr
215
216 cmdStr := ex.String()
217
218 out, err := ex.Output()
219 usage := extractUsage(ex.ProcessState)
220 if err != nil {
221 s := stderr.String()
222 if len(s) > stderrLimit {
223 s = s[:stderrLimit] + "… (truncated)"
224 }
225 // Normalize context-related errors so callers can distinguish the
226 // execution timeout cause from caller-owned cancellation.
227 if ctx.Err() != nil {
228 cause := context.Cause(ctx)
229 if cause != nil && !errors.Is(cause, ctx.Err()) {
230 err = cause
231 } else {
232 err = ctx.Err()
233 }
234 }
235
236 return out, cmdStr, usage, fmt.Errorf("%s: %v: %w (stderr: %s)", label, ex, err, strings.TrimSpace(s))
237 }
238
239 return out, cmdStr, usage, nil
240 }