Repository: borgbackup/borg

Issue:
make compaction optional?
some people want a more strict "append only, do not change old repo files" behaviour than we have right now (as borg always tries to compact when it commits).

we could make the compaction optional (like `create --no-compact`, maybe also for `check`) and introduce a separate `borg compact` command.

borg would not modify existing files in the `repo/data` directory then, but just add new files.



Discussion:
Interesting comment there about revoking permissions on old files:

https://github.com/oysols/borg/commit/2eeaac10a41cd6260d5f9e863ef8d6eaf120bdb5

This approach might be nice also (so we do not need additional cmdline options / commands) - but it would not work for the "revoke permissions on old files" usecase:

https://github.com/borgbackup/borg/compare/master...oysols:delay_compact_segments

Maybe make it (append only repo) an option in the repository config file instead?

yes, as this relates to repo behaviour, a setting in the repo config might be best.

This works, but where do we document it? Under the "Additional Notes" heading in docs/usage.rst?


Resolving patch:
diff --git a/borg/repository.py b/borg/repository.py
--- a/borg/repository.py
+++ b/borg/repository.py
@@ -1,5 +1,6 @@
 from configparser import ConfigParser
 from binascii import hexlify, unhexlify
+from datetime import datetime
 from itertools import islice
 import errno
 import logging
@@ -84,6 +85,7 @@ def create(self, path):
         config.set('repository', 'version', '1')
         config.set('repository', 'segments_per_dir', str(self.DEFAULT_SEGMENTS_PER_DIR))
         config.set('repository', 'max_segment_size', str(self.DEFAULT_MAX_SEGMENT_SIZE))
+        config.set('repository', 'append_only', '0')
         config.set('repository', 'id', hexlify(os.urandom(32)).decode('ascii'))
         self.save_config(path, config)
 
@@ -105,6 +107,8 @@ def load_key(self):
     def destroy(self):
         """Destroy the repository at `self.path`
         """
+        if self.append_only:
+            raise ValueError(self.path + " is in append-only mode")
         self.close()
         os.remove(os.path.join(self.path, 'config'))  # kill config first
         shutil.rmtree(self.path)
@@ -148,6 +152,7 @@ def open(self, path, exclusive, lock_wait=None, lock=True):
             raise self.InvalidRepository(path)
         self.max_segment_size = self.config.getint('repository', 'max_segment_size')
         self.segments_per_dir = self.config.getint('repository', 'segments_per_dir')
+        self.append_only = self.config.getboolean('repository', 'append_only', fallback=False)
         self.id = unhexlify(self.config.get('repository', 'id').strip())
         self.io = LoggedIO(self.path, self.max_segment_size, self.segments_per_dir)
 
@@ -163,7 +168,8 @@ def commit(self, save_space=False):
         """Commit transaction
         """
         self.io.write_commit()
-        self.compact_segments(save_space=save_space)
+        if not self.append_only:
+            self.compact_segments(save_space=save_space)
         self.write_index()
         self.rollback()
 
@@ -211,6 +217,9 @@ def write_index(self):
         self.index.write(os.path.join(self.path, 'index.tmp'))
         os.rename(os.path.join(self.path, 'index.tmp'),
                   os.path.join(self.path, 'index.%d' % transaction_id))
+        if self.append_only:
+            with open(os.path.join(self.path, 'transactions'), 'a') as log:
+                print('transaction %d, UTC time %s' % (transaction_id, datetime.utcnow().isoformat()), file=log)
         # Remove old indices
         current = '.%d' % transaction_id
         for name in os.listdir(self.path):
@@ -323,6 +332,8 @@ def check(self, repair=False, save_space=False):
         This method verifies all segment checksums and makes sure
         the index is consistent with the data stored in the segments.
         """
+        if self.append_only and repair:
+            raise ValueError(self.path + " is in append-only mode")
         error_found = False
 
         def report_error(msg):
diff --git a/borg/testsuite/repository.py b/borg/testsuite/repository.py
--- a/borg/testsuite/repository.py
+++ b/borg/testsuite/repository.py
@@ -187,6 +187,34 @@ def test_crash_before_deleting_compacted_segments(self):
         self.assert_equal(len(self.repository), 3)
 
 
+class RepositoryAppendOnlyTestCase(RepositoryTestCaseBase):
+    def test_destroy_append_only(self):
+        # Can't destroy append only repo (via the API)
+        self.repository.append_only = True
+        with self.assert_raises(ValueError):
+            self.repository.destroy()
+
+    def test_append_only(self):
+        def segments_in_repository():
+            return len(list(self.repository.io.segment_iterator()))
+        self.repository.put(b'00000000000000000000000000000000', b'foo')
+        self.repository.commit()
+
+        self.repository.append_only = False
+        assert segments_in_repository() == 1
+        self.repository.put(b'00000000000000000000000000000000', b'foo')
+        self.repository.commit()
+        # normal: compact squashes the data together, only one segment
+        assert segments_in_repository() == 1
+
+        self.repository.append_only = True
+        assert segments_in_repository() == 1
+        self.repository.put(b'00000000000000000000000000000000', b'foo')
+        self.repository.commit()
+        # append only: does not compact, only new segments written
+        assert segments_in_repository() == 2
+
+
 class RepositoryCheckTestCase(RepositoryTestCaseBase):
 
     def list_indices(self):


Test patch: