376 lines
12 KiB
Go
376 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// clone-disk agent state: loop-device / mount attachments held for the life
|
|
// of the control agent so several partition clones can reuse them.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
var diskAttach = map[string]*diskAttachment{}
|
|
|
|
func attachedNode(diskPath string, num int, writable bool) (string, error) {
|
|
a := diskAttach[diskPath]
|
|
if a == nil {
|
|
var err error
|
|
a, err = attachDisk(diskPath, writable)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
diskAttach[diskPath] = a
|
|
}
|
|
return partNode(a.blockPath, num), nil
|
|
}
|
|
|
|
func detachAllDisks() {
|
|
for k, a := range diskAttach {
|
|
if a.cleanup != nil {
|
|
a.cleanup()
|
|
}
|
|
delete(diskAttach, k)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// probe_disk
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func handleProbeDisk(out *FrameWriter, m CtrlMsg) {
|
|
d, err := probeDiskLayout(m.Path)
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
|
|
return
|
|
}
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgProbeDiskOK, LayoutJSON: d.JSON()})
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// build_layout: create/size an image, write the partition table, re-read it.
|
|
// The boot-region bytes and the partition data are moved afterwards by the
|
|
// manager through ordinary push/pull transfers.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
type buildOpts struct {
|
|
Script string `json:"script"` // sfdisk restore script (Linux)
|
|
IsFile bool `json:"isFile"` // target is a regular file, not a device
|
|
ImageSize int64 `json:"imageSize"` // truncate the file to this many bytes first
|
|
}
|
|
|
|
func handleBuildLayout(out *FrameWriter, m CtrlMsg) {
|
|
var o buildOpts
|
|
if err := json.Unmarshal([]byte(m.Options), &o); err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "decode build opts: " + err.Error()})
|
|
return
|
|
}
|
|
if o.IsFile {
|
|
f, err := os.OpenFile(m.Path, os.O_CREATE|os.O_RDWR, 0644)
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
|
|
return
|
|
}
|
|
if err := f.Truncate(o.ImageSize); err != nil {
|
|
f.Close()
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("truncate %s: %v", m.Path, err)})
|
|
return
|
|
}
|
|
f.Close()
|
|
}
|
|
if strings.TrimSpace(o.Script) != "" {
|
|
if err := applyPartitionTable(m.Path, o.Script); err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
|
|
return
|
|
}
|
|
} else {
|
|
rereadPartTable(m.Path)
|
|
}
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgBuildLayoutOK})
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// clone_partition: fs-image and file-level methods. (Raw partitions and boot
|
|
// regions go through connect_push / connect_pull instead.)
|
|
//
|
|
// The message is sent to the SOURCE control agent first (Method set, peer
|
|
// fields as for connect_push). On a push failure the manager re-sends it to
|
|
// the DEST control agent with m.Reason == "pull".
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func handleClonePartition(out *FrameWriter, m CtrlMsg) {
|
|
o, err := decodeFSCloneOpts(m.Options)
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "decode clone opts: " + err.Error()})
|
|
return
|
|
}
|
|
switch m.Method {
|
|
case "fs-image":
|
|
if m.Reason == "pull" {
|
|
runFSPullDriver(m, o, out)
|
|
} else {
|
|
runFSPushDriver(m, o, out)
|
|
}
|
|
case "file-level":
|
|
runFileLevelDriver(m, o, out)
|
|
default:
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "unknown clone method " + m.Method})
|
|
}
|
|
}
|
|
|
|
// localDevice resolves this side's partition node. m.Path is the whole disk
|
|
// (or image); m.PartIndex is the 1-based partition number.
|
|
func localDevice(diskPath string, num int, writable bool) (string, error) {
|
|
if isDevicePath(diskPath) || looksLikeBlockDevice(diskPath) {
|
|
// real disk: node is derived directly, no attach needed
|
|
return partNode(diskPath, num), nil
|
|
}
|
|
return attachedNode(diskPath, num, writable)
|
|
}
|
|
|
|
func looksLikeBlockDevice(p string) bool {
|
|
fi, err := os.Stat(p)
|
|
return err == nil && fi.Mode()&os.ModeDevice != 0
|
|
}
|
|
|
|
// runFSPushDriver runs in the SOURCE agent: spawn the peer fs-recv (ssh or
|
|
// local), run the save tool locally, pipe save-stdout -> peer-stdin.
|
|
func runFSPushDriver(m CtrlMsg, o fsCloneOpts, out *FrameWriter) {
|
|
dev, err := localDevice(m.Path, m.PartIndex, false)
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: "source device: " + err.Error()})
|
|
return
|
|
}
|
|
cl := pickCloner(dev, o.FSType, o.Tool)
|
|
if cl == nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "no fs-image cloner for " + o.FSType})
|
|
return
|
|
}
|
|
if o.ShrinkToBytes > 0 {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Stage: "shrink source " + o.FSType + " p" + strconv.Itoa(m.PartIndex)})
|
|
if err := preShrinkFS(dev, o.FSType, o.ShrinkToBytes); err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "pre-shrink: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
peerTail := []string{"agent", "--role", "fs-recv",
|
|
"--path", strconv.Itoa(m.PartIndex), // partition number; peer resolves its own node
|
|
"--fs", o.FSType, "--fstool", o.Tool,
|
|
"--size", strconv.FormatInt(o.SizeBytes, 10),
|
|
"--peerdisk", m.PeerPath,
|
|
}
|
|
peer := peerAgentCommand(m, peerTail)
|
|
peerIn, err := peer.StdinPipe()
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: err.Error()})
|
|
return
|
|
}
|
|
peerOut, _ := peer.StdoutPipe()
|
|
peerErr := newLimitedBuffer(8192)
|
|
peer.Stderr = peerErr
|
|
if err := peer.Start(); err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: "start peer: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
save := exec.Command(cl.save[0], cl.save[1:]...)
|
|
save.Stdout = peerIn
|
|
saveErr, _ := save.StderrPipe()
|
|
if err := save.Start(); err != nil {
|
|
_ = peer.Process.Kill()
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("start %s: %v", cl.save[0], err)})
|
|
return
|
|
}
|
|
|
|
var moved int64
|
|
go scanCloneProgress(saveErr, o.SizeBytes, func(b int64) {
|
|
moved = b
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Stage: o.FSType + " p" + strconv.Itoa(m.PartIndex),
|
|
BytesCopied: b, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
|
|
})
|
|
|
|
saveWait := make(chan error, 1)
|
|
go func() { saveWait <- save.Wait() }()
|
|
sErr := <-saveWait
|
|
_ = peerIn.Close()
|
|
|
|
peerLine := readLine(peerOut)
|
|
pErr := peer.Wait()
|
|
|
|
if sErr != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("%s: %v", cl.save[0], sErr)})
|
|
return
|
|
}
|
|
if pErr != nil || !strings.HasPrefix(peerLine, "OK") {
|
|
reason := strings.TrimSpace(peerLine + " " + peerErr.String())
|
|
if moved == 0 {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: reason})
|
|
return
|
|
}
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "peer restore failed: " + reason})
|
|
return
|
|
}
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, BytesCopied: o.SizeBytes, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPushOK})
|
|
}
|
|
|
|
// runFSPullDriver runs in the DEST agent: spawn the peer fs-send, run the
|
|
// restore tool locally, pipe peer-stdout -> restore-stdin.
|
|
func runFSPullDriver(m CtrlMsg, o fsCloneOpts, out *FrameWriter) {
|
|
dev, err := localDevice(m.Path, m.PartIndex, true)
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: "dest device: " + err.Error()})
|
|
return
|
|
}
|
|
cl := pickCloner(dev, o.FSType, o.Tool)
|
|
if cl == nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "no fs-image cloner for " + o.FSType})
|
|
return
|
|
}
|
|
|
|
peerTail := []string{"agent", "--role", "fs-send",
|
|
"--path", strconv.Itoa(m.PartIndex),
|
|
"--fs", o.FSType, "--fstool", o.Tool,
|
|
"--peerdisk", m.PeerPath,
|
|
"--shrink", strconv.FormatInt(o.ShrinkToBytes, 10),
|
|
}
|
|
peer := peerAgentCommand(m, peerTail)
|
|
peerOut, err := peer.StdoutPipe()
|
|
if err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: err.Error()})
|
|
return
|
|
}
|
|
peerErr := newLimitedBuffer(8192)
|
|
peer.Stderr = peerErr
|
|
if err := peer.Start(); err != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: "start peer: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
restore := exec.Command(cl.restore[0], cl.restore[1:]...)
|
|
restore.Stdin = peerOut
|
|
restoreErr, _ := restore.StderrPipe()
|
|
if err := restore.Start(); err != nil {
|
|
_ = peer.Process.Kill()
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("start %s: %v", cl.restore[0], err)})
|
|
return
|
|
}
|
|
go scanCloneProgress(restoreErr, o.SizeBytes, func(b int64) {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Stage: o.FSType + " p" + strconv.Itoa(m.PartIndex),
|
|
BytesCopied: b, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
|
|
})
|
|
|
|
rErr := restore.Wait()
|
|
pErr := peer.Wait()
|
|
if pErr != nil {
|
|
if rErr != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: strings.TrimSpace(peerErr.String())})
|
|
return
|
|
}
|
|
}
|
|
if rErr != nil {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("%s: %v", cl.restore[0], rErr)})
|
|
return
|
|
}
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, BytesCopied: o.SizeBytes, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgPullOK})
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// fs-send / fs-recv: one-shot stdio helpers spawned on the peer.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func runFSSendRole(partNum int, fsType, tool, peerDisk string, shrinkTo int64) error {
|
|
dev, err := resolveHelperDevice(peerDisk, partNum, shrinkTo > 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cl := pickCloner(dev, fsType, tool)
|
|
if cl == nil {
|
|
return fmt.Errorf("no fs-image cloner for %s", fsType)
|
|
}
|
|
if shrinkTo > 0 {
|
|
if err := preShrinkFS(dev, fsType, shrinkTo); err != nil {
|
|
return fmt.Errorf("pre-shrink: %w", err)
|
|
}
|
|
}
|
|
cmd := exec.Command(cl.save[0], cl.save[1:]...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func runFSRecvRole(partNum int, fsType, tool, peerDisk string) error {
|
|
dev, err := resolveHelperDevice(peerDisk, partNum, true)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ERR: %v\n", err)
|
|
return err
|
|
}
|
|
cl := pickCloner(dev, fsType, tool)
|
|
if cl == nil {
|
|
err := fmt.Errorf("no fs-image cloner for %s", fsType)
|
|
fmt.Fprintf(os.Stdout, "ERR: %v\n", err)
|
|
return err
|
|
}
|
|
cmd := exec.Command(cl.restore[0], cl.restore[1:]...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stdout, "ERR: %v\n", err)
|
|
return err
|
|
}
|
|
fmt.Fprintln(os.Stdout, "OK")
|
|
return nil
|
|
}
|
|
|
|
// resolveHelperDevice is used by the one-shot fs-send/fs-recv helpers, which
|
|
// have no persistent attachment map of their own.
|
|
func resolveHelperDevice(disk string, num int, writable bool) (string, error) {
|
|
if isDevicePath(disk) || looksLikeBlockDevice(disk) {
|
|
return partNode(disk, num), nil
|
|
}
|
|
a, err := attachDisk(disk, writable)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// deliberately leak the loop device until process exit; a one-shot
|
|
// helper is short-lived and the parent detaches on the control side.
|
|
_ = a
|
|
return partNode(a.blockPath, num), nil
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// file-level: mkfs on the target partition + rsync the tree across.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func runFileLevelDriver(m CtrlMsg, o fsCloneOpts, out *FrameWriter) {
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgError,
|
|
Message: "file-level clone (--file-level) is not implemented yet; use --raw or an fs-image type"})
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// reinstall_boot: best-effort, never fatal to the clone.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func handleReinstallBoot(out *FrameWriter, m CtrlMsg) {
|
|
msg := reinstallBootloader(m.Path, m.Options)
|
|
_ = out.WriteJSON(CtrlMsg{Type: msgReinstallBootOK, Message: msg})
|
|
}
|
|
|
|
// readLine reads one \n-terminated line (used for the fs-recv status line).
|
|
func readLine(r io.Reader) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
br := bufio.NewReader(r)
|
|
s, _ := br.ReadString('\n')
|
|
return strings.TrimSpace(s)
|
|
}
|