262 lines
8.0 KiB
Go
262 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// CloneDiskConfig is everything `clonetool clone-disk` needs. It embeds
|
|
// SyncConfig for the transport/deploy/ssh knobs shared with `sync`.
|
|
type CloneDiskConfig struct {
|
|
SyncConfig
|
|
Parts []int
|
|
Raw []int
|
|
FileLevel []int
|
|
FileAuto bool
|
|
AllowShrink bool
|
|
NoShrink bool
|
|
NewIDs bool
|
|
ReinstallBoot bool
|
|
VSS bool
|
|
ImageSize int64
|
|
}
|
|
|
|
func runCloneDisk(cfg CloneDiskConfig) 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
|
|
}
|
|
|
|
srcRemoteBin, dstRemoteBin := cfg.RemoteBin, cfg.RemoteBin
|
|
if !srcSpec.IsLocal() {
|
|
if srcRemoteBin, err = resolveRemoteBin(&cfg.SyncConfig, srcSpec, "source"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !dstSpec.IsLocal() {
|
|
if !srcSpec.IsLocal() && sameHost(srcSpec, dstSpec) {
|
|
dstRemoteBin = srcRemoteBin
|
|
} else if dstRemoteBin, err = resolveRemoteBin(&cfg.SyncConfig, dstSpec, "dest"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// --- bring up both control agents -------------------------------------
|
|
srcCtrl, srcLayout, srcSudo, err := bringUpProbe(srcSpec, "source", &cfg.SyncConfig, srcRemoteBin)
|
|
if err != nil {
|
|
return fmt.Errorf("source: %w", err)
|
|
}
|
|
defer srcCtrl.Close()
|
|
|
|
dstCtrl, dstInfo, dstSudo, err := bringUpController(dstSpec, "dest", &cfg.SyncConfig, dstRemoteBin, dstSpec.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("dest: %w", err)
|
|
}
|
|
defer dstCtrl.Close()
|
|
|
|
isFile := !(dstInfo.Exists && dstInfo.IsDevice)
|
|
destSize := dstInfo.Size
|
|
fmt.Fprintf(os.Stderr, "source %s: %s, %s, %d partitions\n",
|
|
srcSpec, srcLayout.Scheme, humanBytes(srcLayout.DiskSize), len(srcLayout.Partitions))
|
|
reportMissingTools(srcLayout)
|
|
|
|
// --- plan the target -------------------------------------------------
|
|
plan, err := planTargetLayout(srcLayout, isFile, destSize, planOpts{
|
|
Parts: intSet(cfg.Parts),
|
|
Raw: intSet(cfg.Raw),
|
|
FileLevel: intSet(cfg.FileLevel),
|
|
FileAuto: cfg.FileAuto,
|
|
AllowShrink: cfg.AllowShrink && !cfg.NoShrink,
|
|
NewIDs: cfg.NewIDs,
|
|
ImageSize: cfg.ImageSize,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, n := range plan.Notes {
|
|
fmt.Fprintf(os.Stderr, " %s\n", n)
|
|
}
|
|
shrinks := false
|
|
for _, pp := range plan.Parts {
|
|
if pp.ShrinkToB > 0 {
|
|
shrinks = true
|
|
}
|
|
}
|
|
if shrinks && !cfg.Yes {
|
|
return fmt.Errorf("shrinking resizes the SOURCE filesystem(s) in place before imaging; " +
|
|
"re-run with --yes to confirm, or use a larger target")
|
|
}
|
|
if isFile && !cfg.Yes && dstInfo.Exists && dstInfo.Size > plan.ImageSize {
|
|
if !confirmShrink(dstSpec, dstInfo.Size, plan.ImageSize) {
|
|
return fmt.Errorf("aborted")
|
|
}
|
|
}
|
|
|
|
// --- rebuild the partition table on the target ---------------------
|
|
bo := buildOpts{Script: plan.Script, IsFile: isFile, ImageSize: plan.ImageSize}
|
|
boJSON, _ := json.Marshal(bo)
|
|
fmt.Fprintf(os.Stderr, "rebuilding partition table on %s ...\n", dstSpec)
|
|
if err := dstCtrl.BuildLayout(dstSpec.Path, string(boJSON)); err != nil {
|
|
return fmt.Errorf("build layout: %w", err)
|
|
}
|
|
|
|
bothLocal := srcSpec.IsLocal() && dstSpec.IsLocal()
|
|
srcHost, srcUser := resolveConnectHost(srcSpec, &cfg.SyncConfig)
|
|
dstHost, dstUser := resolveConnectHost(dstSpec, &cfg.SyncConfig)
|
|
|
|
base := func(s Spec, remoteBin string) CtrlMsg {
|
|
return CtrlMsg{
|
|
BlockSize: cfg.BlockSize, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts,
|
|
ConnectTimeoutSec: cfg.ConnectTimeoutSec, PeerLocal: bothLocal,
|
|
}
|
|
}
|
|
_ = base
|
|
|
|
// --- copy the boot regions verbatim (offset windows) ---------------
|
|
for _, r := range plan.BootRegions {
|
|
if r.Length <= 0 {
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "boot region @%d (%s) %s ...\n", r.Offset, humanBytes(r.Length), r.Note)
|
|
if err := runWindowTransfer(srcCtrl, dstCtrl, windowJob{
|
|
srcPath: srcSpec.Path, dstPath: dstSpec.Path,
|
|
srcBase: r.Offset, dstBase: r.Offset, size: r.Length,
|
|
bothLocal: bothLocal, blockSize: cfg.BlockSize, cfg: &cfg.SyncConfig,
|
|
srcHost: srcHost, srcUser: srcUser, dstHost: dstHost, dstUser: dstUser,
|
|
srcRemoteBin: srcRemoteBin, dstRemoteBin: dstRemoteBin,
|
|
srcSudo: srcSudo, dstSudo: dstSudo,
|
|
}); err != nil {
|
|
return fmt.Errorf("boot region @%d: %w", r.Offset, err)
|
|
}
|
|
}
|
|
|
|
// --- per-partition data ------------------------------------------------
|
|
for i, pp := range plan.Parts {
|
|
tag := fmt.Sprintf("partition %d/%d (p%d %s, %s)", i+1, len(plan.Parts), pp.Num, orDash(pp.FSType), pp.Method)
|
|
fmt.Fprintf(os.Stderr, "%s ...\n", tag)
|
|
switch pp.Method {
|
|
case "raw":
|
|
sz := pp.SrcSizeB
|
|
if pp.DstSizeB < sz {
|
|
sz = pp.DstSizeB
|
|
}
|
|
if err := runWindowTransfer(srcCtrl, dstCtrl, windowJob{
|
|
srcPath: srcSpec.Path, dstPath: dstSpec.Path,
|
|
srcBase: pp.SrcStartB, dstBase: pp.DstStartB, size: sz,
|
|
bothLocal: bothLocal, blockSize: cfg.BlockSize, cfg: &cfg.SyncConfig,
|
|
srcHost: srcHost, srcUser: srcUser, dstHost: dstHost, dstUser: dstUser,
|
|
srcRemoteBin: srcRemoteBin, dstRemoteBin: dstRemoteBin,
|
|
srcSudo: srcSudo, dstSudo: dstSudo,
|
|
}); err != nil {
|
|
return fmt.Errorf("%s: %w", tag, err)
|
|
}
|
|
case "fs-image":
|
|
if err := runFSImagePartition(srcCtrl, dstCtrl, pp, cloneCtx{
|
|
srcPath: srcSpec.Path, dstPath: dstSpec.Path, bothLocal: bothLocal,
|
|
cfg: &cfg.SyncConfig, srcHost: srcHost, srcUser: srcUser,
|
|
dstHost: dstHost, dstUser: dstUser,
|
|
srcRemoteBin: srcRemoteBin, dstRemoteBin: dstRemoteBin,
|
|
srcSudo: srcSudo, dstSudo: dstSudo,
|
|
}); err != nil {
|
|
return fmt.Errorf("%s: %w", tag, err)
|
|
}
|
|
case "file-level":
|
|
return fmt.Errorf("%s: file-level clone is not implemented yet", tag)
|
|
}
|
|
}
|
|
|
|
// --- bootloader (opt-in) --------------------------------------------
|
|
if cfg.ReinstallBoot {
|
|
bootJSON, _ := json.Marshal(bootOpts{
|
|
RootPart: plan.RootPart, ESPPart: plan.ESPPart, UEFI: plan.UEFI, DiskPath: dstSpec.Path,
|
|
})
|
|
fmt.Fprintf(os.Stderr, "reinstalling bootloader on %s ...\n", dstSpec)
|
|
summary, err := dstCtrl.ReinstallBoot(dstSpec.Path, string(bootJSON))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " bootloader: %v\n", err)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, " bootloader: %s\n", summary)
|
|
}
|
|
}
|
|
|
|
label := cfg.Job
|
|
if label == "" {
|
|
label = fmt.Sprintf("%s -> %s", srcSpec, dstSpec)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "done: %s\n", label)
|
|
return nil
|
|
}
|
|
|
|
// bringUpProbe starts a source control agent and runs probe_disk, retrying
|
|
// once under sudo on a permission error (mirrors bringUpController).
|
|
func bringUpProbe(spec Spec, tag string, cfg *SyncConfig, remoteBin string) (*Controller, *DiskLayout, bool, error) {
|
|
sudo := cfg.Sudo == "always" && canElevate()
|
|
c, err := startController(spec, tag, cfg, remoteBin, sudo)
|
|
if err != nil {
|
|
return nil, nil, sudo, err
|
|
}
|
|
lay, err := c.ProbeDisk(spec.Path)
|
|
if err == nil {
|
|
return c, lay, sudo, nil
|
|
}
|
|
if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo && canElevate() {
|
|
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, spec.Path)
|
|
c.Close()
|
|
sudo = true
|
|
if c, err = startController(spec, tag, cfg, remoteBin, true); err != nil {
|
|
return nil, nil, sudo, err
|
|
}
|
|
if lay, err = c.ProbeDisk(spec.Path); err == nil {
|
|
return c, lay, sudo, nil
|
|
}
|
|
}
|
|
c.Close()
|
|
return nil, nil, sudo, err
|
|
}
|
|
|
|
func reportMissingTools(d *DiskLayout) {
|
|
want := []string{"sfdisk", "ntfsclone", "partclone.extfs", "partclone.restore", "e2image"}
|
|
var miss []string
|
|
for _, t := range want {
|
|
if !d.Tools[t] {
|
|
miss = append(miss, t)
|
|
}
|
|
}
|
|
if len(miss) > 0 {
|
|
fmt.Fprintf(os.Stderr, " note: not present on source: %s (affected partitions fall back to raw block copy)\n",
|
|
strings.Join(miss, ", "))
|
|
}
|
|
}
|
|
|
|
func orDash(s string) string {
|
|
if s == "" {
|
|
return "-"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func intSet(xs []int) map[int]bool {
|
|
m := make(map[int]bool, len(xs))
|
|
for _, x := range xs {
|
|
m[x] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
func sortedParts(ps []Partition) []Partition {
|
|
out := append([]Partition(nil), ps...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start })
|
|
return out
|
|
}
|