4 Commits

Author SHA1 Message Date
  Jim Paris 46f9f98860 backup: show errors at top of email notification 2 years ago
  Jim Paris dc7d72b2da initial-setup: make systemd units restart on failure 2 years ago
  Jim Paris cb12e09c46 backup: rework output to make notification emails easier to read 2 years ago
  Jim Paris 1115d1f821 backup: rename pstr() helper to b2s() 2 years ago
2 changed files with 66 additions and 49 deletions
Split View
  1. +64
    -49
      backup.py
  2. +2
    -0
      initial-setup.sh

+ 64
- 49
backup.py View File

@@ -23,8 +23,8 @@ import yaml
import wcmatch.glob # type: ignore
import humanfriendly # type: ignore

def pstr(path: bytes) -> str:
return path.decode(errors='backslashreplace')
def b2s(raw: bytes) -> str:
return raw.decode(errors='backslashreplace')

def format_size(n: int) -> str:
return humanfriendly.format_size(n, keep_width=True, binary=True)
@@ -111,28 +111,31 @@ class Backup:
self.dry_run = dry_run
self.root_seen: typing.Dict[bytes, bool] = {}

# Saved log messages
# Saved log messages (which includes borg output)
self.logs: typing.List[typing.Tuple[str, str]] = []

# All captured borg output
self.captured_output: typing.List[bytes] = []

def out(self, path: bytes):
self.outfile.write(path + (b'\n' if self.dry_run else b'\0'))

def log(self, letter: str, msg: str, bold: bool=False):
colors = { 'E': 31, 'W': 33, 'I': 36 };
colors = {
'E': 31, # red: error
'W': 33, # yellow: warning
'N': 34, # blue: notice, a weaker warning (no email generated)
'I': 36, # cyan: info, backup.py script output
'O': 37, # white: regular output from borg
};
c = colors[letter] if letter in colors else 0
b = "" if bold else "\033[22m"
sys.stderr.write(f"\033[1;{c}m{letter}:{b} {msg}\033[0m\n")
sys.stderr.flush()
sys.stdout.write(f"\033[1;{c}m{letter}:{b} {msg}\033[0m\n")
sys.stdout.flush()
self.logs.append((letter, msg))

def run(self, outfile: typing.IO[bytes]):
self.outfile = outfile
for root in self.config.roots:
if root in self.root_seen:
self.log('I', f"ignoring root, already seen: {pstr(root)}")
self.log('I', f"ignoring root, already seen: {b2s(root)}")
continue

try:
@@ -140,13 +143,13 @@ class Backup:
if not stat.S_ISDIR(st.st_mode):
raise NotADirectoryError
except FileNotFoundError:
self.log('E', f"root does not exist: {pstr(root)}")
self.log('E', f"root does not exist: {b2s(root)}")
continue
except NotADirectoryError:
self.log('E', f"root is not a directory: {pstr(root)}")
self.log('E', f"root is not a directory: {b2s(root)}")
continue

self.log('I', f"processing root {pstr(root)}")
self.log('I', f"processing root {b2s(root)}")
self.scan(root)

def scan(self, path: bytes, parent_st: os.stat_result=None):
@@ -199,7 +202,7 @@ class Backup:
force = self.config.match_re(self.config.unexclude, decorated_path)
if exclude_reason and not force:
self.log(exclude_reason[0],
f"{exclude_reason[1]}: {pstr(path)}")
f"{exclude_reason[1]}: {b2s(path)}")
return

# Print path for Borg
@@ -221,7 +224,7 @@ class Backup:
with open(path + b'/CACHEDIR.TAG', 'rb') as f:
if f.read(len(tag)) == tag:
self.log(
'I', f"skipping, cache dir: {pstr(path)}")
'I', f"skipping, cache dir: {b2s(path)}")
return
except:
pass
@@ -235,7 +238,7 @@ class Backup:
IsADirectoryError,
NotADirectoryError,
PermissionError) as e:
self.log('E', f"can't read {pstr(path)}: {str(e)}")
self.log('E', f"can't read {b2s(path)}: {str(e)}")
return

def run_borg(self, argv: typing.List[str],
@@ -251,6 +254,9 @@ class Backup:
if borg.stdin is None:
raise Exception("no pipe")

# Count warnings and errors from Borg, so we can interpret its
# error codes correctly (e.g. ignoring exit codes if warnings
# were all harmless).
borg_saw_warnings = 0
borg_saw_errors = 0

@@ -262,24 +268,30 @@ class Backup:
for line in fh:
try:
data = json.loads(line)

if data['type'] == 'log_message':
# Count warnings and errors, but ignore some.
changed_msg = "file changed while we backed it up"
if data['levelname'] == 'WARNING':
prefix = "warning: "
if changed_msg not in data['message']:
if changed_msg in data['message']:
# harmless; don't count as a Borg warning
outlevel = 'N'
else:
borg_saw_warnings += 1
outlevel = 'W'
output = "warning: "
elif data['levelname'] not in ('DEBUG', 'INFO'):
prefix = "error: "
borg_saw_errors += 1
outlevel = 'E'
output = "error: "
else:
prefix = ""
line = (prefix + data['message'] + '\n').encode()
outlevel = 'O'
output = ""
output += data['message']

elif (data['type'] == 'progress_message'
and 'message' in data):
line = (data['message'] + '\n').encode()
outlevel = 'O'
output = data['message']

elif data['type'] == 'archive_progress':
now = time.time()
@@ -287,12 +299,12 @@ class Backup:
last_progress = now
def size(short: str, full: str) -> str:
return f" {short}={format_size(data[full])}"
line = (f"progress:" +
f" files={data['nfiles']}" +
size('orig', 'original_size') +
size('comp', 'compressed_size') +
size('dedup', 'deduplicated_size') +
"\n").encode()
outlevel = 'O'
output = (f"progress:" +
f" files={data['nfiles']}" +
size('orig', 'original_size') +
size('comp', 'compressed_size') +
size('dedup', 'deduplicated_size'))
else:
continue
else:
@@ -300,10 +312,9 @@ class Backup:
continue
except Exception as e:
# on error, print raw line with exception
line = f"[exception: {str(e)} ]".encode() + line
sys.stdout.buffer.write(line)
sys.stdout.flush()
self.captured_output.append(line)
outlevel = 'E'
output = f"[exception: {str(e)}] " + b2s(line).rstrip()
self.log(outlevel, output)
fh.close()
def _reader_thread(fh):
try:
@@ -322,8 +333,7 @@ class Backup:
time.sleep(1)
stdin_writer(borg.stdin)
except BrokenPipeError:
sys.stderr.write(f"broken pipe\n")
sys.stderr.flush()
self.log('E', "<broken pipe>")
finally:
try:
borg.stdin.close()
@@ -460,19 +470,24 @@ def main(argv: typing.List[str]):
if email and not args.dry_run:
backup.log('I', f"sending error notification to {email}")

# Show all of our warnings and errors. Use a ">" prefix
# so warnings and errors get highlighted by the mail reader.
body = [ "Logs from backup.py:" ]
for (letter, msg) in backup.logs:
if letter == "E" or letter == "W":
prefix = ">"
else:
prefix = " "
body.append(f"{prefix}{letter}: {msg}")
body_text = "\n".join(body).encode()

# Followed by borg output
body_text += b"\n\nBorg output:\n" + b"".join(backup.captured_output)
def write_logs(title, only_include=None):
body = [ title ]
for (letter, msg) in backup.logs:
if only_include and letter not in only_include:
continue
# Use a ":" prefix for warnings/errors/notices so that
# the mail reader highlights them.
if letter in "EWN":
prefix = ":"
else:
prefix = " "
body.append(f"{prefix}{letter}: {msg}")
return "\n".join(body).encode()


body_text = write_logs("Logged errors and warnings:", "EWN")
body_text += "\n"
body_text += write_logs("All logs:")

# Subject summary
if errmsg and warnmsg:


+ 2
- 0
initial-setup.sh View File

@@ -278,6 +278,8 @@ ExecStart=${BORG_DIR}/backup.py
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
Restart=on-failure
RestartSec=600
EOF

log "Setting up systemd"


Loading…
Cancel
Save