clonetool/manager.go

332 lines
10 KiB
Go

package main
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"time"
)
const defaultBlockSize = 4 * 1024 * 1024
const defaultConnectTimeoutSec = 8
// SyncConfig holds everything the `sync` command needs. It is also handed
// (the transport-relevant fields of it) to control agents as part of
// connect_push/connect_pull requests, so they know how to reach the peer.
type SyncConfig struct {
Job string
Source string
Dest string
BlockSize int64
Yes bool
Sudo string // auto | always | never
Deploy bool // copy this binary to remote hosts that lack it
ConnectTimeoutSec int
SSHBin string
SSHOpts []string
RemoteBin string
ManagerHost string
}
func runSync(cfg SyncConfig) error {
srcSpec, err := parseSpec(cfg.Source)
if err != nil {
return fmt.Errorf("--source: %w", err)
}
dstSpec, err := parseSpec(cfg.Dest)
if err != nil {
return fmt.Errorf("--dest: %w", err)
}
if err := checkNotSame(srcSpec, dstSpec); err != nil {
return err
}
// Make sure each remote endpoint has a runnable clonetool, copying this
// binary over if not (unless --deploy=false). A host that appears on
// both sides is only probed once.
srcRemoteBin, dstRemoteBin := cfg.RemoteBin, cfg.RemoteBin
if !srcSpec.IsLocal() {
if srcRemoteBin, err = resolveRemoteBin(&cfg, srcSpec, "source"); err != nil {
return err
}
}
if !dstSpec.IsLocal() {
if !srcSpec.IsLocal() && sameHost(srcSpec, dstSpec) {
dstRemoteBin = srcRemoteBin
} else if dstRemoteBin, err = resolveRemoteBin(&cfg, dstSpec, "dest"); err != nil {
return err
}
}
srcCtrl, srcInfo, srcSudo, err := bringUpController(srcSpec, "source", &cfg, srcRemoteBin, srcSpec.Path)
if err != nil {
return fmt.Errorf("source: %w", err)
}
defer srcCtrl.Close()
if !srcInfo.Exists {
return fmt.Errorf("source %s does not exist", srcSpec)
}
dstCtrl, dstInfo, dstSudo, err := bringUpController(dstSpec, "dest", &cfg, dstRemoteBin, dstSpec.Path)
if err != nil {
return fmt.Errorf("dest: %w", err)
}
defer dstCtrl.Close()
targetSize, err := computeTargetSize(srcSpec, srcInfo, dstSpec, dstInfo, cfg.Yes)
if err != nil {
return err
}
if err := dstCtrl.Prepare(dstSpec.Path, targetSize); err != nil {
return fmt.Errorf("prepare destination: %w", err)
}
blockCount := (targetSize + cfg.BlockSize - 1) / cfg.BlockSize
pp := newProgressPrinter(cfg.BlockSize)
cb := transferCallbacks{onProgress: pp.print}
bothLocal := srcSpec.IsLocal() && dstSpec.IsLocal()
srcHost, srcUser := resolveConnectHost(srcSpec, &cfg)
dstHost, dstUser := resolveConnectHost(dstSpec, &cfg)
pushReq := CtrlMsg{
Path: srcSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize,
PeerHost: dstHost, PeerUser: dstUser, PeerPath: dstSpec.Path, PeerLocal: bothLocal,
RemoteBin: dstRemoteBin, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts, ConnectTimeoutSec: cfg.ConnectTimeoutSec,
Sudo: dstSudo,
}
if bothLocal {
fmt.Fprintf(os.Stderr, "both source and dest are local; syncing directly (no ssh) ...\n")
} else {
fmt.Fprintf(os.Stderr, "attempting push %s -> %s ...\n", srcSpec, dstSpec)
}
ok, reason, err := srcCtrl.ConnectPush(pushReq, cb)
if err != nil {
return err
}
if !ok {
pp.finish()
fmt.Fprintf(os.Stderr, "push not possible (%s); trying pull %s <- %s ...\n", reason, dstSpec, srcSpec)
pullReq := CtrlMsg{
Path: dstSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize,
PeerHost: srcHost, PeerUser: srcUser, PeerPath: srcSpec.Path, PeerLocal: bothLocal,
RemoteBin: srcRemoteBin, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts, ConnectTimeoutSec: cfg.ConnectTimeoutSec,
Sudo: srcSudo,
}
ok2, reason2, err2 := dstCtrl.ConnectPull(pullReq, cb)
if err2 != nil {
return err2
}
if !ok2 {
return fmt.Errorf(
"could not establish a direct connection in either direction (push: %s; pull: %s); "+
"run the manager on the source or destination host, or set up SSH connectivity in at least one direction",
reason, reason2)
}
}
pp.finish()
label := cfg.Job
if label == "" {
label = fmt.Sprintf("%s -> %s", srcSpec, dstSpec)
}
fmt.Fprintf(os.Stderr, "done: %s, %d blocks\n", label, blockCount)
return nil
}
// bringUpController starts a control agent for spec and does its initial
// stat. With --sudo=always the agent is elevated from the start; with
// --sudo=auto a permission error on the stat triggers one transparent
// restart under sudo. The returned bool reports whether the agent (and any
// peer helper it later spawns for this side) is running elevated.
func bringUpController(spec Spec, tag string, cfg *SyncConfig, remoteBin, probePath string) (*Controller, PathInfo, bool, error) {
sudo := cfg.Sudo == "always" && canElevate()
c, err := startController(spec, tag, cfg, remoteBin, sudo)
if err != nil {
return nil, PathInfo{}, sudo, err
}
info, err := c.Stat(probePath)
if err == nil {
return c, info, sudo, nil
}
if errors.Is(err, errNeedPriv) && !canElevate() {
c.Close()
return nil, PathInfo{}, sudo, fmt.Errorf(
"%w; on Windows, run clonetool from an elevated (Administrator) console to open a raw disk", err)
}
if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo {
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, probePath)
c.Close()
sudo = true
if c, err = startController(spec, tag, cfg, remoteBin, true); err != nil {
return nil, PathInfo{}, sudo, err
}
if info, err = c.Stat(probePath); err == nil {
return c, info, sudo, nil
}
}
c.Close()
if sudo {
return nil, PathInfo{}, sudo, fmt.Errorf(
"%w; if this is a sudo failure, configure passwordless sudo for clonetool on %s or run the manager as root",
err, hostLabel(spec))
}
return nil, PathInfo{}, sudo, err
}
func sameHost(a, b Spec) bool {
return strings.EqualFold(a.Host, b.Host) && a.User == b.User
}
func hostLabel(spec Spec) string {
if spec.IsLocal() {
return "this machine"
}
return spec.Host
}
func resolveConnectHost(spec Spec, cfg *SyncConfig) (host, user string) {
if !spec.IsLocal() {
return spec.Host, spec.User
}
host = cfg.ManagerHost
if host == "" {
if h, err := os.Hostname(); err == nil {
host = h
}
}
return host, ""
}
// computeTargetSize applies the sizing/truncation rules: a block-device
// destination can't grow, so the source must fit inside it (sync exactly
// min(src,dst), leaving the remainder of the device untouched); a
// regular-file destination is truncated to the source's size, shrinking
// with confirmation if it currently holds more data than that.
func computeTargetSize(srcSpec Spec, srcInfo PathInfo, dstSpec Spec, dstInfo PathInfo, yes bool) (int64, error) {
if dstInfo.Exists && dstInfo.IsDevice {
if srcInfo.Size > dstInfo.Size {
return 0, fmt.Errorf("source %s (%s) is larger than destination device %s (%s); a device can't be grown",
srcSpec, humanBytes(srcInfo.Size), dstSpec, humanBytes(dstInfo.Size))
}
return srcInfo.Size, nil
}
if dstInfo.Exists && dstInfo.Size > srcInfo.Size {
if !yes {
if !confirmShrink(dstSpec, dstInfo.Size, srcInfo.Size) {
return 0, fmt.Errorf("aborted: destination %s would be truncated from %s to %s", dstSpec, humanBytes(dstInfo.Size), humanBytes(srcInfo.Size))
}
}
}
return srcInfo.Size, nil
}
func confirmShrink(dstSpec Spec, oldSize, newSize int64) bool {
fmt.Fprintf(os.Stderr, "destination %s is %s; it will be truncated to %s to match the source. Continue? [y/N] ",
dstSpec, humanBytes(oldSize), humanBytes(newSize))
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(strings.ToLower(line))
return line == "y" || line == "yes"
}
// progressPrinter renders a single rewriting status line on stderr. Scan
// and transfer now run concurrently, so it keeps the latest of each kind of
// update (blocks hashed so far, blocks copied/skipped) and shows them on one
// line, with a rolling copy rate; the "scan H/M" segment disappears once the
// destination fingerprint is complete.
type progressPrinter struct {
blockSize int64
start time.Time
lastT time.Time
lastCopied int64
rate float64 // copied blocks/sec, smoothed
active bool
hashed int64
copied int64
skipped int64
total int64
}
func newProgressPrinter(blockSize int64) *progressPrinter {
now := time.Now()
return &progressPrinter{blockSize: blockSize, start: now, lastT: now}
}
func (p *progressPrinter) print(m CtrlMsg) {
p.active = true
if m.TotalBlocks > 0 {
p.total = m.TotalBlocks
}
if m.Phase == "scan" {
if m.Hashed > p.hashed {
p.hashed = m.Hashed
}
} else {
p.copied, p.skipped = m.Copied, m.Skipped
now := time.Now()
if dt := now.Sub(p.lastT).Seconds(); dt >= 0.4 {
inst := float64(m.Copied-p.lastCopied) / dt
if p.rate == 0 {
p.rate = inst
} else {
p.rate = 0.6*p.rate + 0.4*inst
}
p.lastT, p.lastCopied = now, m.Copied
}
}
done := p.copied + p.skipped
total := p.total
if total <= 0 {
total = done
}
mib := p.rate * float64(p.blockSize) / (1024 * 1024)
scan := ""
if p.total > 0 && p.hashed < p.total {
scan = fmt.Sprintf(" scan %s/%s", formatCount(p.hashed), formatCount(p.total))
}
fmt.Fprintf(os.Stderr, "\r syncing %s %s/%s copied=%s skipped=%s%s %6.1f MiB/s ",
progressBar(done, total), formatCount(done), formatCount(total),
formatCount(p.copied), formatCount(p.skipped), scan, mib)
}
// finish ends the current status line with a newline so following output
// (and the shell prompt) starts clean.
func (p *progressPrinter) finish() {
if p.active {
fmt.Fprintln(os.Stderr)
p.active = false
}
}
func progressBar(done, total int64) string {
const w = 22
pct := 0
if total > 0 {
pct = int(done * 100 / total)
}
if pct > 100 {
pct = 100
}
n := pct * w / 100
return fmt.Sprintf("[%s%s] %3d%%", strings.Repeat("=", n), strings.Repeat(" ", w-n), pct)
}
func formatCount(n int64) string {
switch {
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1e6)
case n >= 10_000:
return fmt.Sprintf("%.0fk", float64(n)/1e3)
default:
return fmt.Sprintf("%d", n)
}
}