ea3e2870066e — Steve Fink 4 months ago
Switch to using dmsetup to avoid alignment problems
2 files changed, 189 insertions(+), 128 deletions(-)

M bin/viewsetup
M doc/VirtualAndPhysicalWindows.md
M bin/viewsetup +177 -115
@@ 12,7 12,20 @@ KiB = 2 ** 10
 MiB = 2 ** 20
 GiB = 2 ** 30
 
-allowed_actions = ['create-mapping', 'create-md', 'create-vmdk', 'list', 'remove', 'all']
+allowed_actions = [
+    'create-mapping',
+    'create-md',
+    'create-vmdk',
+    'list',
+    'remove',
+    'all'
+]
+
+
+def abort(msg):
+    import sys
+    print(msg, file=sys.stderr)
+    sys.exit(1)
 
 
 def get_disks():

          
@@ 23,8 36,10 @@ def get_disks():
         if d['type'] == 'disk' and d['mountpoint'] is None
     ]
 
+
 disks = get_disks()
 disk = None if len(disks) > 1 else "/dev/" + disks[0]
+CFG_DIR = os.path.join(os.getenv("HOME"), ".config", "diskviews")
 
 parser = argparse.ArgumentParser('setup a view of a disk')
 parser.add_argument('--action', '--actions', default='create-md',

          
@@ 33,41 48,69 @@ parser.add_argument('--map', action='sto
                     help='alias for --action=create-mapping')
 parser.add_argument('--vmdk', action='store_const', dest='action', const='create-vmdk',
                     help='alias for --action=create-vmdk')
+parser.add_argument('--remove', action='store_const', dest='action', const='remove',
+                    help='alias for --action=remove')
+parser.add_argument('--list', action='store_const', dest='action', const='list',
+                    help='alias for --action=list')
 parser.add_argument('--device', '-d', default=disk,
                     help='(whole) disk to create a view of')
-parser.add_argument('--basename', '-b', default=None,
+parser.add_argument('--name', '-n', default=None, dest='_name', metavar='NAME',
                     help='name to use in generated files, defaults to basename of device')
 parser.add_argument('--dir', '-o', default=None,
-                    help='directory storing view configuration files')
+                    help=f"directory storing view configuration dirs, default is {CFG_DIR}/(name)")
 parser.add_argument('--force', '-f', action='store_true', default=False,
                     help='overwrite existing files')
 parser.add_argument('--auto', '-a', action='store_true', default=False,
                     help='choose default disposition for all partitions')
+parser.add_argument('name', nargs='?',
+                    help='name of view to create or access (same as --name NAME)')
 
 args = parser.parse_args()
-md = None
+
+if args.name is None:
+    args.name = args._name
+
+if args.name is None and args.dir is not None:
+    args.name = os.path.basename(args.dir)
+
+if args.name is None and args.device is not None:
+    args.name = os.path.basename(args.device)
+
+if args.dir is None and args.name is not None:
+    args.dir = os.path.join(CFG_DIR, args.name)
+
+# If there is already a slices file, use it to set the device.
+if args.device is None and args.dir is not None and os.path.exists(args.dir):
+    with open(os.path.join(args.dir, 'slices.json')) as fh:
+        data = json.load(fh)
+        args.device = data['device']
+        if args.name is None:
+            args.name = os.path.basename(args.device)
+
+if args.device is None:
+    diskdevs = ' '.join("/dev/" + d for d in disks)
+    abort(f"Use -d (--device) to select from available disks: {diskdevs}")
+
+if args.dir is None:
+    args.dir = os.path.join(CFG_DIR, args.name)
 
 actions = set(args.action.split(','))
 for action in actions:
     if action not in set(allowed_actions):
         raise Exception(f"invalid action '{action}'")
 
-if args.dir is None:
-    if args.basename is None:
-        args.basename = os.path.basename(args.device)
-    args.dir = f'views/{args.basename}'
+dm_dev = f"/dev/mapper/{args.name}_view"
+print(f"Using {dm_dev} for device path")
+slices_filename = os.path.join(args.dir, 'slices.json')
 
 
-def run(cmd, quiet=False):
+def run(cmd, quiet=False, output=False):
     if not quiet:
         print(" ".join(cmd))
-    return subprocess.check_call(cmd)
-
-
-def abort(msg):
-    import sys
-    print(msg, file=sys.stderr)
-    sys.exit(1)
+    if output:
+        return subprocess.check_output(cmd, text=True)
+    else:
+        return subprocess.check_call(cmd)
 
 
 def read_mtab():

          
@@ 88,7 131,7 @@ def read_mtab():
 def read_partitions(device):
     mounts = read_mtab()
     info = defaultdict(dict)
-    fdisk = json.loads(subprocess.check_output(["sudo", "sfdisk", "-J", device], text=True))
+    fdisk = json.loads(run(["sudo", "sfdisk", "-J", device], quiet=True, output=True))
     info['unit'] = fdisk['partitiontable']['unit']
     if info['unit'] != 'sectors':
         raise Exception(f"script only handles units of sectors, not {info['unit']}")

          
@@ 106,22 149,22 @@ def read_partitions(device):
         }
         info['partitions'][p['node']] = part
 
-    for line in subprocess.check_output(["sudo", "sfdisk", "-l", device], text=True).splitlines():
+    for line in run(["sudo", "sfdisk", "-l", device], quiet=True, output=True).splitlines():
         if m := re.match(r'(/dev/\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*(.*)', line):
             dev, start, end, sectors, size, type_ = m.groups()
             info['partitions'][dev]['type'] = type_
 
-    info['end'] = int(subprocess.check_output(['sudo', 'blockdev', '--getsz', device], text=True).rstrip())
-    
+    info['end'] = int(
+        run(['sudo', 'blockdev', '--getsz', device], quiet=True, output=True).rstrip()
+    )
+
     info['ordered-partitions'] = sorted(
         info['partitions'].keys(),
         key=lambda k: info['partitions'][k]['start']
     )
-    
+
     return info
 
-slices_filename = os.path.join(args.dir, f"slices.txt")
-
 
 def check_new_file(filename):
     if os.path.exists(filename) and not args.force:

          
@@ 158,42 201,37 @@ def human_bytes(b):
     return f"{b:.1f} TB"
 
 
-def is_windows_system_partition(part):
-    gpt_types = set([
-        "efi system partition",
-        "microsoft reserved partition",
-    ])
-    if (part['gptname'] or '').lower() in gpt_types:
-        return True
+def guess_disposition(part):
+    known_types = {
+        'efi system partition': 'system partition',
+        'microsoft reserved partition': 'windows system partition',
+    }
+
+    known = known_types.get((part['gptname'] or '').lower())
+    if known:
+        return ('clone', known)
 
     if (part['gptname'] or '').lower() == 'basic data partition':
         if 'RequiredPartition' in part.get('attrs', set()):
-            return True
-
-    return False
-
-
-def guess_disposition(part):
-    if is_windows_system_partition(part):
-        return ('clone', f"small Windows system partition")
+            return ('clone', 'Windows partition with RequiredPartition attr')
 
     mount = part.get('mount')
     if mount and mount['mountpoint'].startswith('/boot'):
         return ('clone', f"cloned {mount['mountpoint']} partition")
 
     if mount:
-        return ('mask', f"mounted partition, mask with zeroes")
+        return ('mask', "mounted partition, mask with zeroes")
 
     if 'microsoft' in part.get('type', '').lower():
-        return ('expose', f"Windows or other partition to expose")
+        return ('expose', "Windows partition to expose")
 
     if 'lvm' in part.get('type', '').lower():
-        return ('mask', f"LVM partition, masking it off")
+        return ('mask', "LVM partition, masking it off")
 
     if part.get('gptname'):
-        return ('expose', f"assumed to be Windows or system partition to expose")
+        return ('expose', "assumed to be Windows or system partition to expose")
 
-    return ('mask', f"other partition to mask with zeroes")
+    return ('mask', "other partition to mask with zeroes")
 
 
 def make_zeroes(dst, count, blocksize):

          
@@ 201,10 239,10 @@ def make_zeroes(dst, count, blocksize):
     run([
         'sudo',
         'dd',
-        f"if=/dev/zero",
+        "if=/dev/zero",
         f"of={dst}",
         f"bs={blocksize}",
-        f"count=0",
+        "count=0",
         f"seek={count}",
         'conv=sparse',
     ])

          
@@ 235,6 273,7 @@ def describe(names):
     else:
         return ",".join(names)
 
+
 if 'create-mapping' in actions or 'all' in actions:
     info = read_partitions(args.device)
     maskid = [0]

          
@@ 245,22 284,16 @@ if 'create-mapping' in actions or 'all' 
             disposition = slices[0]['disposition'] = slices[1]['disposition']
 
         sectors = slices[-1]['end'] - slices[0]['start']
-        if disposition != 'expose':
-            if sectors % (4096 / info['sector-size']):
-                import pdb; pdb.set_trace()
-                devices = " ".join(i['device'] for i in slices)
-                print(f"alignment error for devices {devices}")
-                abort(f"{sectors} will get mangled with mdadm rounding. Time to learn about dmsetup linear?")
 
         if disposition == 'mask':
-            filename = os.path.join(args.dir, f"{args.basename}.zero{maskid[0]}.dat")
+            filename = os.path.join(args.dir, f"zero{maskid[0]}.dat")
             maskid[0] += 1
             make_zeroes(filename, sectors, info['sector-size'])
             slices[0]['filename'] = filename
 
         elif disposition in ('clone', 'gap'):
             name = describe([slice['device'] for slice in slices])
-            filename = os.path.join(args.dir, f"{args.basename}.{name}.dat")
+            filename = os.path.join(args.dir, f"{name}.dat")
             copy_chunk(args.device, filename, slices[0]['start'], sectors, info['sector-size'])
             slices[0]['filename'] = filename
 

          
@@ 268,6 301,7 @@ if 'create-mapping' in actions or 'all' 
             assert disposition == 'expose'
             slices[0]['filename'] = slices[0]['device']
 
+        slices[-1]['range-filename'] = slices[0]['filename']
 
     def make_files_for_slices(slices):
         # Merge consecutive slices with the same non-expose disposition.

          
@@ 280,10 314,17 @@ if 'create-mapping' in actions or 'all' 
                 # Attach gap to next range.
                 range[0]['disposition'] = disposition
                 range.append(slice)
-            elif disposition == 'gap' and range[-1]['disposition'] in ('clone', 'mask'):
-                # Attach gap to previous range.
-                slice['disposition'] = range[-1]['disposition']
-                range.append(slice)
+            elif disposition == 'gap' and range[-1]['disposition'] != 'expose':
+                # Special case: a gap after an expose is necessary for
+                # alignment, and cannot be combined.
+                if range[-1]['disposition'] == 'expose':
+                    process_range(range)
+                    range = [slice]
+                    slice['disposition'] = 'clone'
+                else:
+                    # Attach gap to previous range.
+                    slice['disposition'] = range[-1]['disposition']
+                    range.append(slice)
             else:
                 process_range(range)
                 range = [slice]

          
@@ 300,10 341,13 @@ if 'create-mapping' in actions or 'all' 
         part = info['partitions'][device]
         disposition, why = guess_disposition(part)
 
-        print(f"{device}:")
+        typestr = "" if not part.get('type') else " " + part['type']
+        print(f"{device}:{typestr}")
         if mounts.get(device):
-            print(f"  {mounts[device]['fstype']} filesystem mounted at {mounts[device]['mountpoint']}")
-        print(f"  sectors {part['start']}-{part['end']-1}, {human_bytes(part['size'] * info['sector-size'])}")
+            mount = mounts[device]['mountpoint']
+            print(f"  {mounts[device]['fstype']} filesystem mounted at {mount}")
+        bytes = part['size'] * info['sector-size']
+        print(f"  sectors {part['start']}-{part['end']-1}, {human_bytes(bytes)}")
         print(f"  GPT partition name: {part['gptname']}")
         if args.auto:
             print(f"  automatically chosen disposition is {disposition}: \"{why}\"")

          
@@ 367,8 411,8 @@ if 'create-mapping' in actions or 'all' 
     gap = info['end'] - slices[-1]['end']
     if gap > 0:
         slices.append({
-            'disposition': 'gap',
-            'description': 'gap after last partition',
+            'disposition': 'clone',
+            'description': 'gap after last partition, containing master GPT',
             'device': args.device,
             'sectors': gap,
             'bytes': gap * info['sector-size'],

          
@@ 380,83 424,101 @@ if 'create-mapping' in actions or 'all' 
     make_files_for_slices(slices)
 
     with open(slices_filename, "w") as fh:
-        fh.write(json.dumps(slices, indent=4))
+        fh.write(json.dumps({'device': args.device, 'slices': slices}, indent=4))
+    print(f"Wrote {slices_filename}")
 
 if 'create-md' in actions or 'all' in actions:
     with open(slices_filename, 'r') as fh:
-        slices = json.load(fh)
+        data = json.load(fh)
+        slices = data['slices']
+
+    if os.path.exists(dm_dev):
+        abort(f"{dm_dev} already exists")
+
+    print("Setting up loopback devices")
+    loopbacks = {}
     for slice in slices:
         if slice['type'] == 'file' and slice.get('filename'):
-            slice['loopback'] = subprocess.check_output([
+            loop = run([
                 'sudo',
                 'losetup',
                 '-f',
                 '--show',
                 slice['filename']
-            ], text=True).rstrip()
+            ], output=True).rstrip()
+            slice['loopback'] = loop
+            loopbacks[slice['filename']] = loop
 
-    md = 0
-    while os.path.exists(f"/dev/md{md}"):
-        md += 1
-    backed_slices = [s for s in slices if s.get('loopback') or s.get('filename')]
-    run([
-        'sudo',
-        'mdadm',
-        '--build',
-        f"/dev/md{md}",
-        '--level=linear',
-        '--rounding=4',
-        f"--raid-devices={len(backed_slices)}"
-    ] + [s.get('loopback') or s['filename'] for s in backed_slices])
+    dmconfig_filename = os.path.join(args.dir, "dmconfig.txt")
+    with open(dmconfig_filename, "wt") as fh:
+        offset = 0
+        for slice in slices:
+            filename = slice.get('range-filename')
+            if filename is None:
+                continue
+            dev = loopbacks.get(filename, filename)
+            sectors = slice['end'] - offset
+            print(f"{offset} {sectors} linear {dev} 0", file=fh)
+            offset = slice['end']
+    print(f"Wrote {dmconfig_filename}")
 
+    try:
+        run(['sh', '-c', f"sudo dmsetup create {args.name}_view < {dmconfig_filename}"])
+    except Exception:
+        run(['sudo', 'losetup', '-d'] + list(loopbacks.values()))
+        run(['sudo', 'dmsetup', 'remove', f"{args.name}_view"])
+        raise
     user = subprocess.check_output(['id', '-nu'], text=True).rstrip()
     group = subprocess.check_output(['id', '-ng'], text=True).rstrip()
-    run(['sudo', 'chown', f"{user}:{group}", f"/dev/md{md}"])
-    run(['sudo', 'chmod', '0666', f"/dev/md{md}"])
+    run(['sudo', 'chown', f"{user}:{group}", dm_dev])
+    run(['sudo', 'chmod', '0666', dm_dev])
+
+    orig_size = run(['sudo', 'blockdev', '--getsz', args.device], output=True).strip()
+    new_size = run(['sudo', 'blockdev', '--getsz', dm_dev], output=True).strip()
+
+    print(f"Size of original, in sectors: {orig_size}")
+    print(f"Size of new device, in sectors: {new_size}")
+
+if 'create-vmdk' in actions or 'all' in actions:
+    vmdk_filename = os.path.join(args.dir, f"{args.name}.vmdk")
+    run([
+        'VBoxManage', 'internalcommands', 'createrawvmdk',
+        '-filename', vmdk_filename, '-rawdisk', dm_dev
+    ])
+
 
-if md is None:
-    # Find the last /dev/md{n} device, assuming it's the one created
-    # by this script.
-    md = 0
-    while os.path.exists(f"/dev/md{md}"):
-        md += 1
-    if md == 0:
-        md = None
-    else:
-        md -= 1
-    
-if 'create-vmdk' in actions or 'all' in actions:
-    vmdk_filename = os.path.join(args.dir, f"{args.basename}.vmdk")
-    run(['VBoxManage', 'internalcommands', 'createrawvmdk', '-filename', vmdk_filename, '-rawdisk', f"/dev/md{md}"])
-
-def get_devices(mdfile):
+def get_devices(dmfile):
     devices = []
-    active = 0
-    for line in subprocess.check_output(['sudo', 'mdadm', '--detail', f"/dev/md{md}"], text=True).splitlines():
-        if m := re.match(r'^\s*Number', line):
-            active = 1
-        elif active:
-            devices.append(line.split(" ")[-1])
+    output = run(['sudo', 'dmsetup', 'deps', dm_dev], output=True)
+    for major, minor in re.findall(r'\((\d+), (\d+)\)', output):
+        major = int(major)
+        minor = int(minor)
+        if major == 7:
+            devices.append(f"/dev/loop{minor}")
+        else:
+            found = False
+            for ent in os.listdir("/dev"):
+                path = f"/dev/{ent}"
+                st = os.lstat(path)
+                if major == os.major(st.st_rdev) and minor == os.minor(st.st_rdev):
+                    devices.append(path)
+                    found = True
+                    break
+            if not found:
+                devices.append(f"DEV[{major},{minor}]")
     return devices
 
-   
+
 if 'list' in actions or 'all' in actions:
-    if md is None:
-        raise Exception("no /dev/md* devices found")
-    for device in get_devices(f"/dev/md{md}"):
+    for device in get_devices(dm_dev):
         if device.startswith('/dev/loop'):
             run(['losetup', device], quiet=True)
         else:
             print(device)
-    
+
 if 'remove' in actions:
-    if md is None:
-        raise Exception("no /dev/md* devices found")
-    devices = get_devices(f"/dev/md{md}")
-    run(['sudo', 'mdadm', '--remove', f"/dev/md{md}"])
-    run(['sudo', 'mdadm', '--stop', f"/dev/md{md}"])
-    with open(slices_filename, 'r') as fh:
-        slices = json.load(fh)
+    devices = get_devices(dm_dev)
+    run(['sudo', 'dmsetup', 'remove', dm_dev])
     for device in devices:
         if device.startswith('/dev/loop'):
             run(['sudo', 'losetup', '-d', device])

          
M doc/VirtualAndPhysicalWindows.md +12 -13
@@ 49,16 49,15 @@ 
 - construct a virtual disk pointing to your actual disk
   - get my `viewsetup` utility:
     - hg clone https://hg.sr.ht/~sfink/sfink-tools
-    - get it from sfink-tools/bin/viewsetup
-  - go to an appropriate directory (mine is `~/VirtualBox VMs/`) and then:
-    - create a disk description that exposes the Windows partitions and masks off the live
-      Linux partition you're running from:
-      - viewsetup -d /dev/nvme0n1 --action create-mapping --auto
-    - create /dev/md0, a virtual block device that cobbles together the above "slices":
-      - viewsetup -d /dev/nvme0n1 --action create-md
-    - create a VirtualBox disk descriptor that uses it:
-      - viewsetup -d /dev/nvme0n1 --action create-vmdk
-    - these will create their files in a subdirectory `views/nvme0n1/`
+    - get it from `sfink-tools/bin/viewsetup`
+    - or it's a single file, so you could just grab it from https://hg.sr.ht/~sfink/sfink-tools/raw/bin/viewsetup?rev=tip
+  - create a disk description that exposes the Windows partitions and masks off the live
+    Linux partition you're running from:
+    - `viewsetup --map --auto --name ssd`
+  - create /dev/mapper/ssd_view, a virtual block device that cobbles together the above "slices":
+    - `viewsetup ssd`
+  - create a VirtualBox disk descriptor that uses it:
+    - `viewsetup --action create-vmdk ssd`
 - get VirtualBox working with Secure Boot
   - Secure Boot requires signing the vbox kernel modules
     - you could try to follow https://stackoverflow.com/questions/61248315/sign-virtual-box-modules-vboxdrv-vboxnetflt-vboxnetadp-vboxpci-centos-8

          
@@ 70,7 69,7 @@ 
   - Name: whatever (I used "Local Windows", which is not the greatest name)
   - Version: Windows 10 (64-bit)
   - Use an existing virtual hard disk
-    - navigate to the VMDK in the folder created by viewsetup above
+    - navigate to the VMDK created by viewsetup above (`~/.config/diskviews/ssd/ssd.vmdk`)
   - enable EFI
   - use PIIX3 for Chipset (in System/Motherboard)
   - use PIIX4 for storage controller (not NVMe for some reason...?)

          
@@ 78,5 77,5 @@ 
       gives a potential fix, haven't tried it
   - when you boot, it will require you to reset your PIN. :-(
 - Ongoing
-  - whenever you reboot, you'll need to recreate /dev/md0 with
-    - `viewsetup -d /dev/nvme0n1` (same as `--action create-md`)
+  - whenever you reboot, you'll need to recreate /dev/mapper/ssd_view with
+    - `viewsetup ssd` (same as `--action create-md`)