VMDK のスナップショット (差分ディスク)

VMware Player + NHM は便利だけど複雑な派生とか全くしない私の使い方にはやや面倒なので車輪の再発明
monolithicSparse な disk にしか適用できない上に親のファイル名を書き換えて差分が参照されるようにするので注意。
これを .vmdk に関連づけさせて楽になった。

"""vmdk_delta.py - make and delete delta vmdk file.

Make delta
----------
 > vmdk_delta.py make sd0.vmdk
Then `sd0.vmdk` is renamed `sd0-000000.vmdk`, and you get new delta file
`sd0.vmdk`.

Delete delta
------------
 > vmdk_delta.py delete sd0.vmdk
Then `sd0.vmdk` is removed, and `sd0-000000.vmdk` is renamed `sd0.vmdk`.
If `sd0.vmdk` is not delta file, this tool doesn't do anything.

vmdk format
-----------
see http://www.vmware.com/app/vmdk/?src=vmdk

"""
import sys
import os
import random
import struct
from ctypes import *
char = c_char
Bool = uint8 = c_byte
uint16 = c_ushort
uint32 = c_ulong
SectorType = c_ulonglong # sector size is 512 bytes.

class SparseExtentHeader(Structure):
    _pack_ = 1
    _fields_ = [
        ('magicNumber', uint32), # 0x564d444b
        ('version', uint32), # may be 1
        ('flags', uint32),
        ('capacity', SectorType),
        ('grainSize', SectorType),
        ('descriptorOffset', SectorType),
        ('descriptorSize', SectorType),
        ('numGTEsPerGT', uint32), # number of entries in a grain table
        ('rgdOffset', SectorType), # redundant level 0 of metadata
        ('gdOffset', SectorType), # level 0 of metadata
        ('overHead', SectorType), # size of total vmdk hdr
        ('uncleanShutdown', Bool),
        ('singleEndLineChar', char),
        ('nonEndLineChar', char),
        ('doubleEndLineChar1', char),
        ('doubleEndLineChar2', char),
        ('compressAlgorithm', uint16),
        ('pad', uint8 * 433),
        ]


def readstruct(st, fp):
    memmove(addressof(st), fp.read(sizeof(st)), sizeof(st))


def getinfo(path):
    fp = open(path, 'rb')
    hdr = SparseExtentHeader()
    readstruct(hdr, fp)
    assert hdr.magicNumber == 0x564d444b
    assert hdr.version == 1

    fp.seek(hdr.descriptorOffset * 512)
    lines = fp.read(hdr.descriptorSize * 512).split('\0', 1)[0]
    descriptor = {}; section = ''
    for line in lines.splitlines():
        line = line.strip()
        if line.startswith('# '):
            section = line[1:].strip()
        elif line:
            descriptor.setdefault(section, []).append(line)
    for section in ('Disk DescriptorFile', 'The Disk Data Base'):
        d = {}
        for line in descriptor[section]:
            if line.startswith('#'): continue
            k, v = line.split('=', 1)
            k = k.strip(); v = v.strip()
            d[k] = v
        descriptor[section] = d

    fp.seek(hdr.rgdOffset * 512)
    rgt0 = struct.unpack('<L', fp.read(4))[0] # Redundant grain table #0
    fp.seek(hdr.rgdOffset * 512)
    rgd = []
    while fp.tell() < rgt0 * 512:
        rgd.append(struct.unpack('<L', fp.read(4))[0])

    fp.seek(hdr.gdOffset * 512)
    gt0 = struct.unpack('<L', fp.read(4))[0] # Grain table #0
    fp.seek(hdr.gdOffset * 512)
    gd = []
    while fp.tell() < gt0 * 512:
        gd.append(struct.unpack('<L', fp.read(4))[0])

    fp.seek(0)
    overhead = fp.read(hdr.overHead * 512)

    return type('SparseExtent', (), {
        'header': hdr,
        'descriptor': descriptor,
        'rgd': rgd,
        'gd': gd,
        'overhead': overhead,
        })


def mkchild(parentPath, childName):
    """only supported monolithicSparse"""
    e = getinfo(parentPath)
    assert e.header.descriptorOffset > 0
    assert not e.header.uncleanShutdown

    fp = open(os.path.join(os.path.dirname(parentPath), childName), 'wb')
    fp.write(e.overhead)
    fp.seek(e.header.descriptorOffset * 512)
    fp.write('\0' * e.header.descriptorSize * 512)
    fp.seek(e.header.descriptorOffset * 512)

    fp.write('# Disk DescriptorFile\n')
    items = (
        ('version', e.descriptor['Disk DescriptorFile']['version']),
        ('CID', '%.8x' % random.randint(0, 0xffffffff)),
        ('parentCID', e.descriptor['Disk DescriptorFile']['CID']),
        ('createType', e.descriptor['Disk DescriptorFile']['createType']),
        ('parentFileNameHint',
         '"%s"' % os.path.basename(parentPath).encode('string_escape')),
        )
    for i in items: fp.write('%s=%s\n' % i)
    fp.write('\n')

    fp.write('# Extent description\n')
    fp.write('RW %d SPARSE "%s"\n' % (
        e.header.capacity,
        os.path.basename(childName).encode('string_escape'), ))
    fp.write('\n')

    fp.write('# The Disk Data Base\n')
    fp.write('#DDB\n')
    fp.write('\n')

    fp.seek(e.rgd[0] * 512)
    fp.write('\0' * (e.header.gdOffset - e.rgd[0]) * 512)

    fp.seek(e.gd[0] * 512)
    fp.write('\0' * (e.header.overHead - e.gd[0]) * 512)


def usage():
    print 'Usage to make delta: %s make vmdk-path' % sys.argv[0]
    print 'Usage to delete delta: %s delete vmdk-path' % sys.argv[0]
    raise SystemExit(-1)


if __name__ == '__main__':
    try:
        action = sys.argv[1]
        path = sys.argv[2]
    except LookupError:
        usage()

    ancestors = [path]
    while 1:
        e = getinfo(ancestors[0])
        if 'parentFileNameHint' not in e.descriptor['Disk DescriptorFile']:
            break
        basename = e.descriptor['Disk DescriptorFile']['parentFileNameHint']
        basename = basename[1:-1].decode('string_escape')
        ancestors.insert(
            0, os.path.join(os.path.dirname(path), basename))

    if action == 'make':
        i = os.path.splitext(ancestors[-1])
        ancestors[-1] = '%s-%.6d%s' % (i[0], len(ancestors) - 1, i[1])
        os.rename(path, ancestors[-1])
        mkchild(ancestors[-1], os.path.basename(path))
    elif action == 'delete':
        if len(ancestors) < 2:
            raise Exception('%r is not disk delta file.' % path)
        os.unlink(path)
        os.rename(ancestors[-2], path)
    else:
        usage()