Repository: borgbackup/borg

Issue:
return codes
how shall we deal with return codes of borg?

currently it is:
0 == no error, normal termination
1 == some error

this could be way more informative. :)
0 == no error, normal termination
1 == some error that hasn't been assigned a better rc yet
2... == other separately identifiable conditions
128+N == killed by signal N

what I am asking myself:

are there standard return codes for some stuff or does everybody invent them individually?
update: i found sysexits.h - maybe better than nothing.

what rc do we use if some files could not get processed (not found, permissions problems, ...), but the rest was ok?

---

:moneybag: [there is a bounty for this](https://www.bountysource.com/issues/22591770-return-codes)



Discussion:
I like how Syncthing does it:
http://docs.syncthing.net/users/syncthing.html
https://github.com/syncthing/syncthing/blob/master/etc/linux-systemd/user/syncthing.service

For example, 5 signals that the process is restarting due to an upgrade. This information can be useful to the process supervision system.

So, perhaps think about what we would want to do about different kinds of exits, give each one a code, and document it.

Another option is to group exits are different but that we think should be handled in the same way, and give them all the same exit codes. But I think this approach tries to read the user's mind too much and should be avoided.

The great thing about good exit codes is that you don't really have to parse them at all :)

On June 20, 2015 5:24:34 PM EDT, TW notifications@github.com wrote:

> how shall we deal with return codes of borg?
> 
> currently it is:
> 0 == no error, normal termination
> 1 == some error
> 
> this could be way more informative. :)
> 0 == no error, normal termination
> 1 == some error that hasn't been assigned a better rc yet
> 2 == SIGINTR (ctrl-c)
> 3 == SIGTERM (normal "kill")
> 4... == other separately identifiable conditions
> 
> what I am asking myself: are there standard return codes for some stuff
> or does everybody invent them individually?
> 
> what rc do we use if some files could not get processed (not found,
> permissions problems, ...), but the rest was ok?
> 
> ---
> 
> Reply to this email directly or view it on GitHub:
> https://github.com/borgbackup/borg/issues/61

@lfam thanks for pointing at syncthing docs, I added the 128+N rc for signals.

If someone wants to help:

research how other backup commandline tools handle return codes and document it here (link it from here).

This is how fbackup does:
(pdf) http://h20565.www2.hp.com/hpsc/doc/public/display?docId=emr_na-c02260774

> **RETURN VALUE**
> fbackup returns one of the following values:
> **0** upon normal completion.
> **1** if it is interrupted but allowed to save its state for possible restart.
> **2** if any error conditions prevent the session from completing.
> **4** if any warning conditions are encountered.
> If warnings occur, the operator should check the fbackup logs to verify the sanity of the backup.

rsnapshot: http://linux.die.net/man/1/rsnapshot

```
Exit Values
0 All operations completed successfully
1 A fatal error occurred
2 Some warnings occurred, but the backup still finished 
```

GNU tar: http://www.gnu.org/software/tar/manual/html_section/tar_19.html

```
Possible exit codes of GNU tar are summarized in the following table:

0
    `Successful termination'.
1
    `Some files differ'. If tar was invoked with `--compare' (`--diff', 
`-d') command line option, this means that some files in the archive 
differ from their disk counterparts (see section Comparing Archive 
Members with the File System). If tar was given `--create', `--append' 
or `--update' option, this exit code means that some files were changed 
while being archived and so the resulting archive does not contain the 
exact copy of the file set.

2
    `Fatal error'. This means that some fatal, unrecoverable error 
occurred. 

If tar has invoked a subprocess and that subprocess exited with a 
nonzero exit code, tar exits with that code as well. This can happen, 
for example, if tar was given some compression option (see section 
Creating and Reading Compressed Archives) and the external compressor 
program failed. Another example is rmt failure during backup to the 
remote device (see section Remote Tape Server).
```

That last paragraph is interesting...

Duplicity: http://duplicity.nongnu.org/epydoc/duplicity.log.ErrorCode-class.html

```
Enumeration class to hold error code values. These values should never 
change, as frontends rely upon them. Don't use 0 or negative numbers. 
This code is returned by duplicity to indicate which error occurred via 
both exit code and log.

    generic = 1
    command_line = 2
    hostname_mismatch = 3
    no_manifests = 4
    mismatched_manifests = 5
    unreadable_manifests = 6
    cant_open_filelist = 7
    bad_url = 8
    bad_archive_dir = 9
    bad_sign_key = 10
    restore_dir_exists = 11
    verify_dir_doesnt_exist = 12
    backup_dir_doesnt_exist = 13
    file_prefix_error = 14
    globbing_error = 15
    redundant_inclusion = 16
    inc_without_sigs = 17
    no_sigs = 18
    restore_dir_not_found = 19
    no_restore_files = 20
    mismatched_hash = 21
    unsigned_volume = 22
    user_error = 23
    boto_old_style = 24
    boto_lib_too_old = 25
    boto_calling_format = 26
    ftp_ncftp_missing = 27
    ftp_ncftp_too_old = 28
    exception = 30
    gpg_failed = 31
    s3_bucket_not_style = 32
    not_implemented = 33
    get_freespace_failed = 34
    not_enough_freespace = 35
    get_ulimit_failed = 36
    maxopen_too_low = 37
    connection_failed = 38
    restart_file_not_found = 39
    gio_not_available = 40
    source_dir_mismatch = 42
    ftps_lftp_missing = 43
    volume_wrong_size = 44
    enryption_mismatch = 45
    backend_error = 50
    backend_permission_denied = 51
    backend_not_found = 52
    backend_no_space = 53
```

rdiff-backup: http://www.nongnu.org/rdiff-backup/rdiff-backup.1.html

```
EXIT STATUS

       If rdiff-backup finishes successfully, the exit status will be  0.   If
       there  is  an unrecoverable (critical) error, it will be non-zero (usu-
       ally 1, but don't depend on this  specific  value).   When  setting  up
       rdiff-backup  to  run  automatically (as from cron(8) or similar) it is
       probably a good idea to check the exit code.
```

rdiff-backup also has an error policy, which I think is a good approach for backup software: http://www.nongnu.org/rdiff-backup/error_policy.html

bsdtar
FreeBSD: https://www.freebsd.org/cgi/man.cgi?query=bsdtar&sektion=1

```
EXIT STATUS
     The tar utility exits 0 on success, and >0 if an error occurs.
```

OpenBSD: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man1/tar.1?query=tar&sec=1

```
EXIT STATUS
The tar utility exits with one of the following values:
0
    All files were processed successfully.
1
    An error occurred.
```

It seems that even a return value of 1 is not defined consistently, since for GNU tar it does not mean "otherwise undefined fatal error".

I like Duplicity's approach. It provides a rich interface for shell scripting that does NOT involve parsing strings. Sure, it could get messy (okay, it probably already has... what is 'user_error'?), but it's nice to treat 'backend_no_space' differently from 'connection_failed'.

if we are going to go Duplicity's way, it seems the proper way would be to have a library of Python exceptions with each its own unique integer identifier, and fire those as relevant.

but it seems a little cracked up: i think returning 1 on errors, 0 on success would be sufficient, provided the error message is meaningful. although 128+N is seems useful for splitting between external and internal error conditions...

Maybe the smallest change that would make most people happy would be something like:
0 = complete success, no errors/warnings
1 = backup process reached its normal end, but there were some errors/warnings (this usually means most stuff was backed up, but not everything)
2 = backup process either did not start or it ended abnormally and did not reach its normal end, there was some fatal error (this usually means either nothing was backed up or the backup is very incomplete)

would be fine with me. basically the same distinction as in #57 then...


Resolving patch:
diff --git a/borg/archiver.py b/borg/archiver.py
--- a/borg/archiver.py
+++ b/borg/archiver.py
@@ -19,7 +19,8 @@
     format_file_mode, ExcludePattern, IncludePattern, exclude_path, adjust_patterns, to_localtime, timestamp, \
     get_cache_dir, get_keys_dir, format_timedelta, prune_within, prune_split, \
     Manifest, remove_surrogates, update_excludes, format_archive, check_extension_modules, Statistics, \
-    is_cachedir, bigint_to_int, ChunkerParams, CompressionSpec, have_cython
+    is_cachedir, bigint_to_int, ChunkerParams, CompressionSpec, have_cython, \
+    EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
 from .logger import create_logger, setup_logging
 logger = create_logger()
 if have_cython():
@@ -37,7 +38,7 @@
 class Archiver:
 
     def __init__(self):
-        self.exit_code = 0
+        self.exit_code = EXIT_SUCCESS
 
     def open_repository(self, location, create=False, exclusive=False):
         if location.proto == 'ssh':
@@ -49,7 +50,7 @@ def open_repository(self, location, create=False, exclusive=False):
 
     def print_error(self, msg, *args):
         msg = args and msg % args or msg
-        self.exit_code = 1
+        self.exit_code = EXIT_WARNING  # we do not terminate here, so it is a warning
         logger.error('borg: ' + msg)
 
     def print_verbose(self, msg, *args, **kw):
@@ -92,18 +93,18 @@ def do_check(self, args):
             if repository.check(repair=args.repair):
                 logger.info('Repository check complete, no problems found.')
             else:
-                return 1
+                return EXIT_WARNING
         if not args.repo_only and not ArchiveChecker().check(
                 repository, repair=args.repair, archive=args.repository.archive, last=args.last):
-            return 1
-        return 0
+            return EXIT_WARNING
+        return EXIT_SUCCESS
 
     def do_change_passphrase(self, args):
         """Change repository key file passphrase"""
         repository = self.open_repository(args.repository)
         manifest, key = Manifest.load(repository)
         key.change_passphrase()
-        return 0
+        return EXIT_SUCCESS
 
     def do_create(self, args):
         """Create new archive"""
@@ -326,7 +327,7 @@ def do_delete(self, args):
                     print("""Type "YES" if you understand this and want to continue.\n""", file=sys.stderr)
                     # XXX: prompt may end up on stdout, but we'll assume that input() does the right thing
                     if input('Do you want to continue? ') != 'YES':
-                        self.exit_code = 1
+                        self.exit_code = EXIT_ERROR
                         return self.exit_code
                 repository.destroy()
                 logger.info("Repository deleted.")
@@ -358,7 +359,7 @@ def do_mount(self, args):
             operations.mount(args.mountpoint, args.options, args.foreground)
         except RuntimeError:
             # Relevant error message already printed to stderr by fuse
-            self.exit_code = 1
+            self.exit_code = EXIT_ERROR
         return self.exit_code
 
     def do_list(self, args):
@@ -431,7 +432,7 @@ def do_prune(self, args):
         if args.hourly + args.daily + args.weekly + args.monthly + args.yearly == 0 and args.within is None:
             self.print_error('At least one of the "within", "keep-hourly", "keep-daily", "keep-weekly", '
                              '"keep-monthly" or "keep-yearly" settings must be specified')
-            return 1
+            return EXIT_ERROR
         if args.prefix:
             archives = [archive for archive in archives if archive.name.startswith(args.prefix)]
         keep = []
@@ -1042,22 +1043,33 @@ def main():  # pragma: no cover
     setup_signal_handlers()
     archiver = Archiver()
     try:
+        msg = None
         exit_code = archiver.run(sys.argv[1:])
     except Error as e:
-        archiver.print_error(e.get_message() + "\n%s" % traceback.format_exc())
+        msg = e.get_message() + "\n%s" % traceback.format_exc()
         exit_code = e.exit_code
     except RemoteRepository.RPCError as e:
-        archiver.print_error('Error: Remote Exception.\n%s' % str(e))
-        exit_code = 1
+        msg = 'Remote Exception.\n%s' % str(e)
+        exit_code = EXIT_ERROR
     except Exception:
-        archiver.print_error('Error: Local Exception.\n%s' % traceback.format_exc())
-        exit_code = 1
+        msg = 'Local Exception.\n%s' % traceback.format_exc()
+        exit_code = EXIT_ERROR
     except KeyboardInterrupt:
-        archiver.print_error('Error: Keyboard interrupt.\n%s' % traceback.format_exc())
-        exit_code = 1
-    if exit_code:
-        archiver.print_error('Exiting with failure status due to previous errors')
+        msg = 'Keyboard interrupt.\n%s' % traceback.format_exc()
+        exit_code = EXIT_ERROR
+    if msg:
+        logger.error(msg)
+    exit_msg = 'terminating with %s status, rc %d'
+    if exit_code == EXIT_SUCCESS:
+        logger.info(exit_msg % ('success', exit_code))
+    elif exit_code == EXIT_WARNING:
+        logger.warning(exit_msg % ('warning', exit_code))
+    elif exit_code == EXIT_ERROR:
+        logger.error(exit_msg % ('error', exit_code))
+    else:
+        logger.error(exit_msg % ('abnormal', exit_code))
     sys.exit(exit_code)
 
+
 if __name__ == '__main__':
     main()
diff --git a/borg/helpers.py b/borg/helpers.py
--- a/borg/helpers.py
+++ b/borg/helpers.py
@@ -44,13 +44,27 @@ def have_cython():
     import msgpack
 
 
+# return codes returned by borg command
+# when borg is killed by signal N, rc = 128 + N
+EXIT_SUCCESS = 0  # everything done, no problems
+EXIT_WARNING = 1  # reached normal end of operation, but there were issues
+EXIT_ERROR = 2  # terminated abruptly, did not reach end of operation
+
+
 class Error(Exception):
     """Error base class"""
 
-    exit_code = 1
+    # if we raise such an Error and it is only catched by the uppermost
+    # exception handler (that exits short after with the given exit_code),
+    # it is always a (fatal and abrupt) EXIT_ERROR, never just a warning.
+    exit_code = EXIT_ERROR
 
     def get_message(self):
-        return 'Error: ' + type(self).__doc__.format(*self.args)
+        return type(self).__doc__.format(*self.args)
+
+
+class IntegrityError(Error):
+    """Data integrity error"""
 
 
 class ExtensionModuleError(Error):
@@ -487,10 +501,6 @@ def format_archive(archive):
     return '%-36s %s' % (archive.name, to_localtime(archive.ts).strftime('%c'))
 
 
-class IntegrityError(Error):
-    """Data integrity error"""
-
-
 def memoize(function):
     cache = {}
 
diff --git a/borg/testsuite/archiver.py b/borg/testsuite/archiver.py
--- a/borg/testsuite/archiver.py
+++ b/borg/testsuite/archiver.py
@@ -20,7 +20,7 @@
 from ..archiver import Archiver
 from ..cache import Cache
 from ..crypto import bytes_to_long, num_aes_blocks
-from ..helpers import Manifest
+from ..helpers import Manifest, EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
 from ..remote import RemoteRepository, PathNotAllowed
 from ..repository import Repository
 from . import BaseTestCase
@@ -122,6 +122,24 @@ def exec_fn(*args, **kw):
     return exec_fn
 
 
+def test_return_codes(cmd, tmpdir):
+    repo = tmpdir.mkdir('repo')
+    input = tmpdir.mkdir('input')
+    output = tmpdir.mkdir('output')
+    input.join('test_file').write('content')
+    rc, out = cmd('init', '%s' % str(repo))
+    assert rc == EXIT_SUCCESS
+    rc, out = cmd('create', '%s::archive' % repo, str(input))
+    assert rc == EXIT_SUCCESS
+    with changedir(str(output)):
+        rc, out = cmd('extract', '%s::archive' % repo)
+        assert rc == EXIT_SUCCESS
+    rc, out = cmd('extract', '%s::archive' % repo, 'does/not/match')
+    assert rc == EXIT_WARNING  # pattern did not match
+    rc, out = cmd('create', '%s::archive' % repo, str(input))
+    assert rc == EXIT_ERROR  # duplicate archive name
+
+
 class ArchiverTestCaseBase(BaseTestCase):
     EXE = None  # python source based
     FORK_DEFAULT = False


Test patch: