You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
104 lines
2.9 KiB
104 lines
2.9 KiB
package testrunner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// LocalNode implements NodeRunner by executing commands locally via os/exec.
|
|
// Used by agents that run on the same machine as the targets they manage.
|
|
type LocalNode struct {
|
|
hostname string
|
|
isRoot bool
|
|
}
|
|
|
|
// NewLocalNode creates a LocalNode, detecting root status.
|
|
func NewLocalNode(hostname string) *LocalNode {
|
|
n := &LocalNode{hostname: hostname}
|
|
n.isRoot = n.detectRoot()
|
|
return n
|
|
}
|
|
|
|
// Hostname returns the node's hostname.
|
|
func (n *LocalNode) Hostname() string { return n.hostname }
|
|
|
|
// IsRoot returns whether the process is running as root.
|
|
func (n *LocalNode) IsRoot() bool { return n.isRoot }
|
|
|
|
func (n *LocalNode) detectRoot() bool {
|
|
// Try "id -u" to check if running as root (uid 0).
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*secondDuration)
|
|
defer cancel()
|
|
out, _, code, err := n.Run(ctx, "id -u")
|
|
if err != nil || code != 0 {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(out) == "0"
|
|
}
|
|
|
|
// secondDuration avoids importing time in the constant.
|
|
const secondDuration = 1_000_000_000 // time.Second
|
|
|
|
// Run executes a command locally via bash -c.
|
|
func (n *LocalNode) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
|
c := exec.CommandContext(ctx, "bash", "-c", cmd)
|
|
var outBuf, errBuf bytes.Buffer
|
|
c.Stdout = &outBuf
|
|
c.Stderr = &errBuf
|
|
|
|
runErr := c.Run()
|
|
if ctx.Err() != nil {
|
|
return outBuf.String(), errBuf.String(), -1, fmt.Errorf("command timed out: %w", ctx.Err())
|
|
}
|
|
if runErr != nil {
|
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
|
return outBuf.String(), errBuf.String(), exitErr.ExitCode(), nil
|
|
}
|
|
return outBuf.String(), errBuf.String(), -1, runErr
|
|
}
|
|
return outBuf.String(), errBuf.String(), 0, nil
|
|
}
|
|
|
|
// RunRoot executes a command as root. If already root, runs directly.
|
|
// Otherwise uses sudo -n (non-interactive, fails if password required).
|
|
func (n *LocalNode) RunRoot(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
|
if n.isRoot {
|
|
return n.Run(ctx, cmd)
|
|
}
|
|
return n.Run(ctx, "sudo -n "+cmd)
|
|
}
|
|
|
|
// Upload copies a file from local source to a local destination path.
|
|
func (n *LocalNode) Upload(local, remote string) error {
|
|
// Ensure destination directory exists.
|
|
dir := filepath.Dir(remote)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", dir, err)
|
|
}
|
|
|
|
src, err := os.Open(local)
|
|
if err != nil {
|
|
return fmt.Errorf("open source %s: %w", local, err)
|
|
}
|
|
defer src.Close()
|
|
|
|
dst, err := os.OpenFile(remote, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
return fmt.Errorf("create dest %s: %w", remote, err)
|
|
}
|
|
defer dst.Close()
|
|
|
|
if _, err := io.Copy(dst, src); err != nil {
|
|
return fmt.Errorf("copy %s → %s: %w", local, remote, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close is a no-op for LocalNode (no connection to close).
|
|
func (n *LocalNode) Close() {}
|