summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2013-08-02 08:46:28 +0100
committerLars Wirzenius <liw@liw.fi>2013-08-02 08:46:28 +0100
commit3228b6cf556adfc60228027c56936b1a8fc07f8c (patch)
tree35aa6e9ff2a6aeb1dfa07fc63a40c8a562c2445a
parentba4d80e6d8315ba7b05a41008e43fb9b4d185342 (diff)
parent99b5d890a60cff03c78750397e3d395858c5da01 (diff)
downloadobnam-3228b6cf556adfc60228027c56936b1a8fc07f8c.tar.gz
Add FUSE plugin
-rw-r--r--NEWS2
-rw-r--r--debian/changelog6
-rw-r--r--debian/control2
-rw-r--r--obnamlib/plugins/fuse_plugin.py584
-rw-r--r--setup.py2
-rw-r--r--without-tests1
-rw-r--r--yarns/fuse.yarn85
-rw-r--r--yarns/obnam.sh35
8 files changed, 716 insertions, 1 deletions
diff --git a/NEWS b/NEWS
index 23030cdc..0a2387bb 100644
--- a/NEWS
+++ b/NEWS
@@ -15,6 +15,8 @@ Bug fixes:
line such as "exclude = foo, bar," (note trailing comma) would result
in an empty pattern, which would match everything, and therefore
nothing would be backed up. Reported by Sharon Kimble.
+* A FUSE plugin to access (read-only) data from the backup repository
+ has been added. Written by Valery Yundin.
Version 1.4, released 2013-03-16
--------------------------------
diff --git a/debian/changelog b/debian/changelog
index 6033dca3..6a8f64bc 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+obnam (1.5-1) UNRELEASED; urgency=low
+
+ * Update cmdtest build dependency to get yarn.
+
+ -- Lars Wirzenius <liw@liw.fi> Fri, 02 Aug 2013 08:43:29 +0100
+
obnam (1.4-1) unstable; urgency=low
* New upstream release.
diff --git a/debian/control b/debian/control
index 6c473ecc..702f5939 100644
--- a/debian/control
+++ b/debian/control
@@ -15,7 +15,7 @@ Build-Depends: debhelper (>= 7.3.8),
python-cliapp (>= 1.20130313~),
genbackupdata (>= 1.6~),
summain (>= 0.18),
- cmdtest (>= 0.6~),
+ cmdtest (>= 0.9.1~),
attr
Homepage: http://liw.fi/obnam/
X-Python-Version: >= 2.6
diff --git a/obnamlib/plugins/fuse_plugin.py b/obnamlib/plugins/fuse_plugin.py
new file mode 100644
index 00000000..cd7868fb
--- /dev/null
+++ b/obnamlib/plugins/fuse_plugin.py
@@ -0,0 +1,584 @@
+# Copyright (C) 2013 Valery Yundin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import os
+import stat
+import sys
+import logging
+import errno
+import struct
+import signal
+
+import obnamlib
+
+try:
+ import fuse
+ fuse.fuse_python_api = (0, 2)
+except ImportError:
+ class Bunch:
+ def __init__(self, **kwds):
+ self.__dict__.update(kwds)
+ fuse = Bunch(Fuse = object)
+
+
+class ObnamFuseOptParse(object):
+ '''Option parsing class for FUSE
+
+ has to set fuse_args.mountpoint
+ '''
+
+ obnam = None
+
+ def __init__(self, *args, **kw):
+ self.fuse_args = \
+ 'fuse_args' in kw and kw.pop('fuse_args') or fuse.FuseArgs()
+ if 'fuse' in kw:
+ self.fuse = kw.pop('fuse')
+
+ def parse_args(self, args=None, values=None):
+ self.fuse_args.mountpoint = self.obnam.app.settings['to']
+ for opt in self.obnam.app.settings['fuse-opt']:
+ if opt == '-f':
+ self.fuse_args.setmod('foreground')
+ else:
+ self.fuse_args.add(opt)
+ if not hasattr(self.fuse_args, 'ro'):
+ self.fuse_args.add('ro')
+
+
+class ObnamFuseFile(object):
+
+ fs = None # points to active ObnamFuse object
+
+ direct_io = False # do not use direct I/O on this file.
+ keep_cache = True # cached file data need not to be invalidated.
+
+ def __init__(self, path, flags, *mode):
+ logging.debug('FUSE file open %s %d', path, flags)
+ if ((flags & os.O_WRONLY) or (flags & os.O_RDWR) or
+ (flags & os.O_CREAT) or (flags & os.O_EXCL) or
+ (flags & os.O_TRUNC) or (flags & os.O_APPEND)):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+ try:
+ self.path = path
+
+ if path == '/.pid' and self.fs.obnam.app.settings['viewmode'] == 'multiple':
+ self.read = self.read_pid
+ return
+
+ self.metadata = self.fs.get_metadata(path)
+ # if not a regular file return EINVAL
+ if not stat.S_ISREG(self.metadata.st_mode):
+ raise IOError(errno.EINVAL, 'Invalid argument')
+
+ self.chunkids = None
+ self.chunksize = None
+ self.lastdata = None
+ self.lastblock = None
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def read_pid(self, length, offset):
+ pid = str(os.getpid())
+ if length < len(pid) or offset != 0:
+ return ''
+ else:
+ return pid
+
+ def fgetattr(self):
+ logging.debug('FUSE file fgetattr')
+ return self.fs.getattr(self.path)
+
+ def read(self, length, offset):
+ logging.debug('FUSE file read(%s, %d, %d)', self.path, length, offset)
+ try:
+ if length == 0 or offset >= self.metadata.st_size:
+ return ''
+
+ repo = self.fs.obnam.repo
+ gen, repopath = self.fs.get_gen_path(self.path)
+
+ # if stored inside B-tree
+ contents = repo.get_file_data(gen, repopath)
+ if contents is not None:
+ return contents[offset:offset+length]
+
+ # stored in chunks
+ if not self.chunkids:
+ self.chunkids = repo.get_file_chunks(gen, repopath)
+
+ if len(self.chunkids) == 1:
+ if not self.lastdata:
+ self.lastdata = repo.get_chunk(self.chunkids[0])
+ return self.lastdata[offset:offset+length]
+ else:
+ chunkdata = None
+ if not self.chunksize:
+ # take the cached value as the first guess for chunksize
+ self.chunksize = self.fs.sizecache.get(gen, self.fs.chunksize)
+ blocknum = offset/self.chunksize
+ blockoffs = offset - blocknum*self.chunksize
+
+ # read a chunk if guessed blocknum and chunksize make sense
+ if blocknum < len(self.chunkids):
+ chunkdata = repo.get_chunk(self.chunkids[blocknum])
+ else:
+ chunkdata = ''
+
+ # check if chunkdata is of expected length
+ validate = min(self.chunksize, self.metadata.st_size - blocknum*self.chunksize)
+ if validate != len(chunkdata):
+ if blocknum < len(self.chunkids)-1:
+ # the length of all but last chunks is chunksize
+ self.chunksize = len(chunkdata)
+ else:
+ # guessing failed, get the length of the first chunk
+ self.chunksize = len(repo.get_chunk(self.chunkids[0]))
+ chunkdata = None
+
+ # save correct chunksize
+ self.fs.sizecache[gen] = self.chunksize
+
+ if not chunkdata:
+ blocknum = offset/self.chunksize
+ blockoffs = offset - blocknum*self.chunksize
+ if self.lastblock == blocknum:
+ chunkdata = self.lastdata
+ else:
+ chunkdata = repo.get_chunk(self.chunkids[blocknum])
+
+ output = []
+ while True:
+ output.append(chunkdata[blockoffs:blockoffs+length])
+ readlength = len(chunkdata) - blockoffs
+ if length > readlength and blocknum < len(self.chunkids)-1:
+ length -= readlength
+ blocknum += 1
+ blockoffs = 0
+ chunkdata = repo.get_chunk(self.chunkids[blocknum])
+ else:
+ self.lastblock = blocknum
+ self.lastdata = chunkdata
+ break
+ return ''.join(output)
+ except (OSError, IOError), e:
+ logging.debug('FUSE Expected exception')
+ raise
+ except:
+ logging.exception('Unexpected exception')
+ raise
+
+ def release(self, flags):
+ logging.debug('FUSE file release %d', flags)
+ self.lastdata = None
+ return 0
+
+ def fsync(self, isfsyncfile):
+ logging.debug('FUSE file fsync')
+ return 0
+
+ def flush(self):
+ logging.debug('FUSE file flush')
+ return 0
+
+ def ftruncate(self, size):
+ logging.debug('FUSE file ftruncate %d', size)
+ return 0
+
+ def lock(self, cmd, owner, **kw):
+ logging.debug('FUSE file lock %s %s %s', repr(cmd), repr(owner), repr(kw))
+ raise IOError(errno.EOPNOTSUPP, 'Operation not supported')
+
+
+class ObnamFuse(fuse.Fuse):
+ '''FUSE main class
+
+ '''
+
+ MAX_METADATA_CACHE = 512
+
+ def sigUSR1(self):
+ if self.obnam.app.settings['viewmode'] == 'multiple':
+ repo = self.obnam.app.open_repository()
+ repo.open_client(self.obnam.app.settings['client-name'])
+ generations = [gen for gen in repo.list_generations()
+ if not repo.get_is_checkpoint(gen)]
+ self.obnam.repo = repo
+ self.rootstat, self.rootlist = self.multiple_root_list(generations)
+ self.metadatacache.clear()
+
+ def get_metadata(self, path):
+ #logging.debug('FUSE get_metadata(%s)', path)
+ try:
+ return self.metadatacache[path]
+ except KeyError:
+ if len(self.metadatacache) > self.MAX_METADATA_CACHE:
+ self.metadatacache.clear()
+ metadata = self.obnam.repo.get_metadata(*self.get_gen_path(path))
+ self.metadatacache[path] = metadata
+ return metadata
+
+ def get_stat(self, path):
+ logging.debug('FUSE get_stat(%s)', path)
+ metadata = self.get_metadata(path)
+ st = fuse.Stat()
+ st.st_mode = metadata.st_mode
+ st.st_dev = metadata.st_dev
+ st.st_nlink = metadata.st_nlink
+ st.st_uid = metadata.st_uid
+ st.st_gid = metadata.st_gid
+ st.st_size = metadata.st_size
+ st.st_atime = metadata.st_atime_sec
+ st.st_mtime = metadata.st_mtime_sec
+ st.st_ctime = st.st_mtime
+ return st
+
+ def single_root_list(self, gen):
+ repo = self.obnam.repo
+ mountroot = self.obnam.mountroot
+ rootlist = {}
+ for entry in repo.listdir(gen, mountroot):
+ path = '/' + entry
+ rootlist[path] = self.get_stat(path)
+ rootstat = self.get_stat('/')
+ return (rootstat, rootlist)
+
+ def multiple_root_list(self, generations):
+ repo = self.obnam.repo
+ mountroot = self.obnam.mountroot
+ rootlist = {}
+ used_generations = []
+ for gen in generations:
+ path = '/' + str(gen)
+ try:
+ genstat = self.get_stat(path)
+ start, end = repo.get_generation_times(gen)
+ genstat.st_ctime = genstat.st_mtime = end
+ rootlist[path] = genstat
+ used_generations.append(gen)
+ except obnamlib.Error:
+ pass
+
+ if not used_generations:
+ raise obnamlib.Error('No generations found for %s' % mountroot)
+
+ latest = used_generations[-1]
+ laststat = rootlist['/' + str(latest)]
+ rootstat = fuse.Stat(**laststat.__dict__)
+
+ laststat = fuse.Stat(target=str(latest), **laststat.__dict__)
+ laststat.st_mode &= ~(stat.S_IFDIR | stat.S_IFREG)
+ laststat.st_mode |= stat.S_IFLNK
+ rootlist['/latest'] = laststat
+
+ pidstat = fuse.Stat(**rootstat.__dict__)
+ pidstat.st_mode = stat.S_IFREG | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
+ rootlist['/.pid'] = pidstat
+
+ return (rootstat, rootlist)
+
+ def init_root(self):
+ repo = self.obnam.repo
+ mountroot = self.obnam.mountroot
+ generations = self.obnam.app.settings['generation']
+
+ if self.obnam.app.settings['viewmode'] == 'single':
+ if len(generations) != 1:
+ raise obnamlib.Error(
+ 'The single mode wants exactly one generation option')
+
+ gen = repo.genspec(generations[0])
+ if mountroot == '/':
+ self.get_gen_path = lambda path: (gen, path)
+ else:
+ self.get_gen_path = (lambda path : path == '/'
+ and (gen, mountroot)
+ or (gen, mountroot + path))
+
+ self.rootstat, self.rootlist = self.single_root_list(gen)
+ logging.debug('FUSE single rootlist %s', repr(self.rootlist))
+ elif self.obnam.app.settings['viewmode'] == 'multiple':
+ # we need the list of all real (non-checkpoint) generations
+ if len(generations) == 1:
+ generations = [gen for gen in repo.list_generations()
+ if not repo.get_is_checkpoint(gen)]
+
+ if mountroot == '/':
+ def gen_path_0(path):
+ if path.count('/') == 1:
+ gen = path[1:]
+ return (int(gen), mountroot)
+ else:
+ gen, repopath = path[1:].split('/', 1)
+ return (int(gen), mountroot + repopath)
+ self.get_gen_path = gen_path_0
+ else:
+ def gen_path_n(path):
+ if path.count('/') == 1:
+ gen = path[1:]
+ return (int(gen), mountroot)
+ else:
+ gen, repopath = path[1:].split('/', 1)
+ return (int(gen), mountroot + '/' + repopath)
+ self.get_gen_path = gen_path_n
+
+ self.rootstat, self.rootlist = self.multiple_root_list(generations)
+ logging.debug('FUSE multiple rootlist %s', repr(self.rootlist))
+ else:
+ raise obnamlib.Error('Unknown value for viewmode')
+
+ def __init__(self, *args, **kw):
+ self.obnam = kw['obnam']
+ ObnamFuseFile.fs = self
+ self.file_class = ObnamFuseFile
+ self.metadatacache = {}
+ self.chunksize = self.obnam.app.settings['chunk-size']
+ self.sizecache = {}
+ self.rootlist = None
+ self.rootstat = None
+ self.init_root()
+ fuse.Fuse.__init__(self, *args, **kw)
+
+ def getattr(self, path):
+ try:
+ if path.count('/') == 1:
+ if path == '/':
+ return self.rootstat
+ elif path in self.rootlist:
+ return self.rootlist[path]
+ else:
+ raise obnamlib.Error('ENOENT')
+ else:
+ return self.get_stat(path)
+ except obnamlib.Error:
+ raise IOError(errno.ENOENT, 'No such file or directory')
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def readdir(self, path, fh):
+ logging.debug('FUSE readdir(%s, %s)', path, repr(fh))
+ try:
+ if path == '/':
+ listdir = [x[1:] for x in self.rootlist.keys()]
+ else:
+ listdir = self.obnam.repo.listdir(*self.get_gen_path(path))
+ return [fuse.Direntry(name) for name in ['.', '..'] + listdir]
+ except obnamlib.Error:
+ raise IOError(errno.EINVAL, 'Invalid argument')
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def readlink(self, path):
+ try:
+ statdata = self.rootlist.get(path)
+ if statdata and hasattr(statdata, 'target'):
+ return statdata.target
+ metadata = self.get_metadata(path)
+ if metadata.islink():
+ return metadata.target
+ else:
+ raise IOError(errno.EINVAL, 'Invalid argument')
+ except obnamlib.Error:
+ raise IOError(errno.ENOENT, 'No such file or directory')
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def statfs(self):
+ logging.debug('FUSE statfs')
+ try:
+ repo = self.obnam.repo
+ if self.obnam.app.settings['viewmode'] == 'multiple':
+ blocks = sum(repo.client.get_generation_data(gen)
+ for gen in repo.list_generations())
+ files = sum(repo.client.get_generation_file_count(gen)
+ for gen in repo.list_generations())
+ else:
+ gen = self.get_gen_path('/')[0]
+ blocks = repo.client.get_generation_data(gen)
+ files = repo.client.get_generation_file_count(gen)
+ stv = fuse.StatVfs()
+ stv.f_bsize = 65536
+ stv.f_frsize = 0
+ stv.f_blocks = blocks/65536
+ stv.f_bfree = 0
+ stv.f_bavail = 0
+ stv.f_files = files
+ stv.f_ffree = 0
+ stv.f_favail = 0
+ stv.f_flag = 0
+ stv.f_namemax = 255
+ #raise OSError(errno.ENOSYS, 'Unimplemented')
+ return stv
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def getxattr(self, path, name, size):
+ logging.debug('FUSE getxattr %s %s %d', path, name, size)
+ try:
+ try:
+ metadata = self.get_metadata(path)
+ except ValueError:
+ return 0
+ if not metadata.xattr:
+ return 0
+ blob = metadata.xattr
+ sizesize = struct.calcsize('!Q')
+ name_blob_size = struct.unpack('!Q', blob[:sizesize])[0]
+ name_blob = blob[sizesize : sizesize + name_blob_size]
+ name_list = name_blob.split('\0')[:-1]
+ if name in name_list:
+ value_blob = blob[sizesize + name_blob_size : ]
+ idx = name_list.index(name)
+ fmt = '!' + 'Q' * len(name_list)
+ lengths_size = sizesize * len(name_list)
+ lengths_list = struct.unpack(fmt, value_blob[:lengths_size])
+ if size == 0:
+ return lengths_list[idx]
+ pos = lengths_size + sum(lengths_list[:idx])
+ value = value_blob[pos:pos + lengths_list[idx]]
+ return value
+ except obnamlib.Error:
+ raise IOError(errno.ENOENT, 'No such file or directory')
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def listxattr(self, path, size):
+ logging.debug('FUSE listxattr %s %d', path, size)
+ try:
+ metadata = self.get_metadata(path)
+ if not metadata.xattr:
+ return 0
+ blob = metadata.xattr
+ sizesize = struct.calcsize('!Q')
+ name_blob_size = struct.unpack('!Q', blob[:sizesize])[0]
+ if size == 0:
+ return name_blob_size
+ name_blob = blob[sizesize : sizesize + name_blob_size]
+ return name_blob.split('\0')[:-1]
+ except obnamlib.Error:
+ raise IOError(errno.ENOENT, 'No such file or directory')
+ except:
+ logging.error('Unexpected exception', exc_info=True)
+ raise
+
+ def fsync(self, path, isFsyncFile):
+ return 0
+
+ def chmod(self, path, mode):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def chown(self, path, uid, gid):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def link(self, targetPath, linkPath):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def mkdir(self, path, mode):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def mknod(self, path, mode, dev):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def rename(self, oldPath, newPath):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def rmdir(self, path):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def symlink(self, targetPath, linkPath):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def truncate(self, path, size):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def unlink(self, path):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def utime(self, path, times):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def write(self, path, buf, offset):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def setxattr(self, path, name, val, flags):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+ def removexattr(self, path, name):
+ raise IOError(errno.EROFS, 'Read only filesystem')
+
+
+class MountPlugin(obnamlib.ObnamPlugin):
+
+ '''Mount backup repository as a user-space filesystem.
+
+ At the momemnt only a specific generation can be mounted
+
+ '''
+
+ def enable(self):
+ mount_group = obnamlib.option_group['mount'] = 'Mounting with FUSE'
+ self.app.add_subcommand('mount', self.mount,
+ arg_synopsis='[ROOT]')
+ self.app.settings.choice(['viewmode'],
+ ['single', 'multiple'],
+ '"single" directly mount specified generation, '
+ '"multiple" mount all generations as separate directories',
+ metavar='MODE',
+ group=mount_group)
+ self.app.settings.string_list(['fuse-opt'],
+ 'options to pass directly to Fuse',
+ metavar='FUSE', group=mount_group)
+
+ def mount(self, args):
+ '''Mount a generation as a FUSE filesystem.'''
+
+ if not hasattr(fuse, 'fuse_python_api'):
+ raise obnamlib.Error('Failed to load module "fuse", '
+ 'try installing python-fuse')
+ self.app.settings.require('repository')
+ self.app.settings.require('client-name')
+ self.app.settings.require('to')
+ self.repo = self.app.open_repository()
+ self.repo.open_client(self.app.settings['client-name'])
+
+ self.mountroot = (['/'] + self.app.settings['root'] + args)[-1]
+ if self.mountroot != '/':
+ self.mountroot = self.mountroot.rstrip('/')
+
+ logging.debug('FUSE Mounting %s@%s:%s to %s', self.app.settings['client-name'],
+ self.app.settings['generation'],
+ self.mountroot, self.app.settings['to'])
+
+ try:
+ ObnamFuseOptParse.obnam = self
+ fs = ObnamFuse(obnam=self, parser_class=ObnamFuseOptParse)
+ signal.signal(signal.SIGUSR1, lambda s,f: fs.sigUSR1())
+ signal.siginterrupt(signal.SIGUSR1, False)
+ fs.flags = 0
+ fs.multithreaded = 0
+ fs.parse()
+ fs.main()
+ except fuse.FuseError, e:
+ raise obnamlib.Error(repr(e))
+
+ self.repo.fs.close()
+
diff --git a/setup.py b/setup.py
index db0b9c55..56ee378a 100644
--- a/setup.py
+++ b/setup.py
@@ -99,6 +99,8 @@ class Check(Command):
if local and fast:
print "run black box tests"
runcmd(['cmdtest', 'tests'])
+ runcmd(
+ ['yarn', '-s', 'yarns/obnam.sh'] + glob.glob('yarns/*.yarn'))
num_clients = '2'
num_generations = '16'
diff --git a/without-tests b/without-tests
index 56e50bbf..8364d849 100644
--- a/without-tests
+++ b/without-tests
@@ -29,3 +29,4 @@
./.pc/debian-changes-0.22-2/obnamlib/plugins/restore_plugin.py
obnamlib/plugins/convert5to6_plugin.py
obnamlib/repo_interface.py
+obnamlib/plugins/fuse_plugin.py
diff --git a/yarns/fuse.yarn b/yarns/fuse.yarn
new file mode 100644
index 00000000..92253d12
--- /dev/null
+++ b/yarns/fuse.yarn
@@ -0,0 +1,85 @@
+Black box testing for the Obnam FUSE plugin
+===========================================
+
+The FUSE plugin gives read-only access to a backup repository.
+There's a lot of potential corner cases here, but for now, this
+test suite concentrates on verifying that at least the basics work.
+
+ SCENARIO Browsing backups with FUSE plugin
+ ASSUMING user is in group fuse
+ GIVEN a live data directory
+ AND a 0 byte file called empty
+ AND a 1 byte file called one
+ AND a 4096 byte file called 4k
+ AND a 10485760 byte file called 10meg
+ AND a hardlink to 4k called 4k-hardlink
+ AND a symlink to 4k called 4k-symlink
+ AND a directory called some-dir
+ AND a symlink to ../4k called some-dir/other-symlink
+ WHEN live data is backed up
+ AND repository is fuse mounted
+ THEN latest generation can be copied correctly from fuse mount
+ FINALLY unmount repository
+
+The following sections implement the various steps. We use
+`$DATADIR/live` for the live data, `$DATADIR/repo` for the repository,
+and `$DATADIR/mount` as the FUSE mount point.
+
+We can only run this test if the user is in the fuse group. This may
+be a portability concern: this works in Debian GNU/Linux, but might be
+different in other Linux distros, or on non-Linux systems.
+
+ IMPLEMENTS ASSUMING user is in group (\S+)
+ groups | tr ' ' '\n' | grep -Fx "$MATCH_1"
+
+ IMPLEMENTS GIVEN a live data directory
+ mkdir "$DATADIR/live"
+
+ IMPLEMENTS GIVEN a (\d+) byte file called (\S+)
+ dd if=/dev/zero of="$DATADIR/live/$MATCH_2" bs=1 count="$MATCH_1"
+
+ IMPLEMENTS GIVEN a hardlink to (\S+) called (\S+)
+ ln "$DATADIR/live/$MATCH_1" "$DATADIR/live/$MATCH_2"
+
+ IMPLEMENTS GIVEN a symlink to (\S+) called (\S+)
+ ln -s "$DATADIR/live/$MATCH_1" "$DATADIR/live/$MATCH_2"
+
+ IMPLEMENTS GIVEN a directory called (\S+)
+ mkdir "$DATADIR/live/$MATCH_1"
+
+We do the backup, and verify that it can be accessed correctly, by
+doing a "manifest" of the live data before the backup, and then
+against the fuse mount, and comparing the two manifests.
+
+`manifest` (a shell function in `obnam.sh`) runs summain with useful
+parameters. It's used twice, and the parameters need to be the same
+so the results can be compared with diff. summain is a manifest tool.
+`manifest` additionally mangles the mtime output to be full seconds
+only: for whatever reason, the fuse mount only shows full seconds.
+This may be a bug (FIXME: find out if it is).
+
+`run_obnam` is another shell function, which runs Obnam without the
+user's configuration files. We don't want the user's configuration to
+affect the test suite.
+
+ IMPLEMENTS WHEN live data is backed up
+ manifest "$DATADIR/live" > "$DATADIR/live.summain"
+ run_obnam backup -r "$DATADIR/repo" "$DATADIR/live"
+
+ IMPLEMENTS WHEN repository is fuse mounted
+ run_obnam clients -r "$DATADIR/repo"
+ mkdir "$DATADIR/mount"
+ run_obnam mount -r "$DATADIR/repo" --to "$DATADIR/mount" --viewmode multiple
+
+ IMPLEMENTS THEN latest generation can be copied correctly from fuse mount
+ manifest "$DATADIR/mount/latest/$DATADIR/live" > "$DATADIR/latest.summain"
+ diff -u "$DATADIR/live.summain" "$DATADIR/latest.summain"
+
+If we did do the fuse mount, **always** unmount it, even when a step
+failed. We do not want failed test runs to leavo mounts lying around.
+
+ IMPLEMENTS FINALLY unmount repository
+ if [ -e "$DATADIR/mount" ]
+ then
+ fusermount -u "$DATADIR/mount"
+ fi
diff --git a/yarns/obnam.sh b/yarns/obnam.sh
new file mode 100644
index 00000000..9de72ba7
--- /dev/null
+++ b/yarns/obnam.sh
@@ -0,0 +1,35 @@
+# Copyright 2013 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+# Run Obnam in a safe way that ignore's any configuration files outside
+# the test.
+
+run_obnam()
+{
+ ./obnam --no-default-config "$@"
+}
+
+
+# Create a manifest with summain of a directory.
+
+manifest()
+{
+ summain -r "$1" --exclude Ino --exclude Dev |
+ sed '/^Mtime:/s/\.[0-9]* / /'
+}
+