borg/scripts/lvm-thin-map.py
Thomas Waldmann 14d05b60b7
scripts: add lvm-thin-map.py to generate input maps from thin metadata, see #4363
Converts thin_dump XML (full mode: allocation map of one thin LV) and
thin_delta XML (delta mode: differences between two thin snapshots)
into the borg create --map format. The docstring documents the full
workflow including the reserve/release_metadata_snap steps and the
snapshot discipline needed for --reuse-from.

Also add a docs section pointing from the --read-special LVM example
to the input map based approach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:37:58 +02:00

166 lines
7 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Generate a borg input map (borg create --map) from LVM thin pool metadata, see #4363.
This converts XML produced by the thin-provisioning-tools (thin_dump / thin_delta,
version >= 0.7.4 required - older thin_delta versions had bugs) into the map format
expected by ``borg create --map``, so borg only reads the parts of a thin LV snapshot
that actually contain (changed) data.
full mode - initial (or periodic full-read) backup of a thin LV snapshot:
lvcreate -s -n snap1 vg/lv # snapshot to back up (keep it for delta mode!)
lvchange -ay -Ky vg/snap1
dmsetup message vg-pool-tpool 0 reserve_metadata_snap
thin_dump -m --dev-id $(lvs --noheadings -o thin_id vg/snap1) /dev/mapper/vg-pool_tmeta \
| lvm-thin-map.py full --device /dev/vg/snap1 > snap1.map
dmsetup message vg-pool-tpool 0 release_metadata_snap
borg create --read-special --chunker-params fixed,4194304 \
--map snap1.map lv-backup /dev/vg/snap1
Unallocated ranges read as zeros, so borg stores them as holes without reading them.
delta mode - incremental backup against the previous snapshot's archive:
lvcreate -s -n snap2 vg/lv
lvchange -ay -Ky vg/snap2
dmsetup message vg-pool-tpool 0 reserve_metadata_snap
thin_delta -m --snap1 $(lvs --noheadings -o thin_id vg/snap1) \
--snap2 $(lvs --noheadings -o thin_id vg/snap2) /dev/mapper/vg-pool_tmeta \
| lvm-thin-map.py delta --device /dev/vg/snap2 > snap2.map
dmsetup message vg-pool-tpool 0 release_metadata_snap
borg create --read-special --chunker-params fixed,4194304 \
--map snap2.map --reuse-from lv-backup lv-backup /dev/vg/snap2
lvremove vg/snap1 # snap2 is the reference for the next delta
Ranges that are identical in both snapshots become "same" ranges: borg reuses the
chunks of the --reuse-from reference archive for them, without reading the device.
Notes:
- The reference snapshot (--snap1) must be the snapshot that was backed up into the
--reuse-from archive - THIS IS NOT CHECKED and cannot be. A wrong pairing silently
produces an archive that does not match the device.
- Remember to release_metadata_snap; a leftover metadata snapshot blocks future reserves.
- The pool's chunk size (data_block_size) defines the map granularity. borg's fixed
chunker block size does not need to match it; ranges are given in exact bytes.
"""
import argparse
import os
import sys
import xml.etree.ElementTree as ET
DELTA_STATES = {
"same": "same", # unchanged between the two snapshots -> reuse reference chunks
"different": "data", # changed -> read
"right_only": "data", # newly allocated -> read
"left_only": "zero", # deallocated (discarded) -> reads as zeros now
}
SECTOR = 512
def die(msg):
print(f"lvm-thin-map: error: {msg}", file=sys.stderr)
sys.exit(2)
def device_size(args):
if args.size is not None:
return args.size
fd = os.open(args.device, os.O_RDONLY)
try:
return os.lseek(fd, 0, os.SEEK_END)
finally:
os.close(fd)
def parse_ranges(xml_file, mode, meta):
"""Yield (start_block, length_blocks, state) from thin_dump / thin_delta XML; fill *meta* in place."""
inside_device = False
context = ET.iterparse(xml_file, events=("start", "end"))
for event, elem in context:
if event == "start":
if elem.tag == "superblock":
meta["data_block_size"] = int(elem.get("data_block_size")) * SECTOR
elif elem.tag == "diff":
meta["left"], meta["right"] = elem.get("left"), elem.get("right")
elif elem.tag == "device":
if "dev_id" in meta:
die("XML contains more than one <device>, re-run thin_dump with --dev-id")
meta["dev_id"] = elem.get("dev_id")
inside_device = True
elif elem.tag == "range":
die("nested <range> elements found - run thin_delta without --verbose")
continue
# end events
if elem.tag == "device":
inside_device = False
elif mode == "delta" and elem.tag in DELTA_STATES:
yield int(elem.get("begin")), int(elem.get("length")), DELTA_STATES[elem.tag]
elif mode == "full" and elem.tag in ("single_mapping", "range_mapping"):
if not inside_device:
die(f"<{elem.tag}> outside of a <device> element - unsupported thin_dump output")
if elem.tag == "single_mapping":
yield int(elem.get("origin_block")), 1, "data"
else:
yield int(elem.get("origin_begin")), int(elem.get("length")), "data"
elem.clear()
def main():
parser = argparse.ArgumentParser(
description="convert thin_dump/thin_delta XML into a borg input map (borg create --map)"
)
parser.add_argument("mode", choices=("full", "delta"), help="full: thin_dump XML, delta: thin_delta XML")
parser.add_argument("xml", nargs="?", default="-", help="XML input file (default: stdin)")
size_group = parser.add_mutually_exclusive_group(required=True)
size_group.add_argument("--device", help="get the map's total size from this block device")
size_group.add_argument("--size", type=int, help="give the map's total size in bytes")
args = parser.parse_intermixed_args()
size = device_size(args)
xml_file = sys.stdin.buffer if args.xml == "-" else args.xml
out = [] # coalesced [start, end, state] ranges, in bytes
offset = 0 # next expected byte offset
meta = {}
def emit(start, end, state):
nonlocal offset
if start < offset:
die(f"XML ranges overlap or are not sorted (at byte offset {start})")
if start > offset:
emit_range(offset, start, "zero") # gap: unallocated in all snapshots, reads as zeros
emit_range(start, end, state)
offset = end
def emit_range(start, end, state):
if out and out[-1][2] == state and out[-1][1] == start:
out[-1][1] = end
else:
out.append([start, end, state])
for begin, length, state in parse_ranges(xml_file, args.mode, meta):
block_size = meta["data_block_size"]
emit(begin * block_size, (begin + length) * block_size, state)
if "data_block_size" not in meta:
die("no <superblock> found in the XML input")
if offset > size:
die(f"XML mappings end at {offset}, beyond the given size {size} - wrong device or size?")
if offset < size:
emit_range(offset, size, "zero")
print(f"# borg input map generated by lvm-thin-map.py ({args.mode} mode)")
print(f"# size: {size} bytes, thin pool chunk size: {meta['data_block_size']} bytes")
if args.mode == "delta":
print(f"# thin_delta left (reference) dev_id: {meta.get('left')}, right dev_id: {meta.get('right')}")
else:
print(f"# thin_dump dev_id: {meta.get('dev_id')}")
for start, end, state in out:
print(f"{start} {end - start} {state}")
if __name__ == "__main__":
main()