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

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
}