1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
|
'''State snapshots to facilitate debugging and fixing apt-listchanges bugs
Sometimes apt-listchanges malfunctions in ways that are highly dependent on the
environment in which it's running and the specific packages it's looking for
changes in. We've implemented this snapshot functionality to facilitate finding
and fixing those bugs quickly when there is active development of the program
ongoing and therefore odds are higher that bugs will be introduced.
When snapshots are enabled (more on that below), each time the program runs, it
makes copies of several significant files and also saves some data to disk, in
a snapshot folder which is, at the end of the run, turned into a .tar.xz file.
The last seven .tar.xz files are saved. If a user who has snapshots enabled
reports a bug, we can ask them to send us the relevant snapshot to assist us in
troubleshooting.
Any user can enable snapshots at any time by adding the configuration settings
`capture_snapshots=true` and `snapshot_dir=[directory-path]` to the
apt-listchanges config fragment file for a particular profile. However, these
settings are undocumented and generally not expected to be added by hand.
Instead, when debconf_helper.py is configured to enable snapshots, it adds
`capture_snapshots=auto` and `snapshot_dir` settings to the configuration of
the `apt` profile.
Setting `capture_snapshots=auto` is equivalent to setting it to `true`, but it
means that in a subsequent release when we change debconf_helper.py to no
longer enable snapshots, it's allowed to automatically remove the
`capture_snapshots` and `snapshot_dir` options and delete any snapshots that
are still around. In this way we can transition easily from experimental to
non-experimental packages and snapshots get disabled and cleaned up
automatically.
The build checks to make sure that debconf_helper.py doesn't have snapshots
enabled unless the changelog says we're doing an experimental release. We never
want to release apt-listchanges past experimental with snapshots enabled.
'''
import datetime
import os
import shutil
from apt_listchanges.ALCConfig import ALCConfig
from apt_listchanges.ALCLog import debug
class Snapshot:
'''Build a snapshot of what happened during the apt-listchanges run
This is designed to _never cause an exception_. If something goes wrong it
just gives up. The goal here is to make a best effort and never interfere
with the proper operation of the program.'''
SAVED_SNAPSHOTS = 7
def __init__(self, config: ALCConfig) -> None:
self.archive = None
if not config.capture_snapshots:
self.capturing = False
return
if not config.snapshot_dir:
self.capturing = False
return
self.snapshot_dir = config.snapshot_dir
if not os.path.exists(self.snapshot_dir):
if not os.path.exists(os.path.dirname(self.snapshot_dir)):
# It's too risky to go traipsing around the filesystem creating
# multiple levels of directories, so let's just skip it. If the
# user really wants to store snapshots multiple levels deep in
# a new hierarchy, they can create the directory by hand.
self.capturing = False
return
try:
os.mkdir(self.snapshot_dir)
except Exception as ex:
debug(f'mkdir({self.snapshot_dir}) failed, {ex}')
self.capturing = False
return
self.snapshot_subdir = datetime.datetime.now().replace(microsecond=0).\
isoformat()
self.snapshot_path = os.path.join(
self.snapshot_dir, self.snapshot_subdir)
try:
os.mkdir(self.snapshot_path)
except Exception as ex:
debug(f'mkdir({self.snapshot_path}) failed, {ex}')
self.capturing = False
return
self.capturing = True
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc_value, _exc_traceback):
self.commit()
return False
def abort(self):
self.capturing = False
try:
shutil.rmtree(self.snapshot_path, ignore_errors=True)
except Exception: # pragma: no cover
pass
def add_file(self, path: str, name: str | None = None) -> None:
'''Add a file to the snapshot
If name is not specified the the basename of the file is used'''
if not self.capturing:
return
if not name:
name = os.path.basename(path)
if not os.path.exists(path):
self.add_data('', f'{name}.missing')
return
try:
shutil.copy(path, os.path.join(self.snapshot_path, name))
except Exception:
self.abort()
def add_data(self, data: str | bytes, name: str) -> None:
if not self.capturing:
return
if isinstance(data, str):
mode = 'wt'
encoding = {'encoding': 'utf-8'}
else:
mode = 'wb'
encoding = {}
try:
target = os.path.join(self.snapshot_path, name)
# pylint: disable=unspecified-encoding
with open(target, mode, **encoding) as f:
f.write(data)
except Exception: # pragma: no cover
self.abort()
def commit(self):
if not self.capturing:
return
try:
self.archive = shutil.make_archive(
self.snapshot_path, 'xztar', self.snapshot_dir,
self.snapshot_subdir)
except Exception:
self.abort()
return
self.capturing = False
try:
shutil.rmtree(self.snapshot_path, ignore_errors=True)
except Exception: # pragma: no cover
return
self.prune()
def prune(self):
'''Prune old snapshots or snapshot directories'''
try:
snapshots = sorted(
(de.name for de in os.scandir(self.snapshot_dir)),
reverse=True)[self.SAVED_SNAPSHOTS:]
for snapshot in snapshots:
path = os.path.join(self.snapshot_dir, snapshot)
try:
os.remove(path)
except Exception: # pragma: no cover
shutil.rmtree(path, ignore_errors=True)
except Exception: # pragma: no cover
pass
|