clonetool/control.go
2026-09-06 20:15:54 +02:00

263 lines
8.1 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)
}
// ProbeDisk asks the agent to enumerate a whole disk (partition table, boot
// regions, per-partition filesystem facts, tool availability).
func (c *Controller) ProbeDisk(path string) (*DiskLayout, error) {
resp, err := c.call(CtrlMsg{Type: msgProbeDisk, Path: path})
if err != nil {
return nil, err
}
switch resp.Type {
case msgProbeDiskOK:
return parseDiskLayoutJSON(resp.LayoutJSON)
case msgError:
return nil, c.agentErr("probe", path, resp)
default:
return nil, fmt.Errorf("%s: unexpected response %q to probe_disk", c.tag, resp.Type)
}
}
// BuildLayout asks the agent to create/size an image and write the partition
// table (optsJSON is a buildOpts blob).
func (c *Controller) BuildLayout(path, optsJSON string) error {
resp, err := c.call(CtrlMsg{Type: msgBuildLayout, Path: path, Options: optsJSON})
if err != nil {
return err
}
switch resp.Type {
case msgBuildLayoutOK:
return nil
case msgError:
return c.agentErr("build_layout", path, resp)
default:
return fmt.Errorf("%s: unexpected response %q to build_layout", c.tag, resp.Type)
}
}
// ClonePartition drives one fs-image or file-level partition clone. dir is
// "push" (send to source agent) or "pull" (send to dest agent).
func (c *Controller) ClonePartition(req CtrlMsg, cb transferCallbacks) (ok bool, reason string, err error) {
req.Type = msgClonePartition
okType, failType := msgPushOK, msgPushFailed
if req.Reason == "pull" {
okType, failType = msgPullOK, msgPullFailed
}
return c.connectAndPump(req, cb, okType, failType)
}
// ReinstallBoot asks the (dest) agent to run grub-install/bcdboot; the
// returned string is a human summary. Best-effort — never returns an error
// for a boot-repair failure, only for a transport failure.
func (c *Controller) ReinstallBoot(path, optsJSON string) (string, error) {
resp, err := c.call(CtrlMsg{Type: msgReinstallBoot, Path: path, Options: optsJSON})
if err != nil {
return "", err
}
switch resp.Type {
case msgReinstallBootOK:
return resp.Message, nil
case msgError:
return "", c.agentErr("reinstall_boot", path, resp)
default:
return "", fmt.Errorf("%s: unexpected response %q to reinstall_boot", c.tag, resp.Type)
}
}
// 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
}