200 lines
5.9 KiB
Go
200 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
)
|
|
|
|
// errNeedPriv is wrapped into the error from a control-agent call that
|
|
// failed only because the agent lacked permission to open a device.
|
|
// bringUpController watches for it to decide whether to retry under sudo.
|
|
var errNeedPriv = errors.New("permission denied opening device")
|
|
|
|
// Controller is the manager's handle on one long-lived control agent
|
|
// (spawned locally or over ssh), used for stat/prepare/connect_push/
|
|
// connect_pull/close. The manager never does any source/dest I/O itself —
|
|
// every byte of the file/device it's syncing is read or written by an
|
|
// agent process, either this one or the sink/source-stream it spawns.
|
|
type Controller struct {
|
|
tag string
|
|
cmd *exec.Cmd
|
|
in io.WriteCloser
|
|
fw *FrameWriter
|
|
fr *FrameReader
|
|
sudo bool
|
|
}
|
|
|
|
func startController(spec Spec, tag string, cfg *SyncConfig, remoteBin string, sudo bool) (*Controller, error) {
|
|
agentArgs := []string{"agent", "--role", "control"}
|
|
var cmd *exec.Cmd
|
|
if spec.IsLocal() {
|
|
if sudo {
|
|
cmd = sudoLocalCommand(agentArgs)
|
|
} else {
|
|
cmd = localAgentCommand(agentArgs)
|
|
}
|
|
} else {
|
|
remoteArgs := append([]string{remoteBin}, agentArgs...)
|
|
if sudo {
|
|
remoteArgs = append([]string{"sudo", "-n", "--"}, remoteArgs...)
|
|
}
|
|
cmd = sshCommand(cfg.SSHBin, cfg.SSHOpts, false, cfg.ConnectTimeoutSec, spec.User, spec.Host, remoteArgs)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("start %s control agent: %w", tag, err)
|
|
}
|
|
go relayPrefixed(stderr, tag)
|
|
|
|
return &Controller{tag: tag, cmd: cmd, in: stdin, fw: NewFrameWriter(stdin), fr: NewFrameReader(stdout), sudo: sudo}, nil
|
|
}
|
|
|
|
func relayPrefixed(r io.Reader, tag string) {
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
fmt.Fprintf(os.Stderr, "[%s] %s\n", tag, sc.Text())
|
|
}
|
|
}
|
|
|
|
func (c *Controller) readOne() (CtrlMsg, error) {
|
|
typ, payload, err := c.fr.ReadFrame()
|
|
if err != nil {
|
|
return CtrlMsg{}, fmt.Errorf("%s: read control frame: %w", c.tag, err)
|
|
}
|
|
if typ != frameCtrlJSON {
|
|
return CtrlMsg{}, fmt.Errorf("%s: unexpected frame type %d on control channel", c.tag, typ)
|
|
}
|
|
var m CtrlMsg
|
|
if err := json.Unmarshal(payload, &m); err != nil {
|
|
return CtrlMsg{}, fmt.Errorf("%s: decode control message: %w", c.tag, err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (c *Controller) call(req CtrlMsg) (CtrlMsg, error) {
|
|
if err := c.fw.WriteJSON(req); err != nil {
|
|
return CtrlMsg{}, fmt.Errorf("%s: send %s: %w", c.tag, req.Type, err)
|
|
}
|
|
return c.readOne()
|
|
}
|
|
|
|
func (c *Controller) Stat(path string) (PathInfo, error) {
|
|
resp, err := c.call(CtrlMsg{Type: msgStat, Path: path})
|
|
if err != nil {
|
|
return PathInfo{}, err
|
|
}
|
|
switch resp.Type {
|
|
case msgStatOK:
|
|
return PathInfo{Exists: resp.Exists, IsDevice: resp.IsDevice, Size: resp.Size}, nil
|
|
case msgError:
|
|
return PathInfo{}, c.agentErr("stat", path, resp)
|
|
default:
|
|
return PathInfo{}, fmt.Errorf("%s: unexpected response %q to stat", c.tag, resp.Type)
|
|
}
|
|
}
|
|
|
|
func (c *Controller) Prepare(path string, size int64) error {
|
|
resp, err := c.call(CtrlMsg{Type: msgPrepare, Path: path, Size: size})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch resp.Type {
|
|
case msgPrepareOK:
|
|
return nil
|
|
case msgError:
|
|
return c.agentErr("prepare", path, resp)
|
|
default:
|
|
return fmt.Errorf("%s: unexpected response %q to prepare", c.tag, resp.Type)
|
|
}
|
|
}
|
|
|
|
// agentErr turns an agent's error reply into an error, tagging it with
|
|
// errNeedPriv when the agent said the cause was a permission problem on a
|
|
// device (so the caller can retry the whole agent under sudo).
|
|
func (c *Controller) agentErr(op, path string, resp CtrlMsg) error {
|
|
if resp.NeedPriv {
|
|
return fmt.Errorf("%s: %s %s: %s: %w", c.tag, op, path, resp.Message, errNeedPriv)
|
|
}
|
|
return fmt.Errorf("%s: %s %s: %s", c.tag, op, path, resp.Message)
|
|
}
|
|
|
|
// transferCallbacks receives streaming updates while a connect_push or
|
|
// connect_pull is in flight.
|
|
type transferCallbacks struct {
|
|
onProgress func(CtrlMsg)
|
|
}
|
|
|
|
// ConnectPush asks the source control agent to try connecting straight out
|
|
// to the destination host and driving the whole transfer itself. ok=false
|
|
// with a non-empty reason means the SSH handshake didn't succeed (caller
|
|
// should try ConnectPull instead); a non-nil err means something failed
|
|
// after the transfer was already committed.
|
|
func (c *Controller) ConnectPush(req CtrlMsg, cb transferCallbacks) (ok bool, reason string, err error) {
|
|
req.Type = msgConnectPush
|
|
return c.connectAndPump(req, cb, msgPushOK, msgPushFailed)
|
|
}
|
|
|
|
// ConnectPull asks the destination control agent to try connecting out to
|
|
// the source host and pulling the transfer.
|
|
func (c *Controller) ConnectPull(req CtrlMsg, cb transferCallbacks) (ok bool, reason string, err error) {
|
|
req.Type = msgConnectPull
|
|
return c.connectAndPump(req, cb, msgPullOK, msgPullFailed)
|
|
}
|
|
|
|
func (c *Controller) connectAndPump(req CtrlMsg, cb transferCallbacks, okType, failedType string) (bool, string, error) {
|
|
if err := c.fw.WriteJSON(req); err != nil {
|
|
return false, "", fmt.Errorf("%s: send %s: %w", c.tag, req.Type, err)
|
|
}
|
|
for {
|
|
m, err := c.readOne()
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
switch m.Type {
|
|
case msgProgress:
|
|
if cb.onProgress != nil {
|
|
cb.onProgress(m)
|
|
}
|
|
case okType:
|
|
return true, "", nil
|
|
case failedType:
|
|
return false, m.Reason, nil
|
|
case msgError:
|
|
return false, "", fmt.Errorf("%s: %s", c.tag, m.Message)
|
|
default:
|
|
return false, "", fmt.Errorf("%s: unexpected message %q during transfer", c.tag, m.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Controller) Close() error {
|
|
_ = c.fw.WriteJSON(CtrlMsg{Type: msgClose})
|
|
_ = c.in.Close()
|
|
err := c.cmd.Wait()
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return nil // agent exiting after we closed its stdin is expected
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|