no file based cloning any more
This commit is contained in:
parent
21c21e17dd
commit
2c3e341343
206
README.md
206
README.md
@ -3,18 +3,7 @@
|
|||||||
Block-level sync for a large file or block device between two machines (or
|
Block-level sync for a large file or block device between two machines (or
|
||||||
two paths on the same machine), driven from a third, passive "manager"
|
two paths on the same machine), driven from a third, passive "manager"
|
||||||
machine. Single static Go binary, no runtime dependencies beyond the
|
machine. Single static Go binary, no runtime dependencies beyond the
|
||||||
system `ssh` client for remote endpoints.
|
system `ssh` client for remote endpoints. Linux only.
|
||||||
|
|
||||||
Two subcommands:
|
|
||||||
|
|
||||||
- **`sync`** — mirror one file/device onto another (the original tool; see
|
|
||||||
most of this README).
|
|
||||||
- **`clone-disk`** — clone a whole system disk: boot record + partition
|
|
||||||
table + per-filesystem data, rebuilt on the target then filled from the
|
|
||||||
source. Same manager/agent/SSH/self-deploy model as `sync`; leans on the
|
|
||||||
standard disk tools already present on a rescue system (`sfdisk`,
|
|
||||||
`ntfsclone`, `partclone.*`, `e2image`, `rsync`, …). See
|
|
||||||
[clone-disk](#clone-disk) below.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@ -72,8 +61,8 @@ Two subcommands:
|
|||||||
self-deployed from an older build of this tool) is treated the same as
|
self-deployed from an older build of this tool) is treated the same as
|
||||||
"not runnable" and triggers the self-deploy above, so the remote binary
|
"not runnable" and triggers the self-deploy above, so the remote binary
|
||||||
is kept in sync with whatever you're running locally. Only the build
|
is kept in sync with whatever you're running locally. Only the build
|
||||||
timestamp is compared, not `GOOS`/`GOARCH` — those are expected to
|
timestamp is compared, not `GOARCH` — that is expected to differ across
|
||||||
differ across a cross-compiled deploy.
|
a cross-compiled deploy.
|
||||||
- **sudo:** if reading or writing an endpoint that is a **block device**
|
- **sudo:** if reading or writing an endpoint that is a **block device**
|
||||||
fails with a permission error, `--sudo=auto` (the default) transparently
|
fails with a permission error, `--sudo=auto` (the default) transparently
|
||||||
restarts that side's agent — and the helper it spawns on the peer —
|
restarts that side's agent — and the helper it spawns on the peer —
|
||||||
@ -84,8 +73,8 @@ Two subcommands:
|
|||||||
start; `--sudo=never` never does.
|
start; `--sudo=never` never does.
|
||||||
- Sizing rules:
|
- Sizing rules:
|
||||||
- Destination is a **block device**: it can't be resized, so if the
|
- Destination is a **block device**: it can't be resized, so if the
|
||||||
source is larger the job fails; otherwise exactly `min(source, dest)`
|
source is larger the job fails; otherwise exactly the source's size is
|
||||||
bytes are synced and the remainder of the device is left untouched.
|
synced and the remainder of the device is left untouched.
|
||||||
- Destination is a **regular file**: it's truncated (created if
|
- Destination is a **regular file**: it's truncated (created if
|
||||||
missing) to exactly the source's size, growing or shrinking it.
|
missing) to exactly the source's size, growing or shrinking it.
|
||||||
Shrinking an existing non-empty file prompts for confirmation unless
|
Shrinking an existing non-empty file prompts for confirmation unless
|
||||||
@ -97,17 +86,15 @@ Two subcommands:
|
|||||||
CGO_ENABLED=0 go build -o clonetool .
|
CGO_ENABLED=0 go build -o clonetool .
|
||||||
```
|
```
|
||||||
|
|
||||||
Cross-compile for another OS/arch by setting `GOOS`/`GOARCH` (no cgo, so
|
Cross-compile for another architecture by setting `GOARCH` (no cgo, so
|
||||||
these all work from any host):
|
this works from any host):
|
||||||
|
|
||||||
```
|
```
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o clonetool .
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o clonetool .
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o clonetool.exe .
|
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o clonetool-darwin .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`./build.sh` writes all three of the above (plus linux/amd64) into `dist/`.
|
`./build.sh` writes linux/amd64 and linux/arm64 binaries into `dist/`. It
|
||||||
It also stamps every binary it builds with the same build timestamp (via
|
also stamps every binary it builds with the same build timestamp (via
|
||||||
`-ldflags -X main.buildTime=...`), which is how the manager tells a stale
|
`-ldflags -X main.buildTime=...`), which is how the manager tells a stale
|
||||||
self-deployed remote binary apart from a current one (see "Version/staleness
|
self-deployed remote binary apart from a current one (see "Version/staleness
|
||||||
check" above) — build all your binaries for a given release with one
|
check" above) — build all your binaries for a given release with one
|
||||||
@ -119,171 +106,7 @@ this check (it only ever compares equal to another literal copy of the same
|
|||||||
Copy the resulting binary to the manager host. Source and destination
|
Copy the resulting binary to the manager host. Source and destination
|
||||||
hosts get it automatically (see self-deploy above), or place it yourself
|
hosts get it automatically (see self-deploy above), or place it yourself
|
||||||
and point `--remote-bin` at it. The binary is architecture-specific —
|
and point `--remote-bin` at it. The binary is architecture-specific —
|
||||||
cross-compile if your hosts differ. Self-deploy streams a POSIX shell
|
cross-compile if your hosts differ.
|
||||||
script over SSH, so a **Windows** host can be the manager or a local
|
|
||||||
endpoint but cannot be an automatic deploy target — put `clonetool.exe`
|
|
||||||
on it yourself and point `--remote-bin` at it.
|
|
||||||
|
|
||||||
## Windows
|
|
||||||
|
|
||||||
clonetool runs on Windows and can clone **physical drives and volumes**,
|
|
||||||
not just files:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Whole disk to an image file
|
|
||||||
clonetool sync --source \\.\PhysicalDrive2 --dest D:\backup\disk2.img
|
|
||||||
|
|
||||||
# Image file back onto a disk (must not be larger than the disk)
|
|
||||||
clonetool sync --source D:\backup\disk2.img --dest \\.\PhysicalDrive2
|
|
||||||
|
|
||||||
# A single volume
|
|
||||||
clonetool sync --source \\.\E: --dest \\.\F:
|
|
||||||
```
|
|
||||||
|
|
||||||
- Raw-disk paths are `\\.\PhysicalDrive<n>` (whole disk) or `\\.\<X>:` (a
|
|
||||||
volume). `\\?\` also works. Forward slashes are accepted.
|
|
||||||
- **Run from an elevated (Administrator) console** to open a raw disk.
|
|
||||||
There is no `sudo` fallback on Windows; `--sudo` is ignored. A
|
|
||||||
permission error tells you to elevate.
|
|
||||||
- A raw-disk **destination** should have no mounted filesystem in use
|
|
||||||
(take the disk offline in Disk Management, or target a volume that
|
|
||||||
nothing else has open) — Windows blocks writes to a disk region owned by
|
|
||||||
a mounted volume. Reading a live disk as the **source** is fine.
|
|
||||||
- Raw-disk I/O must be sector-aligned. `--block-size` must be a multiple
|
|
||||||
of the drive's sector size (512 or 4096); the default 4M is. clonetool
|
|
||||||
handles the final partial block itself.
|
|
||||||
- Device size is read with `IOCTL_DISK_GET_LENGTH_INFO`.
|
|
||||||
|
|
||||||
## clone-disk
|
|
||||||
|
|
||||||
`clonetool clone-disk --source LOC --dest LOC [options]` clones a whole
|
|
||||||
disk — the boot record, the partition table, and the contents of every
|
|
||||||
partition — by **rebuilding** the layout on the target and then filling it
|
|
||||||
from the source. Orchestrated from the manager exactly like `sync`: one
|
|
||||||
control agent per side, data straight between source and target (push, or
|
|
||||||
pull fallback), self-deploy, `--sudo` escalation.
|
|
||||||
|
|
||||||
It does not reimplement any filesystem knowledge — it calls the standard
|
|
||||||
tools a rescue system already has and just moves their bytes between the two
|
|
||||||
machines.
|
|
||||||
|
|
||||||
### What it copies
|
|
||||||
|
|
||||||
1. **Partition table.** The source agent runs `sfdisk -d` (the canonical
|
|
||||||
restorable dump for both GPT and MBR). The manager turns it into a
|
|
||||||
device-independent restore script — dropping the `device:` line and
|
|
||||||
`last-lba` so it re-sizes for the target, keeping the GPT disk GUID and
|
|
||||||
every partition GUID/PARTUUID (so existing `fstab` / BCD / GRUB
|
|
||||||
references still resolve) unless `--new-ids` is given. The target agent
|
|
||||||
feeds that to `sfdisk` and re-reads the table.
|
|
||||||
2. **Boot record / boot code.** The 440-byte MBR bootstrap on every disk,
|
|
||||||
plus — on MBR disks — the whole gap before the first partition (where
|
|
||||||
GRUB's `core.img` lives), are raw-copied through the same block-diff
|
|
||||||
engine `sync` uses, addressed as byte windows. On GPT disks the
|
|
||||||
GPT structures come from `sfdisk`/`sgdisk`; a BIOS-boot partition
|
|
||||||
(`EF02`) is always cloned raw.
|
|
||||||
3. **Partition data**, per partition, by the best available method:
|
|
||||||
- **fs-image** (default when the tool exists): stream a
|
|
||||||
filesystem-aware image — `ntfsclone` for NTFS, `partclone.<fs>` for
|
|
||||||
ext*/xfs/btrfs/f2fs/fat/exfat, `e2image` as an ext fallback. Only
|
|
||||||
used blocks move.
|
|
||||||
- **raw**: the `sync` block-diff engine over the partition's byte
|
|
||||||
window. Used for swap, unrecognised filesystems, `--raw N,…`, and
|
|
||||||
whenever no image tool is installed. Re-runs move only changed blocks.
|
|
||||||
- **file-level** (`--file-level N,…`): `mkfs` on the target + `rsync`
|
|
||||||
*(planned; not yet implemented — use `--raw` or an fs-image type)*.
|
|
||||||
|
|
||||||
### Target sizing (same rules as `sync`)
|
|
||||||
|
|
||||||
- **Target is a device:** never grown. If the source layout fits, it is
|
|
||||||
reproduced as-is and any trailing space on the target is left untouched.
|
|
||||||
If the last partition(s) overflow, `--allow-shrink` will shrink them
|
|
||||||
(filesystem then partition, from the last inward) with `ntfsresize` /
|
|
||||||
`resize2fs` — this **resizes the source filesystem in place** before
|
|
||||||
imaging, so it also needs `--yes`. Without `--allow-shrink` an
|
|
||||||
over-large source is a hard error naming the partition.
|
|
||||||
- **Target is a file:** created and truncated to just what the layout
|
|
||||||
needs (or `--image-size SIZE`). Shrinking an existing image prompts
|
|
||||||
unless `--yes`.
|
|
||||||
|
|
||||||
### NTFS / Windows partitions
|
|
||||||
|
|
||||||
- **From a Linux rescue system: fully supported.** `ntfsclone` clones
|
|
||||||
Windows NTFS partitions (incl. Win10/11) at cluster level, copying only
|
|
||||||
used clusters and preserving every NTFS feature. The volume must be
|
|
||||||
*clean* — Windows Fast Startup and hibernation leave NTFS dirty; boot
|
|
||||||
Windows once and shut down fully, or run `ntfsfix -d` first. `ntfsclone`
|
|
||||||
does not fix booting: keep the partition GUIDs (default) so the existing
|
|
||||||
BCD resolves, or run `bcdboot` from Windows recovery afterwards. This is
|
|
||||||
the same approach Clonezilla uses.
|
|
||||||
- **On Windows itself: raw / VSS only.** There is no `ntfsclone` on
|
|
||||||
Windows. `clone-disk` reads the partition table via
|
|
||||||
`IOCTL_DISK_GET_DRIVE_LAYOUT_EX`, reproduces it by raw-copying the
|
|
||||||
leading sectors (and the backup GPT), and block-clones each partition
|
|
||||||
as a byte window of `\\.\PhysicalDriveN`. With `--vss` (default) it
|
|
||||||
takes a Volume Shadow Copy of each NTFS volume first for a
|
|
||||||
crash-consistent point-in-time source. This is **not** free-space-aware
|
|
||||||
(a `$Bitmap`-driven skip may come later), and shrinking to a smaller
|
|
||||||
target is not supported when the source is native Windows — use a
|
|
||||||
target at least as large, or run the clone from a Linux rescue system
|
|
||||||
to get `ntfsclone`.
|
|
||||||
|
|
||||||
### Bootloader
|
|
||||||
|
|
||||||
By default `clone-disk` only *reproduces* boot structures and preserves
|
|
||||||
disk/partition IDs — enough for a like-for-like replacement disk to boot.
|
|
||||||
`--reinstall-bootloader` additionally mounts the cloned root (+ ESP),
|
|
||||||
bind-mounts `/dev /proc /sys`, and runs `grub-install` + `update-grub` /
|
|
||||||
`grub-mkconfig` in a chroot (Linux targets). It is best-effort and never
|
|
||||||
fails the clone. For Windows, run `bcdboot C:\Windows /s S: /f ALL` from a
|
|
||||||
recovery environment.
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```
|
|
||||||
# Whole disk to an image file (image is sized to the layout)
|
|
||||||
clonetool clone-disk --source /dev/sda --dest /srv/sda.img
|
|
||||||
|
|
||||||
# Disk to disk on another host, orchestrated from a third machine
|
|
||||||
clonetool clone-disk --source box1:/dev/nvme0n1 --dest box2:/dev/nvme0n1
|
|
||||||
|
|
||||||
# Onto a smaller SSD, shrinking the last (data) partition to fit
|
|
||||||
clonetool clone-disk --source /dev/sda --dest /dev/sdb --allow-shrink --yes
|
|
||||||
|
|
||||||
# Fresh IDs so the clone can sit next to the original
|
|
||||||
clonetool clone-disk --source /dev/sda --dest /dev/sdb --new-ids
|
|
||||||
|
|
||||||
# Force a raw block clone of a partition an image tool would otherwise handle
|
|
||||||
clonetool clone-disk --source /dev/sda --dest /dev/sdb --raw 2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tools the agents call (checked at probe time, reported if missing)
|
|
||||||
|
|
||||||
`sfdisk`, `sgdisk`, `partprobe`/`blockdev`, `blkid`, `lsblk`, `losetup`,
|
|
||||||
`ntfsclone`, `ntfsresize`, `ntfsfix`, `partclone.*`, `e2image`,
|
|
||||||
`resize2fs`, `dumpe2fs`, `e2fsck`, `mkfs.*`, `mount`/`umount`, `rsync`,
|
|
||||||
`grub-install`, `update-grub`/`grub-mkconfig`. Windows: `vssadmin` /
|
|
||||||
`wmic`, `bcdboot`, `diskpart`. A missing tool just means the affected
|
|
||||||
partitions fall back to a raw block copy (or the run stops with a clear
|
|
||||||
message if that isn't safe, e.g. a partition that must shrink).
|
|
||||||
|
|
||||||
### clone-disk options
|
|
||||||
|
|
||||||
| Flag | Default | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `--parts LIST` | all | Only clone these partition numbers (data only; the table still lists them all). |
|
|
||||||
| `--raw LIST` | — | Force a raw block clone for these partitions. |
|
|
||||||
| `--file-level LIST` | — | `mkfs` + `rsync` these partitions (not yet implemented). |
|
|
||||||
| `--file-level-auto` | off | Use file-level for fs types with no image cloner. |
|
|
||||||
| `--allow-shrink` | off | Permit shrinking trailing partitions to fit a smaller target (resizes the **source** fs in place; needs `--yes`). |
|
|
||||||
| `--no-shrink` | off | Never shrink; fail if the target is too small. |
|
|
||||||
| `--new-ids` | off | Randomize the GPT disk GUID / MBR signature on the target. |
|
|
||||||
| `--reinstall-bootloader` | off | Run `grub-install` / `grub-mkconfig` on the target after copy (Linux). |
|
|
||||||
| `--vss` | on | Windows source: take a Volume Shadow Copy per NTFS volume. |
|
|
||||||
| `--image-size SIZE` | auto | File target: image size (default = enough for the layout). |
|
|
||||||
|
|
||||||
`--block-size --job --yes --sudo --deploy --ssh --ssh-opt --remote-bin
|
|
||||||
--connect-timeout --manager-host` mean the same as for `sync`.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -298,6 +121,9 @@ clonetool sync --source LOC --dest LOC [options]
|
|||||||
# Same machine
|
# Same machine
|
||||||
clonetool sync --source /dev/sda --dest /dev/sdb
|
clonetool sync --source /dev/sda --dest /dev/sdb
|
||||||
|
|
||||||
|
# Whole disk to an image file
|
||||||
|
clonetool sync --source /dev/sda --dest /srv/sda.img
|
||||||
|
|
||||||
# Two remote machines, orchestrated from a third
|
# Two remote machines, orchestrated from a third
|
||||||
clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img
|
clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img
|
||||||
|
|
||||||
@ -332,9 +158,7 @@ and `wr(dst)` is the destination actually writing changed blocks.
|
|||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
- Block-device size detection is implemented on Linux (`BLKGETSIZE64`) and
|
- Block-device size detection uses `BLKGETSIZE64`; the tool is Linux only.
|
||||||
Windows (`IOCTL_DISK_GET_LENGTH_INFO`). On other systems only regular
|
|
||||||
files can be synced.
|
|
||||||
- If a destination path doesn't exist yet, it's created as a regular
|
- If a destination path doesn't exist yet, it's created as a regular
|
||||||
file — clonetool won't create device nodes, so double-check device
|
file — clonetool won't create device nodes, so double-check device
|
||||||
paths for typos before running.
|
paths for typos before running.
|
||||||
|
|||||||
45
agent.go
45
agent.go
@ -16,15 +16,11 @@ import (
|
|||||||
|
|
||||||
func cmdAgent(args []string) error {
|
func cmdAgent(args []string) error {
|
||||||
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
|
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||||
role := fs.String("role", "", "control|sink|source-stream|fs-send|fs-recv (internal)")
|
role := fs.String("role", "", "control|sink|source-stream (internal)")
|
||||||
path := fs.String("path", "", "path to read/write")
|
path := fs.String("path", "", "path to read/write")
|
||||||
size := fs.Int64("size", 0, "total sync size in bytes")
|
size := fs.Int64("size", 0, "total sync size in bytes")
|
||||||
base := fs.Int64("base", 0, "byte offset the window starts at (clone-disk boot region / offset partition)")
|
base := fs.Int64("base", 0, "byte offset the window starts at")
|
||||||
blockSize := fs.Int64("block-size", defaultBlockSize, "block size in bytes")
|
blockSize := fs.Int64("block-size", defaultBlockSize, "block size in bytes")
|
||||||
fsType := fs.String("fs", "", "filesystem type (fs-send/fs-recv)")
|
|
||||||
fsTool := fs.String("fstool", "", "fs-image tool family (fs-send/fs-recv)")
|
|
||||||
peerDisk := fs.String("peerdisk", "", "this helper's whole-disk/image path (fs-send/fs-recv)")
|
|
||||||
shrinkTo := fs.Int64("shrink", 0, "shrink this fs to N bytes before sending (fs-send)")
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -36,12 +32,6 @@ func cmdAgent(args []string) error {
|
|||||||
return runSinkRole(*path, *base, *size, *blockSize)
|
return runSinkRole(*path, *base, *size, *blockSize)
|
||||||
case "source-stream":
|
case "source-stream":
|
||||||
return runSourceStreamRole(*path, *base, *size, *blockSize)
|
return runSourceStreamRole(*path, *base, *size, *blockSize)
|
||||||
case "fs-send":
|
|
||||||
n, _ := strconv.Atoi(*path)
|
|
||||||
return runFSSendRole(n, *fsType, *fsTool, *peerDisk, *shrinkTo)
|
|
||||||
case "fs-recv":
|
|
||||||
n, _ := strconv.Atoi(*path)
|
|
||||||
return runFSRecvRole(n, *fsType, *fsTool, *peerDisk)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("agent: unknown or missing --role %q", *role)
|
return fmt.Errorf("agent: unknown or missing --role %q", *role)
|
||||||
}
|
}
|
||||||
@ -77,16 +67,7 @@ func runControlAgent() error {
|
|||||||
runPushDriver(m, out)
|
runPushDriver(m, out)
|
||||||
case msgConnectPull:
|
case msgConnectPull:
|
||||||
runPullDriver(m, out)
|
runPullDriver(m, out)
|
||||||
case msgProbeDisk:
|
|
||||||
handleProbeDisk(out, m)
|
|
||||||
case msgBuildLayout:
|
|
||||||
handleBuildLayout(out, m)
|
|
||||||
case msgClonePartition:
|
|
||||||
handleClonePartition(out, m)
|
|
||||||
case msgReinstallBoot:
|
|
||||||
handleReinstallBoot(out, m)
|
|
||||||
case msgClose:
|
case msgClose:
|
||||||
detachAllDisks()
|
|
||||||
_ = out.WriteJSON(CtrlMsg{Type: msgBye})
|
_ = out.WriteJSON(CtrlMsg{Type: msgBye})
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
@ -579,11 +560,33 @@ func runSinkRole(path string, base, size, blockSize int64) error {
|
|||||||
// block is always hashed before a write for it can arrive (the source
|
// block is always hashed before a write for it can arrive (the source
|
||||||
// only sends after seeing that block's hash), so the two accesses to f
|
// only sends after seeing that block's hash), so the two accesses to f
|
||||||
// never race on the same region.
|
// never race on the same region.
|
||||||
|
// While the destination scan runs, emit a heartbeat on a fixed cadence
|
||||||
|
// even if streamHashBlocks is parked inside one slow ReadAt (a big /
|
||||||
|
// non-sparse / remote target). The push driver's idle watchdog counts any
|
||||||
|
// frame from the sink as activity, so this stops a legitimately slow scan
|
||||||
|
// from being declared a stalled sink during a check-only run, where there
|
||||||
|
// is no DATA/ACK traffic to feed the watchdog. A sink that has actually
|
||||||
|
// died stops heartbeating and its pipe closes, so the watchdog still fires.
|
||||||
|
scanDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(10 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-scanDone:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "scan"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
hashErrCh := make(chan error, 1)
|
hashErrCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
err := streamHashBlocks(f, base, size, blockSize, align, func(bh blockHash) error {
|
err := streamHashBlocks(f, base, size, blockSize, align, func(bh blockHash) error {
|
||||||
return out.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash))
|
return out.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash))
|
||||||
}, scanProgressEmitter(out))
|
}, scanProgressEmitter(out))
|
||||||
|
close(scanDone)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = out.WriteFrame(frameHashDone, nil)
|
err = out.WriteFrame(frameHashDone, nil)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
375
agent_disk.go
375
agent_disk.go
@ -1,375 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// reinstallBootloader mounts the freshly cloned root (and ESP), bind-mounts
|
|
||||||
// the API filesystems, and runs grub-install + grub-mkconfig inside a chroot.
|
|
||||||
// It is best-effort: it returns a human summary and never a hard error, so a
|
|
||||||
// clone still completes even when boot repair cannot.
|
|
||||||
func reinstallBootloader(diskPath, optsJSON string) string {
|
|
||||||
var o bootOpts
|
|
||||||
if err := json.Unmarshal([]byte(optsJSON), &o); err != nil {
|
|
||||||
return "skipped: bad boot opts: " + err.Error()
|
|
||||||
}
|
|
||||||
if o.RootPart == 0 {
|
|
||||||
return "skipped: no root partition identified (pass --reinstall-bootloader only for a Linux system disk)"
|
|
||||||
}
|
|
||||||
att, err := attachDisk(diskPath, true)
|
|
||||||
if err != nil {
|
|
||||||
return "skipped: attach: " + err.Error()
|
|
||||||
}
|
|
||||||
defer att.cleanup()
|
|
||||||
|
|
||||||
rootNode := partNode(att.blockPath, o.RootPart)
|
|
||||||
root, umount, err := mountAt(rootNode, false)
|
|
||||||
if err != nil {
|
|
||||||
return "skipped: mount root: " + err.Error()
|
|
||||||
}
|
|
||||||
defer umount()
|
|
||||||
|
|
||||||
if o.ESPPart != 0 {
|
|
||||||
espNode := partNode(att.blockPath, o.ESPPart)
|
|
||||||
espDir := root + "/boot/efi"
|
|
||||||
_ = os.MkdirAll(espDir, 0755)
|
|
||||||
if out, err := exec.Command("mount", espNode, espDir).CombinedOutput(); err != nil {
|
|
||||||
return "skipped: mount ESP: " + strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
defer exec.Command("umount", espDir).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
var log strings.Builder
|
|
||||||
for _, d := range []string{"dev", "proc", "sys", "run"} {
|
|
||||||
_ = exec.Command("mount", "--bind", "/"+d, root+"/"+d).Run()
|
|
||||||
defer exec.Command("umount", "-l", root+"/"+d).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
grubTarget := att.blockPath
|
|
||||||
if isDevicePath(diskPath) || looksLikeBlockDevice(diskPath) {
|
|
||||||
grubTarget = diskPath
|
|
||||||
}
|
|
||||||
giArgs := []string{root, "grub-install", "--recheck"}
|
|
||||||
if o.UEFI {
|
|
||||||
giArgs = append(giArgs, "--target=x86_64-efi", "--efi-directory=/boot/efi", "--removable")
|
|
||||||
} else {
|
|
||||||
giArgs = append(giArgs, grubTarget)
|
|
||||||
}
|
|
||||||
if out, err := exec.Command("chroot", giArgs...).CombinedOutput(); err != nil {
|
|
||||||
fmt.Fprintf(&log, "grub-install failed: %s; ", strings.TrimSpace(string(out)))
|
|
||||||
} else {
|
|
||||||
log.WriteString("grub-install ok; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
mkcfg := "grub-mkconfig"
|
|
||||||
if _, err := exec.LookPath("update-grub"); err == nil {
|
|
||||||
mkcfg = "update-grub"
|
|
||||||
}
|
|
||||||
args := []string{root, mkcfg}
|
|
||||||
if mkcfg == "grub-mkconfig" {
|
|
||||||
args = append(args, "-o", "/boot/grub/grub.cfg")
|
|
||||||
}
|
|
||||||
if out, err := exec.Command("chroot", args...).CombinedOutput(); err != nil {
|
|
||||||
fmt.Fprintf(&log, "%s failed: %s", mkcfg, strings.TrimSpace(string(out)))
|
|
||||||
} else {
|
|
||||||
log.WriteString(mkcfg + " ok")
|
|
||||||
}
|
|
||||||
return log.String()
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
// reinstallBootloader is Linux-only for now. On Windows the recommended path
|
|
||||||
// is to preserve the disk signature / partition GUIDs (clone-disk does this by
|
|
||||||
// raw-copying the leading sectors) so the existing BCD store still resolves,
|
|
||||||
// and otherwise run `bcdboot W:\Windows /s S: /f ALL` from a WinPE/recovery
|
|
||||||
// environment by hand.
|
|
||||||
func reinstallBootloader(diskPath, optsJSON string) string {
|
|
||||||
return "skipped: --reinstall-bootloader is only implemented on Linux; " +
|
|
||||||
"run bcdboot from Windows recovery, or grub-install from a Linux rescue system"
|
|
||||||
}
|
|
||||||
4
build.sh
4
build.sh
@ -15,10 +15,6 @@ build() {
|
|||||||
|
|
||||||
build linux amd64 clonetool-linux-amd64
|
build linux amd64 clonetool-linux-amd64
|
||||||
build linux arm64 clonetool-linux-arm64
|
build linux arm64 clonetool-linux-arm64
|
||||||
build windows amd64 clonetool-windows-amd64.exe
|
|
||||||
build windows arm64 clonetool-windows-arm64.exe
|
|
||||||
build darwin amd64 clonetool-darwin-amd64
|
|
||||||
build darwin arm64 clonetool-darwin-arm64
|
|
||||||
|
|
||||||
echo "done:"
|
echo "done:"
|
||||||
ls -la dist/
|
ls -la dist/
|
||||||
|
|||||||
261
clonedisk.go
261
clonedisk.go
@ -1,261 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
117
clonedrive.go
117
clonedrive.go
@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// windowJob raw-copies a byte window [srcBase,srcBase+size) of the source
|
|
||||||
// disk onto [dstBase,dstBase+size) of the target, through the same
|
|
||||||
// push/pull block-diff engine `sync` uses. Used for boot regions and for
|
|
||||||
// partitions cloned with method "raw".
|
|
||||||
type windowJob struct {
|
|
||||||
srcPath, dstPath string
|
|
||||||
srcBase, dstBase int64
|
|
||||||
size int64
|
|
||||||
bothLocal bool
|
|
||||||
blockSize int64
|
|
||||||
cfg *SyncConfig
|
|
||||||
srcHost, srcUser string
|
|
||||||
dstHost, dstUser string
|
|
||||||
srcRemoteBin string
|
|
||||||
dstRemoteBin string
|
|
||||||
srcSudo, dstSudo bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWindowTransfer(srcCtrl, dstCtrl *Controller, j windowJob) error {
|
|
||||||
pp := newProgressPrinter(j.blockSize)
|
|
||||||
cb := transferCallbacks{onProgress: pp.print}
|
|
||||||
|
|
||||||
pushReq := CtrlMsg{
|
|
||||||
Path: j.srcPath, Base: j.srcBase, PeerBase: j.dstBase, Size: j.size, BlockSize: j.blockSize,
|
|
||||||
PeerHost: j.dstHost, PeerUser: j.dstUser, PeerPath: j.dstPath, PeerLocal: j.bothLocal,
|
|
||||||
RemoteBin: j.dstRemoteBin, SSHBin: j.cfg.SSHBin, SSHOpts: j.cfg.SSHOpts,
|
|
||||||
ConnectTimeoutSec: j.cfg.ConnectTimeoutSec, Sudo: j.dstSudo,
|
|
||||||
}
|
|
||||||
ok, reason, err := srcCtrl.ConnectPush(pushReq, cb)
|
|
||||||
if err != nil {
|
|
||||||
pp.finish()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
pp.finish()
|
|
||||||
fmt.Fprintf(os.Stderr, " push not possible (%s); pulling instead ...\n", reason)
|
|
||||||
pullReq := CtrlMsg{
|
|
||||||
Path: j.dstPath, Base: j.dstBase, PeerBase: j.srcBase, Size: j.size, BlockSize: j.blockSize,
|
|
||||||
PeerHost: j.srcHost, PeerUser: j.srcUser, PeerPath: j.srcPath, PeerLocal: j.bothLocal,
|
|
||||||
RemoteBin: j.srcRemoteBin, SSHBin: j.cfg.SSHBin, SSHOpts: j.cfg.SSHOpts,
|
|
||||||
ConnectTimeoutSec: j.cfg.ConnectTimeoutSec, Sudo: j.srcSudo,
|
|
||||||
}
|
|
||||||
ok2, reason2, err2 := dstCtrl.ConnectPull(pullReq, cb)
|
|
||||||
if err2 != nil {
|
|
||||||
pp.finish()
|
|
||||||
return err2
|
|
||||||
}
|
|
||||||
if !ok2 {
|
|
||||||
pp.finish()
|
|
||||||
return fmt.Errorf("no direct connection either way (push: %s; pull: %s)", reason, reason2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pp.finish()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cloneCtx carries the transport context for an fs-image partition clone.
|
|
||||||
type cloneCtx struct {
|
|
||||||
srcPath, dstPath string
|
|
||||||
bothLocal bool
|
|
||||||
cfg *SyncConfig
|
|
||||||
srcHost, srcUser string
|
|
||||||
dstHost, dstUser string
|
|
||||||
srcRemoteBin string
|
|
||||||
dstRemoteBin string
|
|
||||||
srcSudo, dstSudo bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// runFSImagePartition streams a filesystem image for one partition, trying
|
|
||||||
// push (source drives) then pull (dest drives).
|
|
||||||
func runFSImagePartition(srcCtrl, dstCtrl *Controller, pp plannedPart, c cloneCtx) error {
|
|
||||||
opts := fsCloneOpts{FSType: pp.FSType, Tool: pp.Tool, SizeBytes: pp.SrcSizeB, ShrinkToBytes: pp.ShrinkToB}
|
|
||||||
prog := newProgressPrinter(1 << 20)
|
|
||||||
cb := transferCallbacks{onProgress: prog.printBytes}
|
|
||||||
|
|
||||||
pushReq := CtrlMsg{
|
|
||||||
Method: "fs-image", PartIndex: pp.Num, Options: opts.encode(),
|
|
||||||
Path: c.srcPath, PeerPath: c.dstPath, PeerLocal: c.bothLocal,
|
|
||||||
PeerHost: c.dstHost, PeerUser: c.dstUser,
|
|
||||||
RemoteBin: c.dstRemoteBin, SSHBin: c.cfg.SSHBin, SSHOpts: c.cfg.SSHOpts,
|
|
||||||
ConnectTimeoutSec: c.cfg.ConnectTimeoutSec, Sudo: c.dstSudo,
|
|
||||||
}
|
|
||||||
ok, reason, err := srcCtrl.ClonePartition(pushReq, cb)
|
|
||||||
if err != nil {
|
|
||||||
prog.finish()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
prog.finish()
|
|
||||||
fmt.Fprintf(os.Stderr, " fs-image push not possible (%s); pulling instead ...\n", reason)
|
|
||||||
pullReq := CtrlMsg{
|
|
||||||
Method: "fs-image", Reason: "pull", PartIndex: pp.Num, Options: opts.encode(),
|
|
||||||
Path: c.dstPath, PeerPath: c.srcPath, PeerLocal: c.bothLocal,
|
|
||||||
PeerHost: c.srcHost, PeerUser: c.srcUser,
|
|
||||||
RemoteBin: c.srcRemoteBin, SSHBin: c.cfg.SSHBin, SSHOpts: c.cfg.SSHOpts,
|
|
||||||
ConnectTimeoutSec: c.cfg.ConnectTimeoutSec, Sudo: c.srcSudo,
|
|
||||||
}
|
|
||||||
ok2, reason2, err2 := dstCtrl.ClonePartition(pullReq, cb)
|
|
||||||
if err2 != nil {
|
|
||||||
prog.finish()
|
|
||||||
return err2
|
|
||||||
}
|
|
||||||
if !ok2 {
|
|
||||||
prog.finish()
|
|
||||||
return fmt.Errorf("fs-image failed both ways (push: %s; pull: %s)", reason, reason2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prog.finish()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
63
control.go
63
control.go
@ -135,69 +135,6 @@ func (c *Controller) agentErr(op, path string, resp CtrlMsg) error {
|
|||||||
return fmt.Errorf("%s: %s %s: %s", c.tag, op, path, resp.Message)
|
return fmt.Errorf("%s: %s %s: %s", c.tag, op, path, resp.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProbeDisk asks the agent to enumerate a whole disk (partition table, boot
|
|
||||||
// regions, per-partition filesystem facts, tool availability).
|
|
||||||
func (c *Controller) ProbeDisk(path string) (*DiskLayout, error) {
|
|
||||||
resp, err := c.call(CtrlMsg{Type: msgProbeDisk, Path: path})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch resp.Type {
|
|
||||||
case msgProbeDiskOK:
|
|
||||||
return parseDiskLayoutJSON(resp.LayoutJSON)
|
|
||||||
case msgError:
|
|
||||||
return nil, c.agentErr("probe", path, resp)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%s: unexpected response %q to probe_disk", c.tag, resp.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildLayout asks the agent to create/size an image and write the partition
|
|
||||||
// table (optsJSON is a buildOpts blob).
|
|
||||||
func (c *Controller) BuildLayout(path, optsJSON string) error {
|
|
||||||
resp, err := c.call(CtrlMsg{Type: msgBuildLayout, Path: path, Options: optsJSON})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch resp.Type {
|
|
||||||
case msgBuildLayoutOK:
|
|
||||||
return nil
|
|
||||||
case msgError:
|
|
||||||
return c.agentErr("build_layout", path, resp)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("%s: unexpected response %q to build_layout", c.tag, resp.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClonePartition drives one fs-image or file-level partition clone. dir is
|
|
||||||
// "push" (send to source agent) or "pull" (send to dest agent).
|
|
||||||
func (c *Controller) ClonePartition(req CtrlMsg, cb transferCallbacks) (ok bool, reason string, err error) {
|
|
||||||
req.Type = msgClonePartition
|
|
||||||
okType, failType := msgPushOK, msgPushFailed
|
|
||||||
if req.Reason == "pull" {
|
|
||||||
okType, failType = msgPullOK, msgPullFailed
|
|
||||||
}
|
|
||||||
return c.connectAndPump(req, cb, okType, failType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReinstallBoot asks the (dest) agent to run grub-install/bcdboot; the
|
|
||||||
// returned string is a human summary. Best-effort — never returns an error
|
|
||||||
// for a boot-repair failure, only for a transport failure.
|
|
||||||
func (c *Controller) ReinstallBoot(path, optsJSON string) (string, error) {
|
|
||||||
resp, err := c.call(CtrlMsg{Type: msgReinstallBoot, Path: path, Options: optsJSON})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
switch resp.Type {
|
|
||||||
case msgReinstallBootOK:
|
|
||||||
return resp.Message, nil
|
|
||||||
case msgError:
|
|
||||||
return "", c.agentErr("reinstall_boot", path, resp)
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("%s: unexpected response %q to reinstall_boot", c.tag, resp.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// transferCallbacks receives streaming updates while a connect_push or
|
// transferCallbacks receives streaming updates while a connect_push or
|
||||||
// connect_pull is in flight.
|
// connect_pull is in flight.
|
||||||
type transferCallbacks struct {
|
type transferCallbacks struct {
|
||||||
|
|||||||
39
ctrlmsg.go
39
ctrlmsg.go
@ -19,17 +19,12 @@ type CtrlMsg struct {
|
|||||||
// connect_push (-> source agent) / connect_pull (-> dest agent)
|
// connect_push (-> source agent) / connect_pull (-> dest agent)
|
||||||
PeerHost string `json:"peerHost,omitempty"`
|
PeerHost string `json:"peerHost,omitempty"`
|
||||||
PeerUser string `json:"peerUser,omitempty"`
|
PeerUser string `json:"peerUser,omitempty"`
|
||||||
PeerPort int `json:"peerPort,omitempty"`
|
|
||||||
PeerPath string `json:"peerPath,omitempty"`
|
PeerPath string `json:"peerPath,omitempty"`
|
||||||
// Base/Length restrict a push/pull transfer to a byte window of the
|
// Base/PeerBase shift every block offset on the local/peer side. Zero on
|
||||||
// path (offset Base, Length bytes). Used by clone-disk to raw-copy a
|
// both sides (the whole-file case) is what `sync` uses; the sink and
|
||||||
// boot region or an offset-addressed partition through the same engine.
|
// source-stream roles still accept a non-zero base for a windowed copy.
|
||||||
// Size stays the *window* length for the loop math; Base shifts every
|
|
||||||
// block offset. Zero Base + Length==Size is the whole-file case that
|
|
||||||
// `sync` uses unchanged.
|
|
||||||
Base int64 `json:"base,omitempty"`
|
Base int64 `json:"base,omitempty"`
|
||||||
PeerBase int64 `json:"peerBase,omitempty"`
|
PeerBase int64 `json:"peerBase,omitempty"`
|
||||||
Length int64 `json:"length,omitempty"`
|
|
||||||
// PeerLocal is set when both source and dest are local to the manager,
|
// PeerLocal is set when both source and dest are local to the manager,
|
||||||
// so the driver (itself already a local child of the manager) can spawn
|
// so the driver (itself already a local child of the manager) can spawn
|
||||||
// the sink/source-stream helper as a plain local subprocess instead of
|
// the sink/source-stream helper as a plain local subprocess instead of
|
||||||
@ -66,24 +61,6 @@ type CtrlMsg struct {
|
|||||||
SrcRead int64 `json:"srcRead,omitempty"`
|
SrcRead int64 `json:"srcRead,omitempty"`
|
||||||
Written int64 `json:"written,omitempty"`
|
Written int64 `json:"written,omitempty"`
|
||||||
TotalBlocks int64 `json:"totalBlocks,omitempty"`
|
TotalBlocks int64 `json:"totalBlocks,omitempty"`
|
||||||
BytesCopied int64 `json:"bytesCopied,omitempty"`
|
|
||||||
BytesTotal int64 `json:"bytesTotal,omitempty"`
|
|
||||||
|
|
||||||
// clone-disk staging: which partition/step of the whole-disk job the
|
|
||||||
// progress or status refers to. Stage is a short human label
|
|
||||||
// ("partition table", "boot region", "ntfs p2", "bootloader").
|
|
||||||
Stage string `json:"stage,omitempty"`
|
|
||||||
PartIndex int `json:"partIndex,omitempty"`
|
|
||||||
PartTotal int `json:"partTotal,omitempty"`
|
|
||||||
|
|
||||||
// clone-disk payloads: probe result / requested target layout, both
|
|
||||||
// carried as embedded JSON so CtrlMsg stays a flat envelope.
|
|
||||||
LayoutJSON string `json:"layoutJSON,omitempty"`
|
|
||||||
Method string `json:"method,omitempty"` // partition clone method: "fs-image" | "raw" | "file-level"
|
|
||||||
Options string `json:"options,omitempty"` // JSON of the build/clone options block
|
|
||||||
|
|
||||||
// log
|
|
||||||
Level string `json:"level,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -98,17 +75,7 @@ const (
|
|||||||
msgPullOK = "pull_ok"
|
msgPullOK = "pull_ok"
|
||||||
msgPullFailed = "pull_failed"
|
msgPullFailed = "pull_failed"
|
||||||
msgProgress = "progress"
|
msgProgress = "progress"
|
||||||
msgLog = "log"
|
|
||||||
msgError = "error"
|
msgError = "error"
|
||||||
msgClose = "close"
|
msgClose = "close"
|
||||||
msgBye = "bye"
|
msgBye = "bye"
|
||||||
|
|
||||||
// clone-disk control verbs
|
|
||||||
msgProbeDisk = "probe_disk"
|
|
||||||
msgProbeDiskOK = "probe_disk_ok"
|
|
||||||
msgBuildLayout = "build_layout"
|
|
||||||
msgBuildLayoutOK = "build_layout_ok"
|
|
||||||
msgClonePartition = "clone_partition"
|
|
||||||
msgReinstallBoot = "reinstall_boot"
|
|
||||||
msgReinstallBootOK = "reinstall_boot_ok"
|
|
||||||
)
|
)
|
||||||
|
|||||||
25
device.go
25
device.go
@ -13,20 +13,16 @@ type PathInfo struct {
|
|||||||
Size int64
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func statPath(path string) (PathInfo, error) {
|
// alignmentFor returns the offset/length alignment a path's handle requires
|
||||||
// A raw disk handle (Windows \\.\PhysicalDrive0, \\.\C:) is not something
|
// for positioned reads and writes. Linux block devices accept ordinary
|
||||||
// os.Stat can describe, so ask the platform for its size directly.
|
// buffered pread/pwrite at any alignment, so there is nothing to round to.
|
||||||
if isDevicePath(path) {
|
func alignmentFor(string) int64 { return 1 }
|
||||||
size, err := blockDeviceSize(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return PathInfo{Exists: false}, nil
|
|
||||||
}
|
|
||||||
return PathInfo{}, fmt.Errorf("stat device %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return PathInfo{Exists: true, IsDevice: true, Size: size}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// canElevate reports whether a permission failure opening a device is worth
|
||||||
|
// retrying under `sudo` (see --sudo).
|
||||||
|
func canElevate() bool { return true }
|
||||||
|
|
||||||
|
func statPath(path string) (PathInfo, error) {
|
||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@ -63,9 +59,6 @@ func prepareDest(path string, targetSize int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if isDevicePath(path) && !info.Exists {
|
|
||||||
return fmt.Errorf("destination device %s not found", path)
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open/create %s: %w", path, err)
|
return fmt.Errorf("open/create %s: %w", path, err)
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
//go:build !linux && !windows
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func blockDeviceSize(path string) (int64, error) {
|
|
||||||
return 0, fmt.Errorf("block device size detection is only implemented on linux and windows (got path %s)", path)
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
// isDevicePath reports whether a path must be treated as a raw device from
|
|
||||||
// its string form alone. On unix, os.Stat's mode bits already identify
|
|
||||||
// device nodes accurately (symlinks, non-standard locations and all), so
|
|
||||||
// this is always false and statPath relies on those instead.
|
|
||||||
func isDevicePath(string) bool { return false }
|
|
||||||
|
|
||||||
// alignmentFor returns the offset/length alignment a path's handle requires
|
|
||||||
// for positioned reads and writes. Unix block devices accept ordinary
|
|
||||||
// buffered pread/pwrite at any alignment, so there is nothing to round to.
|
|
||||||
func alignmentFor(string) int64 { return 1 }
|
|
||||||
|
|
||||||
// canElevate reports whether a permission failure opening a device is worth
|
|
||||||
// retrying under `sudo` (see --sudo). Always true on unix.
|
|
||||||
func canElevate() bool { return true }
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// isDevicePath recognises the Win32 device namespaces clonetool can sync
|
|
||||||
// against by string form: \\.\PhysicalDrive0 (a whole disk) and \\.\C: (a
|
|
||||||
// volume). Both the \\.\ and \\?\ prefixes, and their forward-slash
|
|
||||||
// spellings, are accepted. os.Stat can't describe these paths, so statPath
|
|
||||||
// keys off this instead of mode bits on Windows.
|
|
||||||
func isDevicePath(path string) bool {
|
|
||||||
p := strings.ReplaceAll(path, "/", `\`)
|
|
||||||
return strings.HasPrefix(p, `\\.\`) || strings.HasPrefix(p, `\\?\`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// canElevate is false on Windows: there is no `sudo` equivalent to re-exec
|
|
||||||
// under, so a permission failure opening a raw disk is reported with a hint
|
|
||||||
// to run from an elevated console instead of being retried.
|
|
||||||
func canElevate() bool { return false }
|
|
||||||
|
|
||||||
const (
|
|
||||||
ioctlDiskGetLengthInfo = 0x0007405C // IOCTL_DISK_GET_LENGTH_INFO
|
|
||||||
ioctlDiskGetDriveGeometry = 0x00070000 // IOCTL_DISK_GET_DRIVE_GEOMETRY
|
|
||||||
)
|
|
||||||
|
|
||||||
// diskGeometry mirrors DISK_GEOMETRY (24 bytes; Cylinders is a LARGE_INTEGER).
|
|
||||||
type diskGeometry struct {
|
|
||||||
Cylinders int64
|
|
||||||
MediaType uint32
|
|
||||||
TracksPerCylinder uint32
|
|
||||||
SectorsPerTrack uint32
|
|
||||||
BytesPerSector uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// openDeviceHandle opens path for a metadata ioctl only: zero access rights
|
|
||||||
// (which need no privilege and don't require the volume to be unlocked) and
|
|
||||||
// shared read/write so it doesn't disturb a mounted filesystem.
|
|
||||||
func openDeviceHandle(path string) (syscall.Handle, error) {
|
|
||||||
p, err := syscall.UTF16PtrFromString(path)
|
|
||||||
if err != nil {
|
|
||||||
return syscall.InvalidHandle, err
|
|
||||||
}
|
|
||||||
return syscall.CreateFile(p, 0,
|
|
||||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil,
|
|
||||||
syscall.OPEN_EXISTING, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockDeviceSize returns the byte length of a physical drive or volume via
|
|
||||||
// DeviceIoControl(IOCTL_DISK_GET_LENGTH_INFO).
|
|
||||||
func blockDeviceSize(path string) (int64, error) {
|
|
||||||
h, err := openDeviceHandle(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, &os.PathError{Op: "open", Path: path, Err: err}
|
|
||||||
}
|
|
||||||
defer syscall.CloseHandle(h)
|
|
||||||
|
|
||||||
var length int64 // GET_LENGTH_INFORMATION is a single LARGE_INTEGER
|
|
||||||
var ret uint32
|
|
||||||
err = syscall.DeviceIoControl(h, ioctlDiskGetLengthInfo,
|
|
||||||
nil, 0,
|
|
||||||
(*byte)(unsafe.Pointer(&length)), uint32(unsafe.Sizeof(length)),
|
|
||||||
&ret, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("IOCTL_DISK_GET_LENGTH_INFO %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return length, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// alignmentFor returns the sector size a raw disk handle's positioned reads
|
|
||||||
// and writes must be aligned to; 1 for an ordinary file path. It falls back
|
|
||||||
// to 512 if the geometry query fails.
|
|
||||||
func alignmentFor(path string) int64 {
|
|
||||||
if !isDevicePath(path) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
h, err := openDeviceHandle(path)
|
|
||||||
if err != nil {
|
|
||||||
return 512
|
|
||||||
}
|
|
||||||
defer syscall.CloseHandle(h)
|
|
||||||
|
|
||||||
var g diskGeometry
|
|
||||||
var ret uint32
|
|
||||||
err = syscall.DeviceIoControl(h, ioctlDiskGetDriveGeometry,
|
|
||||||
nil, 0,
|
|
||||||
(*byte)(unsafe.Pointer(&g)), uint32(unsafe.Sizeof(g)),
|
|
||||||
&ret, nil)
|
|
||||||
if err != nil || g.BytesPerSector == 0 {
|
|
||||||
return 512
|
|
||||||
}
|
|
||||||
return int64(g.BytesPerSector)
|
|
||||||
}
|
|
||||||
322
disk_linux.go
322
disk_linux.go
@ -1,322 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BLKSSZGET returns the logical (soft) sector size of a block device.
|
|
||||||
const blkSSZGet = 0x1268
|
|
||||||
|
|
||||||
func logicalSectorSize(path string) (int64, error) {
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
if err == nil && fi.Mode().IsRegular() {
|
|
||||||
return 512, nil
|
|
||||||
}
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return 512, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
var ssz int32
|
|
||||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), blkSSZGet, uintptr(unsafe.Pointer(&ssz)))
|
|
||||||
if errno != 0 || ssz <= 0 {
|
|
||||||
return 512, nil
|
|
||||||
}
|
|
||||||
return int64(ssz), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// diskAttachment is a probe-time view of a whole disk: the block path whose
|
|
||||||
// partitions can be opened (the disk itself for a real device, a loop device
|
|
||||||
// for an image file) plus a cleanup to release a loop device.
|
|
||||||
type diskAttachment struct {
|
|
||||||
blockPath string
|
|
||||||
cleanup func()
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachDisk returns a block path whose partition nodes exist. For a regular
|
|
||||||
// file it sets up a partition-scanning loop device; for a real block device
|
|
||||||
// it is a no-op.
|
|
||||||
func attachDisk(path string, writable bool) (*diskAttachment, error) {
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if fi.Mode()&os.ModeDevice != 0 {
|
|
||||||
return &diskAttachment{blockPath: path, cleanup: func() {}}, nil
|
|
||||||
}
|
|
||||||
args := []string{"--find", "--show", "--partscan"}
|
|
||||||
if !writable {
|
|
||||||
args = append(args, "--read-only")
|
|
||||||
}
|
|
||||||
args = append(args, path)
|
|
||||||
out, err := exec.Command("losetup", args...).Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("losetup %s: %w", path, cmdErr(err))
|
|
||||||
}
|
|
||||||
lo := strings.TrimSpace(string(out))
|
|
||||||
return &diskAttachment{
|
|
||||||
blockPath: lo,
|
|
||||||
cleanup: func() { _ = exec.Command("losetup", "--detach", lo).Run() },
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// partNode maps a whole-disk block path + 1-based partition number to its
|
|
||||||
// partition device node (sda -> sda1, nvme0n1 -> nvme0n1p1, loop0 -> loop0p1).
|
|
||||||
func partNode(disk string, num int) string {
|
|
||||||
if l := len(disk); l > 0 && disk[l-1] >= '0' && disk[l-1] <= '9' {
|
|
||||||
return fmt.Sprintf("%sp%d", disk, num)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s%d", disk, num)
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskTools = []string{
|
|
||||||
"sfdisk", "sgdisk", "partprobe", "blockdev", "blkid", "lsblk", "losetup",
|
|
||||||
"ntfsclone", "ntfsresize", "ntfsfix", "mkntfs",
|
|
||||||
"e2image", "resize2fs", "dumpe2fs", "mkfs.ext4", "e2fsck",
|
|
||||||
"partclone.dd", "partclone.extfs", "partclone.ntfs", "partclone.fat",
|
|
||||||
"partclone.exfat", "partclone.xfs", "partclone.btrfs", "partclone.restore",
|
|
||||||
"xfs_copy", "mkfs.vfat", "mkfs.xfs", "mkswap", "rsync",
|
|
||||||
"mount", "umount", "grub-install", "update-grub", "grub-mkconfig",
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeToolset() map[string]bool {
|
|
||||||
m := make(map[string]bool, len(diskTools))
|
|
||||||
for _, t := range diskTools {
|
|
||||||
if _, err := exec.LookPath(t); err == nil {
|
|
||||||
m[t] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeDiskLayout gathers everything clone-disk needs from a source disk.
|
|
||||||
func probeDiskLayout(path string) (*DiskLayout, error) {
|
|
||||||
d := &DiskLayout{DiskPath: path, Tools: probeToolset()}
|
|
||||||
|
|
||||||
info, err := statPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.Exists {
|
|
||||||
return nil, fmt.Errorf("%s does not exist", path)
|
|
||||||
}
|
|
||||||
d.DiskSize = info.Size
|
|
||||||
if d.LogicalSector, err = logicalSectorSize(path); err != nil {
|
|
||||||
d.LogicalSector = 512
|
|
||||||
}
|
|
||||||
d.PhysicalSector = d.LogicalSector
|
|
||||||
|
|
||||||
// Partition table via sfdisk -d (works on a file directly).
|
|
||||||
dump, derr := exec.Command("sfdisk", "-d", path).Output()
|
|
||||||
if derr != nil {
|
|
||||||
d.Scheme = "raw"
|
|
||||||
d.Warnings = append(d.Warnings, fmt.Sprintf("no partition table (%v)", cmdErr(derr)))
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
if err := d.parseSfdiskDump(string(dump)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if d.LogicalSector == 0 {
|
|
||||||
d.LogicalSector = 512
|
|
||||||
}
|
|
||||||
if d.Scheme == "gpt" {
|
|
||||||
if b, err := exec.Command("sgdisk", "--backup=/dev/stdout", path).Output(); err == nil {
|
|
||||||
d.SgdiskB64 = base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach so per-partition nodes exist, then enrich each row.
|
|
||||||
att, err := attachDisk(path, false)
|
|
||||||
if err != nil {
|
|
||||||
d.Warnings = append(d.Warnings, fmt.Sprintf("cannot attach for fs probe: %v", err))
|
|
||||||
} else {
|
|
||||||
defer att.cleanup()
|
|
||||||
for i := range d.Partitions {
|
|
||||||
enrichPartition(&d.Partitions[i], att.blockPath, d.LogicalSector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
d.BootRegions = computeBootRegions(d)
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// enrichPartition fills the FS* fields of p using blkid + fs-specific probes.
|
|
||||||
func enrichPartition(p *Partition, disk string, sector int64) {
|
|
||||||
node := partNode(disk, p.Num)
|
|
||||||
p.DevPath = node
|
|
||||||
if _, err := os.Stat(node); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if out, err := exec.Command("blkid", "-o", "export", node).Output(); err == nil {
|
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
|
||||||
k, v, ok := splitEq(strings.TrimSpace(line))
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch k {
|
|
||||||
case "TYPE":
|
|
||||||
p.FSType = v
|
|
||||||
case "LABEL":
|
|
||||||
p.FSLabel = v
|
|
||||||
case "UUID":
|
|
||||||
p.FSUUID = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch p.FSType {
|
|
||||||
case "ext2", "ext3", "ext4":
|
|
||||||
p.FSUsedBytes, p.FSMinBytes = probeExtSizes(node)
|
|
||||||
case "ntfs":
|
|
||||||
p.FSUsedBytes, p.FSMinBytes = probeNTFSSizes(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeExtSizes(node string) (used, min int64) {
|
|
||||||
if out, err := exec.Command("dumpe2fs", "-h", node).Output(); err == nil {
|
|
||||||
var bs, count, free int64
|
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
|
||||||
f := strings.SplitN(line, ":", 2)
|
|
||||||
if len(f) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v := strings.TrimSpace(f[1])
|
|
||||||
switch strings.TrimSpace(f[0]) {
|
|
||||||
case "Block size":
|
|
||||||
bs, _ = strconv.ParseInt(v, 10, 64)
|
|
||||||
case "Block count":
|
|
||||||
count, _ = strconv.ParseInt(v, 10, 64)
|
|
||||||
case "Free blocks":
|
|
||||||
free, _ = strconv.ParseInt(v, 10, 64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bs > 0 && count > 0 {
|
|
||||||
used = (count - free) * bs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// resize2fs -P needs a clean fs; ignore failures.
|
|
||||||
if out, err := exec.Command("resize2fs", "-P", node).Output(); err == nil {
|
|
||||||
// "Estimated minimum size of the filesystem: 123456" (in 4k blocks)
|
|
||||||
s := string(out)
|
|
||||||
if i := strings.LastIndex(s, ":"); i >= 0 {
|
|
||||||
if n, err := strconv.ParseInt(strings.TrimSpace(s[i+1:]), 10, 64); err == nil {
|
|
||||||
min = n * 4096
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return used, min
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeNTFSSizes(node string) (used, min int64) {
|
|
||||||
out, err := exec.Command("ntfsresize", "--info", "--force", node).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "You might resize at "):
|
|
||||||
// "You might resize at 1234567890 bytes ..."
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
if n, err := strconv.ParseInt(fields[4], 10, 64); err == nil {
|
|
||||||
min = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case strings.Contains(line, "Space in use") && strings.Contains(line, "bytes"):
|
|
||||||
// "Space in use : 1234 MB (1.2%)" - not bytes; skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return min, min
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeBootRegions returns the byte ranges outside any cloned partition
|
|
||||||
// that must still be copied verbatim.
|
|
||||||
func computeBootRegions(d *DiskLayout) []Region {
|
|
||||||
var regs []Region
|
|
||||||
// bootstrap code before the MBR partition table on both MBR and (BIOS-)GPT disks
|
|
||||||
regs = append(regs, Region{Offset: 0, Length: 446, Note: "MBR bootstrap"})
|
|
||||||
if d.Scheme == "mbr" && len(d.Partitions) > 0 {
|
|
||||||
firstStart := d.Partitions[0].Start * d.LogicalSector
|
|
||||||
for _, p := range d.Partitions {
|
|
||||||
s := p.Start * d.LogicalSector
|
|
||||||
if s > 0 && s < firstStart {
|
|
||||||
firstStart = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if firstStart > 512 {
|
|
||||||
regs = append(regs, Region{Offset: 512, Length: firstStart - 512, Note: "MBR gap (GRUB core.img etc.)"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return regs
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyPartitionTable writes script (an sfdisk restore script) to target,
|
|
||||||
// which may be a block device or an image file, and re-reads the table.
|
|
||||||
func applyPartitionTable(target, script string) error {
|
|
||||||
cmd := exec.Command("sfdisk", "--no-reread", "--no-tell-kernel", "--force", "--wipe=always", target)
|
|
||||||
cmd.Stdin = strings.NewReader(script)
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("sfdisk %s: %v: %s", target, err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
rereadPartTable(target)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rereadPartTable(target string) {
|
|
||||||
fi, err := os.Stat(target)
|
|
||||||
if err != nil || fi.Mode()&os.ModeDevice == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := exec.Command("partprobe", target).Run(); err != nil {
|
|
||||||
_ = exec.Command("blockdev", "--rereadpt", target).Run()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lsblkFSType reports the filesystem type on a node ("" if none).
|
|
||||||
func lsblkFSType(node string) string {
|
|
||||||
out, err := exec.Command("lsblk", "-ndo", "FSTYPE", node).Output()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
// mountAt mounts node at a fresh temp dir and returns the dir + an unmount fn.
|
|
||||||
func mountAt(node string, readonly bool) (string, func(), error) {
|
|
||||||
dir, err := os.MkdirTemp("", "clonetool-mnt-")
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
args := []string{}
|
|
||||||
if readonly {
|
|
||||||
args = append(args, "-o", "ro")
|
|
||||||
}
|
|
||||||
args = append(args, node, dir)
|
|
||||||
if out, err := exec.Command("mount", args...).CombinedOutput(); err != nil {
|
|
||||||
os.Remove(dir)
|
|
||||||
return "", nil, fmt.Errorf("mount %s: %v: %s", node, err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return dir, func() {
|
|
||||||
_ = exec.Command("umount", dir).Run()
|
|
||||||
_ = os.Remove(dir)
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdErr(err error) error {
|
|
||||||
if ee, ok := err.(*exec.ExitError); ok {
|
|
||||||
if s := strings.TrimSpace(string(ee.Stderr)); s != "" {
|
|
||||||
return fmt.Errorf("%v: %s", err, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
//go:build !linux && !windows
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func logicalSectorSize(string) (int64, error) { return 512, nil }
|
|
||||||
|
|
||||||
type diskAttachment struct {
|
|
||||||
blockPath string
|
|
||||||
cleanup func()
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachDisk(string, bool) (*diskAttachment, error) {
|
|
||||||
return nil, fmt.Errorf("clone-disk is only implemented on linux and windows (this is %s)", runtime.GOOS)
|
|
||||||
}
|
|
||||||
|
|
||||||
func partNode(disk string, num int) string { return fmt.Sprintf("%s%d", disk, num) }
|
|
||||||
|
|
||||||
func probeDiskLayout(string) (*DiskLayout, error) {
|
|
||||||
return nil, fmt.Errorf("clone-disk is only implemented on linux and windows (this is %s)", runtime.GOOS)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyPartitionTable(string, string) error {
|
|
||||||
return fmt.Errorf("clone-disk partition rebuild is only implemented on linux and windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rereadPartTable(string) {}
|
|
||||||
|
|
||||||
func lsblkFSType(string) string { return "" }
|
|
||||||
|
|
||||||
func mountAt(string, bool) (string, func(), error) {
|
|
||||||
return "", nil, fmt.Errorf("mount is only implemented on linux")
|
|
||||||
}
|
|
||||||
232
disk_windows.go
232
disk_windows.go
@ -1,232 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ioctlDiskGetDriveLayoutEx = 0x00070050 // IOCTL_DISK_GET_DRIVE_LAYOUT_EX
|
|
||||||
ioctlDiskUpdateProperties = 0x00070140 // IOCTL_DISK_UPDATE_PROPERTIES
|
|
||||||
)
|
|
||||||
|
|
||||||
func logicalSectorSize(path string) (int64, error) {
|
|
||||||
if !isDevicePath(path) {
|
|
||||||
return 512, nil
|
|
||||||
}
|
|
||||||
return alignmentFor(path), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// guidString renders a little-endian Win32 GUID (4-2-2 + 8 bytes) as the
|
|
||||||
// canonical upper-case string.
|
|
||||||
func guidString(b []byte) string {
|
|
||||||
if len(b) < 16 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
d1 := binary.LittleEndian.Uint32(b[0:4])
|
|
||||||
d2 := binary.LittleEndian.Uint16(b[4:6])
|
|
||||||
d3 := binary.LittleEndian.Uint16(b[6:8])
|
|
||||||
return strings.ToUpper(fmt.Sprintf("%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X",
|
|
||||||
d1, d2, d3, b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeDiskLayout reads the partition table of a \\.\PhysicalDriveN via
|
|
||||||
// IOCTL_DISK_GET_DRIVE_LAYOUT_EX. Windows-native clone-disk is raw/VSS only:
|
|
||||||
// every partition is cloned block-wise and the table itself is reproduced by
|
|
||||||
// raw-copying the leading sectors (see computeBootRegionsWindows), so no
|
|
||||||
// sfdisk-style rebuild is needed.
|
|
||||||
func probeDiskLayout(path string) (*DiskLayout, error) {
|
|
||||||
d := &DiskLayout{DiskPath: path, Tools: probeToolsetWindows()}
|
|
||||||
|
|
||||||
info, err := statPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.Exists {
|
|
||||||
return nil, fmt.Errorf("%s does not exist", path)
|
|
||||||
}
|
|
||||||
d.DiskSize = info.Size
|
|
||||||
d.LogicalSector, _ = logicalSectorSize(path)
|
|
||||||
if d.LogicalSector == 0 {
|
|
||||||
d.LogicalSector = 512
|
|
||||||
}
|
|
||||||
d.PhysicalSector = d.LogicalSector
|
|
||||||
|
|
||||||
if !isDevicePath(path) {
|
|
||||||
// An image file: we cannot enumerate its table without extra tooling.
|
|
||||||
d.Scheme = "raw"
|
|
||||||
d.Warnings = append(d.Warnings, "image-file source on Windows: whole-image raw clone only")
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
p16, err := syscall.UTF16PtrFromString(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
h, err := syscall.CreateFile(p16, syscall.GENERIC_READ,
|
|
||||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, 0, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &os.PathError{Op: "open", Path: path, Err: err}
|
|
||||||
}
|
|
||||||
defer syscall.CloseHandle(h)
|
|
||||||
|
|
||||||
buf := make([]byte, 64*1024)
|
|
||||||
var ret uint32
|
|
||||||
if err := syscall.DeviceIoControl(h, ioctlDiskGetDriveLayoutEx, nil, 0,
|
|
||||||
&buf[0], uint32(len(buf)), &ret, nil); err != nil {
|
|
||||||
return nil, fmt.Errorf("IOCTL_DISK_GET_DRIVE_LAYOUT_EX %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
style := binary.LittleEndian.Uint32(buf[0:4])
|
|
||||||
count := binary.LittleEndian.Uint32(buf[4:8])
|
|
||||||
switch style {
|
|
||||||
case 0:
|
|
||||||
d.Scheme, d.Label = "mbr", "dos"
|
|
||||||
sig := binary.LittleEndian.Uint32(buf[8:12])
|
|
||||||
d.LabelID = fmt.Sprintf("0x%08x", sig)
|
|
||||||
case 1:
|
|
||||||
d.Scheme, d.Label = "gpt", "gpt"
|
|
||||||
d.LabelID = guidString(buf[8:24])
|
|
||||||
default:
|
|
||||||
d.Scheme = "raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
const hdr = 48
|
|
||||||
const peSize = 144
|
|
||||||
sector := d.LogicalSector
|
|
||||||
for i := uint32(0); i < count; i++ {
|
|
||||||
off := hdr + int(i)*peSize
|
|
||||||
if off+peSize > len(buf) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
e := buf[off : off+peSize]
|
|
||||||
start := int64(binary.LittleEndian.Uint64(e[8:16]))
|
|
||||||
length := int64(binary.LittleEndian.Uint64(e[16:24]))
|
|
||||||
num := int(binary.LittleEndian.Uint32(e[24:28]))
|
|
||||||
if length == 0 || num == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p := Partition{
|
|
||||||
Num: num,
|
|
||||||
Start: start / sector,
|
|
||||||
Size: length / sector,
|
|
||||||
}
|
|
||||||
if style == 1 { // GPT union at offset 32
|
|
||||||
p.Type = guidString(e[32:48])
|
|
||||||
p.UUID = guidString(e[48:64])
|
|
||||||
p.Name = decodeUTF16(e[72:144])
|
|
||||||
} else if style == 0 { // MBR union at offset 32
|
|
||||||
p.Type = fmt.Sprintf("%02x", e[32])
|
|
||||||
if e[33] != 0 {
|
|
||||||
p.Attrs = "bootable"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.Partitions = append(d.Partitions, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.BootRegions = computeBootRegionsWindows(d)
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeUTF16(b []byte) string {
|
|
||||||
u := make([]uint16, 0, len(b)/2)
|
|
||||||
for i := 0; i+1 < len(b); i += 2 {
|
|
||||||
c := binary.LittleEndian.Uint16(b[i : i+2])
|
|
||||||
if c == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
u = append(u, c)
|
|
||||||
}
|
|
||||||
return strings.TrimRight(syscall.UTF16ToString(u), "\x00")
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeBootRegionsWindows: reproduce the partition table by raw-copying the
|
|
||||||
// sectors before the first partition, plus the trailing 33 sectors that hold
|
|
||||||
// the backup GPT.
|
|
||||||
func computeBootRegionsWindows(d *DiskLayout) []Region {
|
|
||||||
var regs []Region
|
|
||||||
if len(d.Partitions) == 0 {
|
|
||||||
return regs
|
|
||||||
}
|
|
||||||
first := d.Partitions[0].Start * d.LogicalSector
|
|
||||||
for _, p := range d.Partitions {
|
|
||||||
if s := p.Start * d.LogicalSector; s > 0 && s < first {
|
|
||||||
first = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if first > 0 {
|
|
||||||
regs = append(regs, Region{Offset: 0, Length: first, Note: "protective MBR + primary GPT / MBR table + gap"})
|
|
||||||
}
|
|
||||||
if d.Scheme == "gpt" && d.DiskSize > 33*d.LogicalSector {
|
|
||||||
regs = append(regs, Region{
|
|
||||||
Offset: d.DiskSize - 33*d.LogicalSector,
|
|
||||||
Length: 33 * d.LogicalSector,
|
|
||||||
Note: "backup GPT",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return regs
|
|
||||||
}
|
|
||||||
|
|
||||||
func rereadPartTable(target string) {
|
|
||||||
if !isDevicePath(target) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p16, err := syscall.UTF16PtrFromString(target)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h, err := syscall.CreateFile(p16, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
|
|
||||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, 0, 0)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer syscall.CloseHandle(h)
|
|
||||||
var ret uint32
|
|
||||||
_ = syscall.DeviceIoControl(h, ioctlDiskUpdateProperties, nil, 0, nil, 0, &ret, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyPartitionTable is a no-op on Windows: the table is reproduced via the
|
|
||||||
// raw boot regions and rereadPartTable() refreshes the kernel's view.
|
|
||||||
func applyPartitionTable(target, script string) error {
|
|
||||||
rereadPartTable(target)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeToolsetWindows() map[string]bool {
|
|
||||||
m := map[string]bool{}
|
|
||||||
for _, t := range []string{"vssadmin", "wmic", "bcdboot", "diskpart", "robocopy", "powershell"} {
|
|
||||||
if _, err := exec.LookPath(t); err == nil {
|
|
||||||
m[t] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
type diskAttachment struct {
|
|
||||||
blockPath string
|
|
||||||
cleanup func()
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachDisk is a no-op for a raw \\.\PhysicalDriveN (partitions are addressed
|
|
||||||
// by byte offset within the whole-disk handle on Windows) and unsupported for
|
|
||||||
// an image file.
|
|
||||||
func attachDisk(path string, writable bool) (*diskAttachment, error) {
|
|
||||||
if isDevicePath(path) {
|
|
||||||
return &diskAttachment{blockPath: path, cleanup: func() {}}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("filesystem-aware / file-level clone of an image file is not supported on Windows; use --raw")
|
|
||||||
}
|
|
||||||
|
|
||||||
func partNode(disk string, num int) string { return fmt.Sprintf("%s (partition %d)", disk, num) }
|
|
||||||
|
|
||||||
func lsblkFSType(string) string { return "" }
|
|
||||||
|
|
||||||
func mountAt(string, bool) (string, func(), error) {
|
|
||||||
return "", nil, fmt.Errorf("mount-based file-level clone is not supported on Windows; use --raw")
|
|
||||||
}
|
|
||||||
298
disklayout.go
298
disklayout.go
@ -1,298 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DiskLayout is the full picture of a source disk that clone-disk needs to
|
|
||||||
// reproduce a target: the partition table (as parsed from `sfdisk -d`, the
|
|
||||||
// canonical restorable form for both GPT and MBR), the byte ranges outside
|
|
||||||
// any partition that still have to be copied verbatim (MBR bootstrap + gap,
|
|
||||||
// BIOS-boot partition), the per-partition filesystem facts, and which of the
|
|
||||||
// external tools we shell out to are actually present.
|
|
||||||
type DiskLayout struct {
|
|
||||||
// Whole-disk geometry.
|
|
||||||
DiskPath string `json:"diskPath"`
|
|
||||||
DiskSize int64 `json:"diskSize"` // bytes
|
|
||||||
LogicalSector int64 `json:"logicalSector"` // bytes, from the kernel/ioctl
|
|
||||||
PhysicalSector int64 `json:"physicalSector"` // bytes
|
|
||||||
Scheme string `json:"scheme"` // "gpt" | "mbr" | "raw" (no recognisable table)
|
|
||||||
|
|
||||||
// Partition table, parsed from `sfdisk -d`.
|
|
||||||
Label string `json:"label"` // "gpt" | "dos"
|
|
||||||
LabelID string `json:"labelID"` // GPT disk GUID / MBR 4-byte signature (0x...)
|
|
||||||
FirstLBA int64 `json:"firstLBA"` // sectors; 0 if the dump omitted it
|
|
||||||
LastLBA int64 `json:"lastLBA"` // sectors; 0 if omitted
|
|
||||||
Partitions []Partition `json:"partitions"`
|
|
||||||
SfdiskDump string `json:"sfdiskDump"` // the verbatim `sfdisk -d` output
|
|
||||||
SgdiskB64 string `json:"sgdiskB64"` // base64 `sgdisk --backup` blob (GPT only), fallback restore path
|
|
||||||
|
|
||||||
// Byte ranges that must be raw-copied and are not covered by a cloned
|
|
||||||
// partition: MBR bootstrap (0..0x1BE) + the gap before the first
|
|
||||||
// partition, and any BIOS-boot partition.
|
|
||||||
BootRegions []Region `json:"bootRegions"`
|
|
||||||
|
|
||||||
Tools map[string]bool `json:"tools"` // external tool name -> present on PATH
|
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Region is a [Offset, Offset+Length) byte window on the whole disk.
|
|
||||||
type Region struct {
|
|
||||||
Offset int64 `json:"offset"`
|
|
||||||
Length int64 `json:"length"`
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partition is one row of the partition table plus the filesystem facts the
|
|
||||||
// source agent gathered for it.
|
|
||||||
type Partition struct {
|
|
||||||
Num int `json:"num"`
|
|
||||||
Start int64 `json:"start"` // sectors
|
|
||||||
Size int64 `json:"size"` // sectors
|
|
||||||
Type string `json:"type"` // GPT type GUID or MBR hex code
|
|
||||||
UUID string `json:"uuid"` // GPT partition GUID (PARTUUID)
|
|
||||||
Name string `json:"name"` // GPT partition name
|
|
||||||
Attrs string `json:"attrs"` // trailing flags: "bootable", "attrs=\"...\"", ...
|
|
||||||
|
|
||||||
// Filesystem facts (source agent; not from sfdisk).
|
|
||||||
DevPath string `json:"devPath"` // source node, e.g. /dev/sda2
|
|
||||||
FSType string `json:"fsType"` // ext4 | ntfs | vfat | xfs | btrfs | swap | "" (none/unknown)
|
|
||||||
FSLabel string `json:"fsLabel"`
|
|
||||||
FSUUID string `json:"fsUUID"`
|
|
||||||
FSUsedBytes int64 `json:"fsUsedBytes"` // 0 if unknown
|
|
||||||
FSMinBytes int64 `json:"fsMinBytes"` // smallest the fs can be shrunk to; 0 if unknown/unshrinkable
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartBytes / SizeBytes convert the sector-unit table fields using the
|
|
||||||
// disk's logical sector size.
|
|
||||||
func (p Partition) StartBytes(sectorSize int64) int64 { return p.Start * sectorSize }
|
|
||||||
func (p Partition) SizeBytes(sectorSize int64) int64 { return p.Size * sectorSize }
|
|
||||||
|
|
||||||
func (d *DiskLayout) JSON() string {
|
|
||||||
b, _ := json.Marshal(d)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDiskLayoutJSON(s string) (*DiskLayout, error) {
|
|
||||||
var d DiskLayout
|
|
||||||
if err := json.Unmarshal([]byte(s), &d); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode disk layout: %w", err)
|
|
||||||
}
|
|
||||||
return &d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSfdiskDump fills the Label/LabelID/FirstLBA/LastLBA/Partitions fields
|
|
||||||
// of d from the text of `sfdisk -d`. It leaves the raw text in d.SfdiskDump.
|
|
||||||
func (d *DiskLayout) parseSfdiskDump(dump string) error {
|
|
||||||
d.SfdiskDump = dump
|
|
||||||
sc := bufio.NewScanner(strings.NewReader(dump))
|
|
||||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
||||||
for sc.Scan() {
|
|
||||||
line := strings.TrimSpace(sc.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i := strings.Index(line, " : "); i >= 0 && strings.Contains(line, "start=") {
|
|
||||||
p, err := parseSfdiskPartLine(line)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
d.Partitions = append(d.Partitions, p)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, val, ok := splitKV(line)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch key {
|
|
||||||
case "label":
|
|
||||||
d.Label = val
|
|
||||||
case "label-id":
|
|
||||||
d.LabelID = val
|
|
||||||
case "first-lba":
|
|
||||||
d.FirstLBA, _ = strconv.ParseInt(val, 10, 64)
|
|
||||||
case "last-lba":
|
|
||||||
d.LastLBA, _ = strconv.ParseInt(val, 10, 64)
|
|
||||||
case "sector-size":
|
|
||||||
if n, err := strconv.ParseInt(val, 10, 64); err == nil && d.LogicalSector == 0 {
|
|
||||||
d.LogicalSector = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := sc.Err(); err != nil {
|
|
||||||
return fmt.Errorf("scan sfdisk dump: %w", err)
|
|
||||||
}
|
|
||||||
switch d.Label {
|
|
||||||
case "gpt":
|
|
||||||
d.Scheme = "gpt"
|
|
||||||
case "dos":
|
|
||||||
d.Scheme = "mbr"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitKV parses a "key: value" header line.
|
|
||||||
func splitKV(line string) (string, string, bool) {
|
|
||||||
i := strings.Index(line, ":")
|
|
||||||
if i < 0 {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSfdiskPartLine parses a body line like
|
|
||||||
//
|
|
||||||
// /dev/sda2 : start= 1050624, size= 1951xxxxxx, type=0FC6..., uuid=..., name="root", attrs="..."
|
|
||||||
// /dev/sda1 : start= 2048, size= 204800, type=83, bootable
|
|
||||||
func parseSfdiskPartLine(line string) (Partition, error) {
|
|
||||||
var p Partition
|
|
||||||
i := strings.Index(line, " : ")
|
|
||||||
dev := strings.TrimSpace(line[:i])
|
|
||||||
p.DevPath = dev
|
|
||||||
p.Num = trailingInt(dev)
|
|
||||||
rest := line[i+3:]
|
|
||||||
for _, fld := range splitTopLevel(rest, ',') {
|
|
||||||
fld = strings.TrimSpace(fld)
|
|
||||||
if fld == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k, v, ok := splitEq(fld)
|
|
||||||
if !ok {
|
|
||||||
// bare flag, e.g. "bootable"
|
|
||||||
if p.Attrs == "" {
|
|
||||||
p.Attrs = fld
|
|
||||||
} else {
|
|
||||||
p.Attrs += ", " + fld
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v = strings.Trim(strings.TrimSpace(v), `"`)
|
|
||||||
switch k {
|
|
||||||
case "start":
|
|
||||||
p.Start, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
|
||||||
case "size":
|
|
||||||
p.Size, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
|
||||||
case "type":
|
|
||||||
p.Type = v
|
|
||||||
case "uuid":
|
|
||||||
p.UUID = v
|
|
||||||
case "name":
|
|
||||||
p.Name = v
|
|
||||||
case "attrs":
|
|
||||||
if p.Attrs == "" {
|
|
||||||
p.Attrs = `attrs="` + v + `"`
|
|
||||||
} else {
|
|
||||||
p.Attrs += `, attrs="` + v + `"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.Start == 0 && p.Size == 0 {
|
|
||||||
return p, fmt.Errorf("sfdisk line without start/size: %q", line)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sfdiskRestoreScript renders a device-independent script that recreates this
|
|
||||||
// table on any target with `sfdisk`. It intentionally drops `device:` and the
|
|
||||||
// per-line device prefixes (sfdisk numbers the partitions in order) and
|
|
||||||
// `last-lba` (let sfdisk size the secondary GPT for the target). label-id and
|
|
||||||
// per-partition uuid are dropped when newIDs is set so a replacement disk can
|
|
||||||
// coexist with the original; otherwise they are preserved so existing
|
|
||||||
// BCD/fstab/GRUB references still resolve. sizeOverride maps a 1-based
|
|
||||||
// partition number to a new size in sectors (used by the shrink path).
|
|
||||||
func (d *DiskLayout) sfdiskRestoreScript(newIDs bool, sizeOverride map[int]int64) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "label: %s\n", d.Label)
|
|
||||||
if d.LabelID != "" && !newIDs {
|
|
||||||
fmt.Fprintf(&b, "label-id: %s\n", d.LabelID)
|
|
||||||
}
|
|
||||||
if d.FirstLBA > 0 {
|
|
||||||
fmt.Fprintf(&b, "first-lba: %d\n", d.FirstLBA)
|
|
||||||
}
|
|
||||||
if d.LogicalSector > 0 {
|
|
||||||
fmt.Fprintf(&b, "sector-size: %d\n", d.LogicalSector)
|
|
||||||
}
|
|
||||||
b.WriteString("unit: sectors\n\n")
|
|
||||||
for _, p := range d.Partitions {
|
|
||||||
size := p.Size
|
|
||||||
if ov, ok := sizeOverride[p.Num]; ok && ov > 0 {
|
|
||||||
size = ov
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "start=%d, size=%d", p.Start, size)
|
|
||||||
if p.Type != "" {
|
|
||||||
fmt.Fprintf(&b, ", type=%s", p.Type)
|
|
||||||
}
|
|
||||||
if p.UUID != "" && !newIDs {
|
|
||||||
fmt.Fprintf(&b, ", uuid=%s", p.UUID)
|
|
||||||
}
|
|
||||||
if p.Name != "" {
|
|
||||||
fmt.Fprintf(&b, ", name=\"%s\"", p.Name)
|
|
||||||
}
|
|
||||||
if p.Attrs != "" {
|
|
||||||
fmt.Fprintf(&b, ", %s", p.Attrs)
|
|
||||||
}
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// biosBootType / espType are the GPT type GUIDs clone-disk special-cases.
|
|
||||||
const (
|
|
||||||
gptBIOSBoot = "21686148-6449-6E6F-744E-656564454649" // EF02 BIOS boot partition
|
|
||||||
gptESP = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" // EF00 EFI system partition
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p Partition) isBIOSBoot() bool {
|
|
||||||
return strings.EqualFold(p.Type, gptBIOSBoot) || p.Type == "21" /* mbr */
|
|
||||||
}
|
|
||||||
func (p Partition) isESP() bool {
|
|
||||||
return strings.EqualFold(p.Type, gptESP) || p.Type == "ef" || p.Type == "EF"
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- small string helpers -------------------------------------------------
|
|
||||||
|
|
||||||
func trailingInt(s string) int {
|
|
||||||
j := len(s)
|
|
||||||
for j > 0 && s[j-1] >= '0' && s[j-1] <= '9' {
|
|
||||||
j--
|
|
||||||
}
|
|
||||||
if j == len(s) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
n, _ := strconv.Atoi(s[j:])
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitEq(s string) (string, string, bool) {
|
|
||||||
i := strings.Index(s, "=")
|
|
||||||
if i < 0 {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s[:i]), s[i+1:], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitTopLevel splits on sep but not inside double quotes.
|
|
||||||
func splitTopLevel(s string, sep byte) []string {
|
|
||||||
var out []string
|
|
||||||
var cur strings.Builder
|
|
||||||
inQ := false
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
switch {
|
|
||||||
case c == '"':
|
|
||||||
inQ = !inQ
|
|
||||||
cur.WriteByte(c)
|
|
||||||
case c == sep && !inQ:
|
|
||||||
out = append(out, cur.String())
|
|
||||||
cur.Reset()
|
|
||||||
default:
|
|
||||||
cur.WriteByte(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, cur.String())
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const gptDump = `label: gpt
|
|
||||||
label-id: 1D2E3F4A-1111-2222-3333-444455556666
|
|
||||||
device: /dev/sda
|
|
||||||
unit: sectors
|
|
||||||
first-lba: 2048
|
|
||||||
last-lba: 8388574
|
|
||||||
sector-size: 512
|
|
||||||
|
|
||||||
/dev/sda1 : start= 2048, size= 204800, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, uuid=AAAAAAAA-1111-2222-3333-444455556666, name="EFI System Partition"
|
|
||||||
/dev/sda2 : start= 206848, size= 8179712, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, uuid=BBBBBBBB-1111-2222-3333-444455556666
|
|
||||||
`
|
|
||||||
|
|
||||||
const mbrDump = `label: dos
|
|
||||||
label-id: 0x1a2b3c4d
|
|
||||||
device: /dev/sdb
|
|
||||||
unit: sectors
|
|
||||||
sector-size: 512
|
|
||||||
|
|
||||||
/dev/sdb1 : start= 2048, size= 512000, type=83, bootable
|
|
||||||
/dev/sdb2 : start= 514048, size= 41734144, type=83
|
|
||||||
`
|
|
||||||
|
|
||||||
func TestParseSfdiskDumpGPT(t *testing.T) {
|
|
||||||
var d DiskLayout
|
|
||||||
if err := d.parseSfdiskDump(gptDump); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.Label != "gpt" || d.Scheme != "gpt" {
|
|
||||||
t.Fatalf("label/scheme = %q/%q", d.Label, d.Scheme)
|
|
||||||
}
|
|
||||||
if d.LabelID != "1D2E3F4A-1111-2222-3333-444455556666" {
|
|
||||||
t.Fatalf("labelID = %q", d.LabelID)
|
|
||||||
}
|
|
||||||
if d.FirstLBA != 2048 || d.LastLBA != 8388574 {
|
|
||||||
t.Fatalf("lba = %d/%d", d.FirstLBA, d.LastLBA)
|
|
||||||
}
|
|
||||||
if len(d.Partitions) != 2 {
|
|
||||||
t.Fatalf("parts = %d", len(d.Partitions))
|
|
||||||
}
|
|
||||||
p1 := d.Partitions[0]
|
|
||||||
if p1.Num != 1 || p1.Start != 2048 || p1.Size != 204800 {
|
|
||||||
t.Fatalf("p1 = %+v", p1)
|
|
||||||
}
|
|
||||||
if p1.Type != "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" || p1.UUID != "AAAAAAAA-1111-2222-3333-444455556666" {
|
|
||||||
t.Fatalf("p1 type/uuid = %q/%q", p1.Type, p1.UUID)
|
|
||||||
}
|
|
||||||
if p1.Name != "EFI System Partition" {
|
|
||||||
t.Fatalf("p1 name = %q", p1.Name)
|
|
||||||
}
|
|
||||||
if !p1.isESP() {
|
|
||||||
t.Fatalf("p1 should be ESP")
|
|
||||||
}
|
|
||||||
if d.Partitions[1].Num != 2 || d.Partitions[1].Size != 8179712 {
|
|
||||||
t.Fatalf("p2 = %+v", d.Partitions[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSfdiskDumpMBR(t *testing.T) {
|
|
||||||
var d DiskLayout
|
|
||||||
if err := d.parseSfdiskDump(mbrDump); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.Scheme != "mbr" || d.LabelID != "0x1a2b3c4d" {
|
|
||||||
t.Fatalf("scheme/id = %q/%q", d.Scheme, d.LabelID)
|
|
||||||
}
|
|
||||||
if len(d.Partitions) != 2 || d.Partitions[0].Attrs != "bootable" {
|
|
||||||
t.Fatalf("parts = %+v", d.Partitions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSfdiskRestoreScript(t *testing.T) {
|
|
||||||
var d DiskLayout
|
|
||||||
if err := d.parseSfdiskDump(gptDump); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep IDs
|
|
||||||
s := d.sfdiskRestoreScript(false, nil)
|
|
||||||
if !strings.Contains(s, "label-id: 1D2E3F4A-1111-2222-3333-444455556666") {
|
|
||||||
t.Fatalf("expected label-id kept:\n%s", s)
|
|
||||||
}
|
|
||||||
if !strings.Contains(s, "uuid=AAAAAAAA-1111-2222-3333-444455556666") {
|
|
||||||
t.Fatalf("expected part uuid kept:\n%s", s)
|
|
||||||
}
|
|
||||||
if strings.Contains(s, "device:") || strings.Contains(s, "/dev/sda1") {
|
|
||||||
t.Fatalf("script must be device-independent:\n%s", s)
|
|
||||||
}
|
|
||||||
if strings.Contains(s, "last-lba") {
|
|
||||||
t.Fatalf("script must drop last-lba:\n%s", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// new IDs
|
|
||||||
s2 := d.sfdiskRestoreScript(true, nil)
|
|
||||||
if strings.Contains(s2, "label-id:") || strings.Contains(s2, "uuid=") {
|
|
||||||
t.Fatalf("--new-ids must strip ids:\n%s", s2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// size override on p2
|
|
||||||
s3 := d.sfdiskRestoreScript(false, map[int]int64{2: 4000000})
|
|
||||||
if !strings.Contains(s3, "size=4000000") {
|
|
||||||
t.Fatalf("expected overridden size:\n%s", s3)
|
|
||||||
}
|
|
||||||
if strings.Contains(s3, "size=8179712") {
|
|
||||||
t.Fatalf("old size should be gone:\n%s", s3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
200
fsclone.go
200
fsclone.go
@ -1,200 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fsCloner is the pair of argv templates that stream one filesystem as an
|
|
||||||
// image: save reads the fs on dev and writes an image to stdout; restore
|
|
||||||
// reads that image from stdin and writes it onto dev. The image format is
|
|
||||||
// whatever the chosen standard tool uses (ntfsclone, partclone, e2image) —
|
|
||||||
// clonetool only moves the bytes between the two ends.
|
|
||||||
type fsCloner struct {
|
|
||||||
name string // tool family, for logs
|
|
||||||
save []string
|
|
||||||
restore []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// fsCloneOpts is the JSON blob carried in CtrlMsg.Options for a fs-image
|
|
||||||
// partition clone so both ends build the identical argv.
|
|
||||||
type fsCloneOpts struct {
|
|
||||||
FSType string `json:"fsType"`
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
SizeBytes int64 `json:"sizeBytes"`
|
|
||||||
// ShrinkToBytes > 0: shrink the SOURCE filesystem to this size in place
|
|
||||||
// before imaging it (so it fits a smaller target partition). This
|
|
||||||
// mutates the source and only runs when the operator passed
|
|
||||||
// --allow-shrink --yes.
|
|
||||||
ShrinkToBytes int64 `json:"shrinkToBytes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o fsCloneOpts) encode() string { b, _ := json.Marshal(o); return string(b) }
|
|
||||||
func decodeFSCloneOpts(s string) (fsCloneOpts, error) {
|
|
||||||
var o fsCloneOpts
|
|
||||||
err := json.Unmarshal([]byte(s), &o)
|
|
||||||
return o, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickCloner returns the fs-image cloner for (fsType, tool) or nil if the
|
|
||||||
// partition should be handled some other way. tool is the family name chosen
|
|
||||||
// by chooseCloneMethod ("ntfsclone", "partclone", "e2image").
|
|
||||||
func pickCloner(dev, fsType, tool string) *fsCloner {
|
|
||||||
switch tool {
|
|
||||||
case "ntfsclone":
|
|
||||||
return &fsCloner{
|
|
||||||
name: "ntfsclone",
|
|
||||||
save: []string{"ntfsclone", "--save-image", "--force", "--output", "-", dev},
|
|
||||||
restore: []string{"ntfsclone", "--restore-image", "--overwrite", dev, "-"},
|
|
||||||
}
|
|
||||||
case "e2image":
|
|
||||||
return &fsCloner{
|
|
||||||
name: "e2image",
|
|
||||||
// -ra: raw + skip unallocated; stream to/from stdio.
|
|
||||||
save: []string{"e2image", "-ra", "-p", dev, "-"},
|
|
||||||
restore: []string{"e2image", "-ra", "-p", "-", dev},
|
|
||||||
}
|
|
||||||
case "partclone":
|
|
||||||
pc := "partclone." + partcloneSuffix(fsType)
|
|
||||||
return &fsCloner{
|
|
||||||
name: pc,
|
|
||||||
save: []string{pc, "-c", "-s", dev, "-O", "-", "-L", "/dev/null"},
|
|
||||||
restore: []string{"partclone.restore", "-s", "-", "-O", dev, "-L", "/dev/null"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func partcloneSuffix(fsType string) string {
|
|
||||||
switch fsType {
|
|
||||||
case "ext2", "ext3", "ext4":
|
|
||||||
return "extfs"
|
|
||||||
case "vfat", "fat", "fat12", "fat16", "fat32", "msdos":
|
|
||||||
return "fat"
|
|
||||||
case "ntfs":
|
|
||||||
return "ntfs"
|
|
||||||
default:
|
|
||||||
return fsType // exfat, xfs, btrfs, f2fs, hfsplus, ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// chooseCloneMethod decides how a partition's data is copied.
|
|
||||||
//
|
|
||||||
// "raw" -> the block-diff engine over an offset window (also the
|
|
||||||
// re-run-friendly path; used for swap, unknown fs, --raw)
|
|
||||||
// "fs-image" -> stream a filesystem image with a standard tool
|
|
||||||
// "file-level" -> mkfs on the target + rsync
|
|
||||||
func chooseCloneMethod(p Partition, tools map[string]bool, forceRaw, forceFile map[int]bool, fileAuto bool) (method, tool string) {
|
|
||||||
if forceRaw[p.Num] {
|
|
||||||
return "raw", ""
|
|
||||||
}
|
|
||||||
if forceFile[p.Num] {
|
|
||||||
return "file-level", ""
|
|
||||||
}
|
|
||||||
if p.isBIOSBoot() {
|
|
||||||
return "raw", "" // tiny, and holds GRUB core - always verbatim
|
|
||||||
}
|
|
||||||
switch p.FSType {
|
|
||||||
case "", "swap", "crypto_LUKS", "LVM2_member":
|
|
||||||
return "raw", ""
|
|
||||||
case "ntfs":
|
|
||||||
if tools["ntfsclone"] {
|
|
||||||
return "fs-image", "ntfsclone"
|
|
||||||
}
|
|
||||||
case "ext2", "ext3", "ext4":
|
|
||||||
if tools["partclone.extfs"] && tools["partclone.restore"] {
|
|
||||||
return "fs-image", "partclone"
|
|
||||||
}
|
|
||||||
if tools["e2image"] {
|
|
||||||
return "fs-image", "e2image"
|
|
||||||
}
|
|
||||||
case "vfat", "fat", "fat12", "fat16", "fat32", "msdos":
|
|
||||||
if tools["partclone.fat"] && tools["partclone.restore"] {
|
|
||||||
return "fs-image", "partclone"
|
|
||||||
}
|
|
||||||
case "exfat", "xfs", "btrfs", "f2fs", "hfsplus":
|
|
||||||
if tools["partclone."+partcloneSuffix(p.FSType)] && tools["partclone.restore"] {
|
|
||||||
return "fs-image", "partclone"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fileAuto && p.FSType != "" && tools["rsync"] {
|
|
||||||
return "file-level", ""
|
|
||||||
}
|
|
||||||
return "raw", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// progressScanner reads a cloner's stderr line by line, pulls a percentage
|
|
||||||
// out of the tool's own progress chatter, and calls emit with an estimated
|
|
||||||
// byte count (fraction * sizeBytes). It swallows everything else.
|
|
||||||
var pctRe = regexp.MustCompile(`([0-9]{1,3}(?:\.[0-9]+)?)\s*%|([0-9]{1,3}(?:\.[0-9]+)?)\s*percent`)
|
|
||||||
|
|
||||||
func scanCloneProgress(r io.Reader, sizeBytes int64, emit func(bytesDone int64)) {
|
|
||||||
br := bufio.NewReader(r)
|
|
||||||
var buf []byte
|
|
||||||
flush := func() {
|
|
||||||
line := strings.TrimSpace(string(buf))
|
|
||||||
buf = buf[:0]
|
|
||||||
if line == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m := pctRe.FindStringSubmatch(line)
|
|
||||||
if m == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s := m[1]
|
|
||||||
if s == "" {
|
|
||||||
s = m[2]
|
|
||||||
}
|
|
||||||
if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 && f <= 100 {
|
|
||||||
emit(int64(f / 100 * float64(sizeBytes)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
c, err := br.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c == '\n' || c == '\r' {
|
|
||||||
flush()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
buf = append(buf, c)
|
|
||||||
if len(buf) > 4096 {
|
|
||||||
buf = buf[:0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// preShrinkFS shrinks the filesystem on dev to (at least) toBytes in place.
|
|
||||||
// Used only on the source, only with --allow-shrink --yes. Best effort with a
|
|
||||||
// hard error if the resize tool fails.
|
|
||||||
func preShrinkFS(dev, fsType string, toBytes int64) error {
|
|
||||||
if toBytes <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case isExtFS(fsType):
|
|
||||||
if out, err := runCmd("e2fsck", "-f", "-y", dev); err != nil {
|
|
||||||
return fmt.Errorf("e2fsck %s: %v: %s", dev, err, out)
|
|
||||||
}
|
|
||||||
kib := toBytes / 1024
|
|
||||||
if out, err := runCmd("resize2fs", dev, fmt.Sprintf("%dK", kib)); err != nil {
|
|
||||||
return fmt.Errorf("resize2fs %s %dK: %v: %s", dev, kib, err, out)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case fsType == "ntfs":
|
|
||||||
// ntfsresize prompts for confirmation on a real resize; feed it "y".
|
|
||||||
if out, err := runCmdStdin("y\n", "ntfsresize", "--force", "--size", strconv.FormatInt(toBytes, 10), dev); err != nil {
|
|
||||||
return fmt.Errorf("ntfsresize %s --size %d: %v: %s", dev, toBytes, err, out)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("don't know how to shrink a %s filesystem", fsType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
112
main.go
112
main.go
@ -4,7 +4,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -17,8 +16,6 @@ func main() {
|
|||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "sync":
|
case "sync":
|
||||||
err = cmdSync(os.Args[2:])
|
err = cmdSync(os.Args[2:])
|
||||||
case "clone-disk":
|
|
||||||
err = cmdCloneDisk(os.Args[2:])
|
|
||||||
case "agent":
|
case "agent":
|
||||||
err = cmdAgent(os.Args[2:])
|
err = cmdAgent(os.Args[2:])
|
||||||
case "version", "--version":
|
case "version", "--version":
|
||||||
@ -42,9 +39,8 @@ func usage() {
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
clonetool sync --source LOC --dest LOC [options]
|
clonetool sync --source LOC --dest LOC [options]
|
||||||
clonetool clone-disk --source LOC --dest LOC [options]
|
|
||||||
clonetool version
|
clonetool version
|
||||||
clonetool agent --role {control|sink|source-stream|fs-send|fs-recv} ... (internal, spawned automatically)
|
clonetool agent --role {control|sink|source-stream} ... (internal, spawned automatically)
|
||||||
|
|
||||||
LOC is either a local path, or [user@]host:path for a path reached over SSH.
|
LOC is either a local path, or [user@]host:path for a path reached over SSH.
|
||||||
Source, destination, and the machine running "sync" (the manager) may all be
|
Source, destination, and the machine running "sync" (the manager) may all be
|
||||||
@ -54,21 +50,6 @@ a single block itself.
|
|||||||
clonetool keeps no state between runs: every sync re-reads and re-hashes both
|
clonetool keeps no state between runs: every sync re-reads and re-hashes both
|
||||||
the source and the destination and transfers only the blocks that differ.
|
the source and the destination and transfers only the blocks that differ.
|
||||||
|
|
||||||
Options for clone-disk (whole-disk: boot record + partition table + per-filesystem data):
|
|
||||||
--parts LIST only clone these partition numbers (comma-separated; default all)
|
|
||||||
--raw LIST force a raw block clone for these partitions
|
|
||||||
--file-level LIST mkfs + rsync these partitions instead of a filesystem image
|
|
||||||
--file-level-auto use file-level for filesystem types with no image cloner
|
|
||||||
--allow-shrink permit shrinking trailing partitions to fit a smaller target
|
|
||||||
(resizes the SOURCE filesystem in place before imaging)
|
|
||||||
--no-shrink never shrink; fail instead if the target is too small
|
|
||||||
--new-ids randomize the GPT disk GUID / MBR signature on the target
|
|
||||||
--reinstall-bootloader after copy, run grub-install / grub-mkconfig on the target (Linux)
|
|
||||||
--vss Windows source: take a Volume Shadow Copy per NTFS volume (default on)
|
|
||||||
--image-size SIZE file target: image size (default = enough for the layout)
|
|
||||||
(also accepts --block-size --job --yes --sudo --deploy --ssh --ssh-opt --remote-bin
|
|
||||||
--connect-timeout --manager-host, same meaning as sync)
|
|
||||||
|
|
||||||
Options for sync:
|
Options for sync:
|
||||||
--block-size SIZE block size, e.g. 4M (default 4M)
|
--block-size SIZE block size, e.g. 4M (default 4M)
|
||||||
--job NAME optional label shown in progress/log output
|
--job NAME optional label shown in progress/log output
|
||||||
@ -134,97 +115,6 @@ func cmdSync(args []string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseIntList(s string) ([]int, error) {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var out []int
|
|
||||||
for _, f := range strings.Split(s, ",") {
|
|
||||||
f = strings.TrimSpace(f)
|
|
||||||
if f == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var n int
|
|
||||||
if _, err := fmt.Sscanf(f, "%d", &n); err != nil || n <= 0 {
|
|
||||||
return nil, fmt.Errorf("invalid partition number %q", f)
|
|
||||||
}
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdCloneDisk(args []string) error {
|
|
||||||
fs := flag.NewFlagSet("clone-disk", flag.ContinueOnError)
|
|
||||||
job := fs.String("job", "", "optional label for progress/log output")
|
|
||||||
source := fs.String("source", "", "source whole-disk location (required)")
|
|
||||||
dest := fs.String("dest", "", "destination whole-disk or image location (required)")
|
|
||||||
blockSizeStr := fs.String("block-size", "4M", "block size for raw/boot-region transfers, e.g. 4M")
|
|
||||||
yes := fs.Bool("yes", false, "don't prompt before shrinking an existing destination image")
|
|
||||||
sudoMode := fs.String("sudo", "auto", "device-access privilege escalation: auto|always|never")
|
|
||||||
deploy := fs.Bool("deploy", true, "copy this binary to remote hosts that lack it")
|
|
||||||
connectTimeout := fs.Int("connect-timeout", defaultConnectTimeoutSec, "ssh connect timeout (seconds)")
|
|
||||||
sshBin := fs.String("ssh", "ssh", "ssh binary")
|
|
||||||
remoteBin := fs.String("remote-bin", "clonetool", "clonetool path on remote hosts")
|
|
||||||
managerHost := fs.String("manager-host", "", "address peers use to reach this machine")
|
|
||||||
partsStr := fs.String("parts", "", "only clone these partition numbers (comma-separated)")
|
|
||||||
rawStr := fs.String("raw", "", "force raw block clone for these partition numbers")
|
|
||||||
fileLevelStr := fs.String("file-level", "", "mkfs + rsync these partition numbers")
|
|
||||||
fileAuto := fs.Bool("file-level-auto", false, "file-level for fs types with no image cloner")
|
|
||||||
allowShrink := fs.Bool("allow-shrink", false, "permit shrinking trailing partitions to fit a smaller target")
|
|
||||||
noShrink := fs.Bool("no-shrink", false, "never shrink; fail if the target is too small")
|
|
||||||
newIDs := fs.Bool("new-ids", false, "randomize the GPT disk GUID / MBR signature on the target")
|
|
||||||
reinstallBoot := fs.Bool("reinstall-bootloader", false, "run grub-install / grub-mkconfig on the target after copy")
|
|
||||||
vss := fs.Bool("vss", true, "Windows source: take a Volume Shadow Copy per NTFS volume")
|
|
||||||
imageSizeStr := fs.String("image-size", "", "file target: image size (default: enough for the layout)")
|
|
||||||
var sshOpts stringSlice
|
|
||||||
fs.Var(&sshOpts, "ssh-opt", `extra "-o OPT" passed to ssh (repeatable)`)
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if *source == "" || *dest == "" {
|
|
||||||
fs.Usage()
|
|
||||||
return fmt.Errorf("--source and --dest are required")
|
|
||||||
}
|
|
||||||
blockSize, err := parseSize(*blockSizeStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("--block-size: %w", err)
|
|
||||||
}
|
|
||||||
switch *sudoMode {
|
|
||||||
case "auto", "always", "never":
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("--sudo: want auto|always|never, got %q", *sudoMode)
|
|
||||||
}
|
|
||||||
parts, err := parseIntList(*partsStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("--parts: %w", err)
|
|
||||||
}
|
|
||||||
raw, err := parseIntList(*rawStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("--raw: %w", err)
|
|
||||||
}
|
|
||||||
fileLevel, err := parseIntList(*fileLevelStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("--file-level: %w", err)
|
|
||||||
}
|
|
||||||
var imageSize int64
|
|
||||||
if *imageSizeStr != "" {
|
|
||||||
if imageSize, err = parseSize(*imageSizeStr); err != nil {
|
|
||||||
return fmt.Errorf("--image-size: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return runCloneDisk(CloneDiskConfig{
|
|
||||||
SyncConfig: SyncConfig{
|
|
||||||
Job: *job, Source: *source, Dest: *dest, BlockSize: blockSize,
|
|
||||||
Yes: *yes, Sudo: *sudoMode, Deploy: *deploy, ConnectTimeoutSec: *connectTimeout,
|
|
||||||
SSHBin: *sshBin, SSHOpts: sshOpts, RemoteBin: *remoteBin, ManagerHost: *managerHost,
|
|
||||||
},
|
|
||||||
Parts: parts, Raw: raw, FileLevel: fileLevel, FileAuto: *fileAuto,
|
|
||||||
AllowShrink: *allowShrink, NoShrink: *noShrink, NewIDs: *newIDs,
|
|
||||||
ReinstallBoot: *reinstallBoot, VSS: *vss, ImageSize: imageSize,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSize(s string) (int64, error) {
|
func parseSize(s string) (int64, error) {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return 0, fmt.Errorf("empty size")
|
return 0, fmt.Errorf("empty size")
|
||||||
|
|||||||
25
manager.go
25
manager.go
@ -153,11 +153,6 @@ func bringUpController(spec Spec, tag string, cfg *SyncConfig, remoteBin, probeP
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return c, info, sudo, 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 {
|
if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo {
|
||||||
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, probePath)
|
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, probePath)
|
||||||
c.Close()
|
c.Close()
|
||||||
@ -331,26 +326,6 @@ func (p *progressPrinter) print(m CtrlMsg) {
|
|||||||
p.srcReadRate.mib(p.blockSize), p.dstReadRate.mib(p.blockSize), p.dstWriteRate.mib(p.blockSize))
|
p.srcReadRate.mib(p.blockSize), p.dstReadRate.mib(p.blockSize), p.dstWriteRate.mib(p.blockSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
// printBytes renders a byte-oriented status line for clone-disk's fs-image
|
|
||||||
// partition clones, where progress arrives as BytesCopied/BytesTotal rather
|
|
||||||
// than block counts.
|
|
||||||
func (p *progressPrinter) printBytes(m CtrlMsg) {
|
|
||||||
if m.Type != msgProgress {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.active = true
|
|
||||||
done, total := m.BytesCopied, m.BytesTotal
|
|
||||||
if total <= 0 {
|
|
||||||
total = done
|
|
||||||
}
|
|
||||||
stage := m.Stage
|
|
||||||
if stage == "" {
|
|
||||||
stage = "fs-image"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "\r %s %s %s / %s ",
|
|
||||||
stage, progressBar(done, total), humanBytes(done), humanBytes(total))
|
|
||||||
}
|
|
||||||
|
|
||||||
// finish ends the current status line with a newline so following output
|
// finish ends the current status line with a newline so following output
|
||||||
// (and the shell prompt) starts clean.
|
// (and the shell prompt) starts clean.
|
||||||
func (p *progressPrinter) finish() {
|
func (p *progressPrinter) finish() {
|
||||||
|
|||||||
215
plan.go
215
plan.go
@ -1,215 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// plannedPart is one partition's copy plan: where it lives on each side (in
|
|
||||||
// bytes) and how its data moves.
|
|
||||||
type plannedPart struct {
|
|
||||||
Num int
|
|
||||||
SrcStartB, SrcSizeB int64
|
|
||||||
DstStartB, DstSizeB int64
|
|
||||||
Method string // "raw" | "fs-image" | "file-level"
|
|
||||||
Tool string // fs-image tool family
|
|
||||||
FSType string
|
|
||||||
ShrinkToB int64 // >0: shrink the fs to this many bytes before imaging
|
|
||||||
}
|
|
||||||
|
|
||||||
type targetPlan struct {
|
|
||||||
Script string // sfdisk restore script ("" when the source has no table / Windows-native)
|
|
||||||
IsFile bool
|
|
||||||
ImageSize int64 // file target: truncate to this
|
|
||||||
Parts []plannedPart
|
|
||||||
BootRegions []Region
|
|
||||||
RootPart int
|
|
||||||
ESPPart int
|
|
||||||
UEFI bool
|
|
||||||
Notes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// bootOpts is the JSON blob passed to reinstall_boot.
|
|
||||||
type bootOpts struct {
|
|
||||||
RootPart int `json:"rootPart"` // 1-based partition number holding "/"
|
|
||||||
ESPPart int `json:"espPart"` // 1-based ESP partition number, 0 if BIOS-only
|
|
||||||
UEFI bool `json:"uefi"`
|
|
||||||
DiskPath string `json:"diskPath"` // whole-disk path grub-install targets
|
|
||||||
}
|
|
||||||
|
|
||||||
type planOpts struct {
|
|
||||||
Parts map[int]bool // empty = all
|
|
||||||
Raw map[int]bool
|
|
||||||
FileLevel map[int]bool
|
|
||||||
FileAuto bool
|
|
||||||
AllowShrink bool
|
|
||||||
NewIDs bool
|
|
||||||
ImageSize int64 // explicit file-target size, 0 = auto
|
|
||||||
}
|
|
||||||
|
|
||||||
const gptTailSectors = 33 // secondary GPT header + entries
|
|
||||||
|
|
||||||
// planTargetLayout turns a probed source layout + the target's nature/size
|
|
||||||
// into a concrete copy plan, applying the sizing rules:
|
|
||||||
//
|
|
||||||
// - device target: never grow; if everything fits, keep the layout and
|
|
||||||
// leave trailing space untouched; if the last partition(s) overflow,
|
|
||||||
// shrink them (filesystem + partition) from the last inward, or fail.
|
|
||||||
// - file target: size the image to hold the layout (or ImageSize).
|
|
||||||
func planTargetLayout(src *DiskLayout, isFile bool, destSize int64, o planOpts) (*targetPlan, error) {
|
|
||||||
sector := src.LogicalSector
|
|
||||||
if sector <= 0 {
|
|
||||||
sector = 512
|
|
||||||
}
|
|
||||||
parts := sortedParts(src.Partitions)
|
|
||||||
var sel []Partition
|
|
||||||
for _, p := range parts {
|
|
||||||
if len(o.Parts) == 0 || o.Parts[p.Num] {
|
|
||||||
sel = append(sel, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(sel) == 0 && src.Scheme != "raw" {
|
|
||||||
return nil, fmt.Errorf("no partitions selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
plan := &targetPlan{IsFile: isFile, BootRegions: append([]Region(nil), src.BootRegions...)}
|
|
||||||
|
|
||||||
// With --new-ids on an MBR disk, keep the raw bootstrap copy clear of the
|
|
||||||
// 4-byte disk signature at offset 0x1B8 so sfdisk's freshly generated one
|
|
||||||
// survives (GPT regenerates its GUID in the header, which we never
|
|
||||||
// raw-copy, so no clamp is needed there).
|
|
||||||
if o.NewIDs && src.Scheme == "mbr" {
|
|
||||||
for i := range plan.BootRegions {
|
|
||||||
r := &plan.BootRegions[i]
|
|
||||||
if r.Offset == 0 && r.Length > 0x1B8 {
|
|
||||||
r.Length = 0x1B8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bytes the layout needs on disk = end of the last selected partition
|
|
||||||
// (+ secondary GPT for GPT disks).
|
|
||||||
lastEndB := int64(0)
|
|
||||||
for _, p := range sel {
|
|
||||||
if e := (p.Start + p.Size) * sector; e > lastEndB {
|
|
||||||
lastEndB = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
neededB := lastEndB
|
|
||||||
if src.Scheme == "gpt" {
|
|
||||||
neededB += gptTailSectors * sector
|
|
||||||
}
|
|
||||||
if src.Scheme == "raw" {
|
|
||||||
neededB = src.DiskSize
|
|
||||||
}
|
|
||||||
|
|
||||||
if isFile {
|
|
||||||
plan.ImageSize = o.ImageSize
|
|
||||||
if plan.ImageSize == 0 {
|
|
||||||
plan.ImageSize = roundUp(neededB+(1<<20), 1<<20)
|
|
||||||
}
|
|
||||||
destSize = plan.ImageSize
|
|
||||||
}
|
|
||||||
if destSize <= 0 {
|
|
||||||
return nil, fmt.Errorf("could not determine target size")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- shrink pass ----------------------------------------------------
|
|
||||||
sizeOverride := map[int]int64{}
|
|
||||||
if destSize < neededB {
|
|
||||||
short := neededB - destSize
|
|
||||||
for i := len(sel) - 1; i >= 0 && short > 0; i-- {
|
|
||||||
p := &sel[i]
|
|
||||||
curB := p.Size * sector
|
|
||||||
minB := p.FSMinBytes
|
|
||||||
shrinkable := minB > 0 && (isExtFS(p.FSType) || p.FSType == "ntfs")
|
|
||||||
if !shrinkable {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
minB = roundUp(minB+(16<<20), sector) // 16 MiB slack
|
|
||||||
if minB >= curB {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reduce := curB - minB
|
|
||||||
if reduce > short {
|
|
||||||
reduce = roundUp(short, sector)
|
|
||||||
}
|
|
||||||
newB := curB - reduce
|
|
||||||
sizeOverride[p.Num] = newB / sector
|
|
||||||
plan.Notes = append(plan.Notes,
|
|
||||||
fmt.Sprintf("partition p%d (%s) shrinks %s -> %s to fit the target",
|
|
||||||
p.Num, p.FSType, humanBytes(curB), humanBytes(newB)))
|
|
||||||
short -= reduce
|
|
||||||
}
|
|
||||||
if short > 0 {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"target (%s) is smaller than the source layout needs (%s); the trailing partition(s) "+
|
|
||||||
"cannot shrink to make up the missing %s; use a larger target",
|
|
||||||
humanBytes(destSize), humanBytes(neededB), humanBytes(short))
|
|
||||||
}
|
|
||||||
if !o.AllowShrink {
|
|
||||||
var names []string
|
|
||||||
for n := range sizeOverride {
|
|
||||||
names = append(names, fmt.Sprintf("p%d", n))
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"target is smaller than the source; partition(s) %s would have to be shrunk. "+
|
|
||||||
"Re-run with --allow-shrink (this resizes the SOURCE filesystem in place before imaging) "+
|
|
||||||
"or use --no-shrink off / a larger target",
|
|
||||||
strings.Join(names, ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- partition-table script --------------------------------------
|
|
||||||
if src.Scheme != "raw" && strings.TrimSpace(src.SfdiskDump) != "" {
|
|
||||||
plan.Script = src.sfdiskRestoreScript(o.NewIDs, sizeOverride)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-partition method --------------------------------------
|
|
||||||
for _, p := range sel {
|
|
||||||
pp := plannedPart{
|
|
||||||
Num: p.Num,
|
|
||||||
SrcStartB: p.Start * sector,
|
|
||||||
SrcSizeB: p.Size * sector,
|
|
||||||
DstStartB: p.Start * sector,
|
|
||||||
DstSizeB: p.Size * sector,
|
|
||||||
FSType: p.FSType,
|
|
||||||
}
|
|
||||||
if ov, ok := sizeOverride[p.Num]; ok {
|
|
||||||
pp.DstSizeB = ov * sector
|
|
||||||
pp.ShrinkToB = ov * sector
|
|
||||||
}
|
|
||||||
method, tool := chooseCloneMethod(p, src.Tools, o.Raw, o.FileLevel, o.FileAuto)
|
|
||||||
pp.Method, pp.Tool = method, tool
|
|
||||||
if pp.ShrinkToB > 0 && method != "fs-image" {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"partition p%d (%s) must shrink to fit but no image cloner is available for it; "+
|
|
||||||
"install ntfsclone / partclone or use a larger target", p.Num, orDash(p.FSType))
|
|
||||||
}
|
|
||||||
plan.Parts = append(plan.Parts, pp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- boot roles ------------------------------------------------
|
|
||||||
plan.RootPart, plan.ESPPart = pickBootParts(sel)
|
|
||||||
plan.UEFI = plan.ESPPart != 0
|
|
||||||
return plan, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExtFS(t string) bool { return t == "ext2" || t == "ext3" || t == "ext4" }
|
|
||||||
|
|
||||||
// pickBootParts guesses the "/" partition (largest Linux fs) and the ESP.
|
|
||||||
func pickBootParts(sel []Partition) (root, esp int) {
|
|
||||||
var bestSize int64
|
|
||||||
for _, p := range sel {
|
|
||||||
if p.isESP() && esp == 0 {
|
|
||||||
esp = p.Num
|
|
||||||
}
|
|
||||||
switch p.FSType {
|
|
||||||
case "ext2", "ext3", "ext4", "btrfs", "xfs":
|
|
||||||
if sz := p.Size; sz > bestSize {
|
|
||||||
bestSize, root = sz, p.Num
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root, esp
|
|
||||||
}
|
|
||||||
146
plan_test.go
146
plan_test.go
@ -1,146 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// mkLayout builds a GPT layout: ESP (vfat) + root (ext4) + data (ntfs).
|
|
||||||
func mkLayout(diskBytes int64) *DiskLayout {
|
|
||||||
d := &DiskLayout{
|
|
||||||
DiskPath: "/dev/src", DiskSize: diskBytes, LogicalSector: 512,
|
|
||||||
Scheme: "gpt", Label: "gpt", LabelID: "GUID",
|
|
||||||
SfdiskDump: gptDump, // any non-empty dump so a script is produced
|
|
||||||
Tools: map[string]bool{
|
|
||||||
"sfdisk": true, "ntfsclone": true,
|
|
||||||
"partclone.extfs": true, "partclone.restore": true, "e2image": true,
|
|
||||||
},
|
|
||||||
Partitions: []Partition{
|
|
||||||
{Num: 1, Start: 2048, Size: 204800, Type: gptESP, FSType: "vfat"}, // 100 MiB
|
|
||||||
{Num: 2, Start: 206848, Size: 10 * 2048 * 1024, FSType: "ext4", FSMinBytes: 2 << 30}, // 10 GiB, min 2 GiB
|
|
||||||
{Num: 3, Start: 206848 + 10*2048*1024, Size: 10 * 2048 * 1024, FSType: "ntfs", FSMinBytes: 3 << 30}, // 10 GiB, min 3 GiB
|
|
||||||
},
|
|
||||||
BootRegions: []Region{{Offset: 0, Length: 446, Note: "MBR bootstrap"}},
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastEndBytes(d *DiskLayout) int64 {
|
|
||||||
var e int64
|
|
||||||
for _, p := range d.Partitions {
|
|
||||||
if x := (p.Start + p.Size) * d.LogicalSector; x > e {
|
|
||||||
e = x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return e + gptTailSectors*d.LogicalSector
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanFitsDeviceKeepsLayout(t *testing.T) {
|
|
||||||
d := mkLayout(40 << 30)
|
|
||||||
plan, err := planTargetLayout(d, false, 40<<30, planOpts{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(plan.Parts) != 3 {
|
|
||||||
t.Fatalf("parts = %d", len(plan.Parts))
|
|
||||||
}
|
|
||||||
for _, p := range plan.Parts {
|
|
||||||
if p.SrcSizeB != p.DstSizeB || p.ShrinkToB != 0 {
|
|
||||||
t.Fatalf("p%d resized unexpectedly: %+v", p.Num, p)
|
|
||||||
}
|
|
||||||
if p.SrcStartB != p.DstStartB {
|
|
||||||
t.Fatalf("p%d start moved", p.Num)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if plan.Parts[0].Method != "raw" { // vfat, no partclone.fat in tool set
|
|
||||||
t.Fatalf("p1 method = %s", plan.Parts[0].Method)
|
|
||||||
}
|
|
||||||
if plan.Parts[1].Method != "fs-image" || plan.Parts[1].Tool != "partclone" {
|
|
||||||
t.Fatalf("p2 method/tool = %s/%s", plan.Parts[1].Method, plan.Parts[1].Tool)
|
|
||||||
}
|
|
||||||
if plan.Parts[2].Method != "fs-image" || plan.Parts[2].Tool != "ntfsclone" {
|
|
||||||
t.Fatalf("p3 method/tool = %s/%s", plan.Parts[2].Method, plan.Parts[2].Tool)
|
|
||||||
}
|
|
||||||
if plan.ESPPart != 1 || plan.RootPart != 2 || !plan.UEFI {
|
|
||||||
t.Fatalf("boot roles: esp=%d root=%d uefi=%v", plan.ESPPart, plan.RootPart, plan.UEFI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanTrailingFreeSpaceOK(t *testing.T) {
|
|
||||||
d := mkLayout(60 << 30)
|
|
||||||
// target smaller than the *disk* but larger than what the layout needs
|
|
||||||
need := lastEndBytes(d)
|
|
||||||
if _, err := planTargetLayout(d, false, need+1<<20, planOpts{}); err != nil {
|
|
||||||
t.Fatalf("should fit into %d (need %d): %v", need+1<<20, need, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanNeedsShrinkRequiresFlag(t *testing.T) {
|
|
||||||
d := mkLayout(40 << 30)
|
|
||||||
need := lastEndBytes(d)
|
|
||||||
small := need - (4 << 30) // 4 GiB short; ntfs (min 3 GiB) can give it up
|
|
||||||
|
|
||||||
if _, err := planTargetLayout(d, false, small, planOpts{}); err == nil {
|
|
||||||
t.Fatalf("expected error without --allow-shrink")
|
|
||||||
}
|
|
||||||
plan, err := planTargetLayout(d, false, small, planOpts{AllowShrink: true})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("with --allow-shrink: %v", err)
|
|
||||||
}
|
|
||||||
p3 := plan.Parts[2]
|
|
||||||
if p3.ShrinkToB == 0 || p3.ShrinkToB >= p3.SrcSizeB {
|
|
||||||
t.Fatalf("p3 should shrink: %+v", p3)
|
|
||||||
}
|
|
||||||
if p3.Method != "fs-image" {
|
|
||||||
t.Fatalf("shrunk partition must be fs-image, got %s", p3.Method)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanCannotFit(t *testing.T) {
|
|
||||||
d := mkLayout(40 << 30)
|
|
||||||
// absurdly small: even shrinking ntfs+ext to their minimums can't help
|
|
||||||
if _, err := planTargetLayout(d, false, 1<<30, planOpts{AllowShrink: true}); err == nil {
|
|
||||||
t.Fatalf("expected cannot-fit error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanFileTargetSizing(t *testing.T) {
|
|
||||||
d := mkLayout(40 << 30)
|
|
||||||
plan, err := planTargetLayout(d, true, 0, planOpts{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
need := lastEndBytes(d)
|
|
||||||
if plan.ImageSize < need || plan.ImageSize > need+(2<<20) {
|
|
||||||
t.Fatalf("image size %d not ~%d", plan.ImageSize, need)
|
|
||||||
}
|
|
||||||
if plan.Script == "" {
|
|
||||||
t.Fatalf("expected a partition-table script for a file target")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanNewIDsScript(t *testing.T) {
|
|
||||||
d := mkLayout(40 << 30)
|
|
||||||
plan, err := planTargetLayout(d, true, 0, planOpts{NewIDs: true})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if plan.Script == "" || containsAny(plan.Script, "label-id:", "uuid=") {
|
|
||||||
t.Fatalf("--new-ids script still has ids:\n%s", plan.Script)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsAny(s string, subs ...string) bool {
|
|
||||||
for _, x := range subs {
|
|
||||||
if len(x) > 0 && indexOf(s, x) >= 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexOf(s, sub string) int {
|
|
||||||
for i := 0; i+len(sub) <= len(s); i++ {
|
|
||||||
if s[i:i+len(sub)] == sub {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@ -86,9 +86,8 @@ type blockHash struct {
|
|||||||
// is computed (this is what lets the peer start comparing before the whole
|
// is computed (this is what lets the peer start comparing before the whole
|
||||||
// side has been scanned). It keeps no hash state of its own. onProgress, if
|
// side has been scanned). It keeps no hash state of its own. onProgress, if
|
||||||
// non-nil, is called with (blocksHashed, totalBlocks) before the first block
|
// non-nil, is called with (blocksHashed, totalBlocks) before the first block
|
||||||
// and after each one. base is 0 for a whole-file sync; clone-disk passes a
|
// and after each one. base is 0 for a whole-file sync; a non-zero base
|
||||||
// non-zero base to fingerprint just a boot region / offset-addressed
|
// fingerprints just a byte window within a larger handle.
|
||||||
// partition within a larger handle.
|
|
||||||
func streamHashBlocks(f *os.File, base, size, blockSize, align int64, onHash func(bh blockHash) error, onProgress func(done, total int64)) error {
|
func streamHashBlocks(f *os.File, base, size, blockSize, align int64, onHash func(bh blockHash) error, onProgress func(done, total int64)) error {
|
||||||
if err := checkBlockAlign(blockSize, align); err != nil {
|
if err := checkBlockAlign(blockSize, align); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user