File: snapshot.py

package info (click to toggle)
apt-listchanges 4.8
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,336 kB
  • sloc: python: 3,477; xml: 693; makefile: 167; sh: 71; perl: 61
file content (164 lines) | stat: -rw-r--r-- 6,476 bytes parent folder | download
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