313 lines
9.4 KiB
Go
313 lines
9.4 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"
|
|
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) && 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 for both
|
|
// phases of a run: "scan" while a side hashes the destination, then the
|
|
// block transfer itself (with a rolling copy rate).
|
|
type progressPrinter struct {
|
|
blockSize int64
|
|
start time.Time
|
|
lastT time.Time
|
|
lastCopied int64
|
|
rate float64 // copied blocks/sec, smoothed
|
|
active bool
|
|
}
|
|
|
|
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.Phase == "scan" {
|
|
fmt.Fprintf(os.Stderr, "\r scanning destination %s %s/%s blocks ",
|
|
progressBar(m.Copied, m.TotalBlocks),
|
|
formatCount(m.Copied), formatCount(m.TotalBlocks))
|
|
return
|
|
}
|
|
|
|
done := m.Copied + m.Skipped
|
|
total := m.TotalBlocks
|
|
if total <= 0 {
|
|
total = done
|
|
}
|
|
|
|
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
|
|
}
|
|
mib := p.rate * float64(p.blockSize) / (1024 * 1024)
|
|
|
|
fmt.Fprintf(os.Stderr, "\r syncing %s %s/%s copied=%s skipped=%s %6.1f MiB/s ",
|
|
progressBar(done, total), formatCount(done), formatCount(total),
|
|
formatCount(m.Copied), formatCount(m.Skipped), 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)
|
|
}
|
|
}
|