223 lines
4.3 KiB
Go
223 lines
4.3 KiB
Go
package helperclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ErrorCodeMissingHelper = "MISSING_HELPER"
|
|
ErrorCodeTimeout = "TIMEOUT"
|
|
ErrorCodeBadJSON = "BAD_JSON"
|
|
ErrorCodeProcessFailed = "PROCESS_FAILED"
|
|
)
|
|
|
|
type Client struct {
|
|
HelperPath string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type ClientError struct {
|
|
Code string
|
|
Message string
|
|
HelperPath string
|
|
Stderr string
|
|
Stdout string
|
|
ExitCode int
|
|
Cause error
|
|
}
|
|
|
|
func (e *ClientError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.HelperPath == "" {
|
|
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
|
}
|
|
return fmt.Sprintf("%s: %s: %s", e.Code, e.HelperPath, e.Message)
|
|
}
|
|
|
|
func (e *ClientError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Cause
|
|
}
|
|
|
|
func (c Client) Call(ctx context.Context, op string, args map[string]any) (*HelperResponse, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
helperPath := c.HelperPath
|
|
if helperPath == "" {
|
|
helperPath = defaultHelperPath()
|
|
}
|
|
|
|
if err := ensureHelperFile(helperPath); err != nil {
|
|
return nil, &ClientError{
|
|
Code: ErrorCodeMissingHelper,
|
|
Message: "helper executable is not available",
|
|
HelperPath: helperPath,
|
|
Cause: err,
|
|
}
|
|
}
|
|
|
|
callCtx := ctx
|
|
var cancel context.CancelFunc
|
|
if c.Timeout > 0 {
|
|
callCtx, cancel = context.WithTimeout(ctx, c.Timeout)
|
|
defer cancel()
|
|
}
|
|
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
|
|
request := HelperRequest{
|
|
Protocol: HelperProtocolV1,
|
|
RequestID: newRequestID(),
|
|
Op: op,
|
|
TimeoutMS: timeoutMillis(callCtx),
|
|
Args: args,
|
|
}
|
|
requestBytes, err := json.Marshal(request)
|
|
if err != nil {
|
|
return nil, &ClientError{
|
|
Code: ErrorCodeProcessFailed,
|
|
Message: "marshal helper request",
|
|
HelperPath: helperPath,
|
|
Cause: err,
|
|
}
|
|
}
|
|
|
|
cmd := exec.CommandContext(callCtx, helperPath, "--json")
|
|
cmd.Stdin = bytes.NewReader(requestBytes)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
runErr := cmd.Run()
|
|
stderrText := stderr.String()
|
|
stdoutText := stdout.String()
|
|
if callCtx.Err() != nil {
|
|
return nil, &ClientError{
|
|
Code: ErrorCodeTimeout,
|
|
Message: "helper process timed out",
|
|
HelperPath: helperPath,
|
|
Stderr: stderrText,
|
|
Stdout: stdoutText,
|
|
ExitCode: exitCode(runErr),
|
|
Cause: callCtx.Err(),
|
|
}
|
|
}
|
|
if runErr != nil {
|
|
return nil, &ClientError{
|
|
Code: ErrorCodeProcessFailed,
|
|
Message: "helper process failed",
|
|
HelperPath: helperPath,
|
|
Stderr: stderrText,
|
|
Stdout: stdoutText,
|
|
ExitCode: exitCode(runErr),
|
|
Cause: runErr,
|
|
}
|
|
}
|
|
|
|
var response HelperResponse
|
|
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
|
|
return nil, &ClientError{
|
|
Code: ErrorCodeBadJSON,
|
|
Message: "helper stdout was not valid response JSON",
|
|
HelperPath: helperPath,
|
|
Stderr: stderrText,
|
|
Stdout: stdoutText,
|
|
Cause: err,
|
|
}
|
|
}
|
|
|
|
return &response, nil
|
|
}
|
|
|
|
func ensureHelperFile(path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return fmt.Errorf("helper path is a directory")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func defaultHelperPath() string {
|
|
return filepath.Join(repoRoot(), "runs", "win-helper", "ISphereWinHelper.exe")
|
|
}
|
|
|
|
func repoRoot() string {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
|
|
dir := wd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return wd
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func timeoutMillis(ctx context.Context) int {
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
return 0
|
|
}
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
return 1
|
|
}
|
|
millis := remaining.Milliseconds()
|
|
if millis < 1 {
|
|
return 1
|
|
}
|
|
if millis > int64(math.MaxInt) {
|
|
return math.MaxInt
|
|
}
|
|
return int(millis)
|
|
}
|
|
|
|
func exitCode(err error) int {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return exitErr.ExitCode()
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func newRequestID() string {
|
|
var bytes [16]byte
|
|
if _, err := rand.Read(bytes[:]); err == nil {
|
|
return hex.EncodeToString(bytes[:])
|
|
}
|
|
return fmt.Sprintf("req-%d", time.Now().UnixNano())
|
|
}
|