~pponnuvel/charm-haproxy/charm-haproxy

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/fetch/ubuntu_apt_pkg.py

  • Committer: mergebot at canonical
  • Author(s): "Tom Haddon"
  • Date: 2020-02-25 09:29:07 UTC
  • mfrom: (138.1.2 focal)
  • Revision ID: mergebot@juju-139df4-prod-is-toolbox-0.canonical.com-20200225092907-8iby32hct0gnuw40
Add focal support

Reviewed-on: https://code.launchpad.net/~mthaddon/charm-haproxy/focal-support/+merge/379563
Reviewed-by: Stuart Bishop <stuart.bishop@canonical.com>

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2019 Canonical Ltd
 
2
#
 
3
# Licensed under the Apache License, Version 2.0 (the "License");
 
4
# you may not use this file except in compliance with the License.
 
5
# You may obtain a copy of the License at
 
6
#
 
7
#  http://www.apache.org/licenses/LICENSE-2.0
 
8
#
 
9
# Unless required by applicable law or agreed to in writing, software
 
10
# distributed under the License is distributed on an "AS IS" BASIS,
 
11
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 
12
# See the License for the specific language governing permissions and
 
13
# limitations under the License.
 
14
 
 
15
"""Provide a subset of the ``python-apt`` module API.
 
16
 
 
17
Data collection is done through subprocess calls to ``apt-cache`` and
 
18
``dpkg-query`` commands.
 
19
 
 
20
The main purpose for this module is to avoid dependency on the
 
21
``python-apt`` python module.
 
22
 
 
23
The indicated python module is a wrapper around the ``apt`` C++ library
 
24
which is tightly connected to the version of the distribution it was
 
25
shipped on.  It is not developed in a backward/forward compatible manner.
 
26
 
 
27
This in turn makes it incredibly hard to distribute as a wheel for a piece
 
28
of python software that supports a span of distro releases [0][1].
 
29
 
 
30
Upstream feedback like [2] does not give confidence in this ever changing,
 
31
so with this we get rid of the dependency.
 
32
 
 
33
0: https://github.com/juju-solutions/layer-basic/pull/135
 
34
1: https://bugs.launchpad.net/charm-octavia/+bug/1824112
 
35
2: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=845330#10
 
36
"""
 
37
 
 
38
import locale
 
39
import os
 
40
import subprocess
 
41
import sys
 
42
 
 
43
 
 
44
class _container(dict):
 
45
    """Simple container for attributes."""
 
46
    __getattr__ = dict.__getitem__
 
47
    __setattr__ = dict.__setitem__
 
48
 
 
49
 
 
50
class Package(_container):
 
51
    """Simple container for package attributes."""
 
52
 
 
53
 
 
54
class Version(_container):
 
55
    """Simple container for version attributes."""
 
56
 
 
57
 
 
58
class Cache(object):
 
59
    """Simulation of ``apt_pkg`` Cache object."""
 
60
    def __init__(self, progress=None):
 
61
        pass
 
62
 
 
63
    def __contains__(self, package):
 
64
        try:
 
65
            pkg = self.__getitem__(package)
 
66
            return pkg is not None
 
67
        except KeyError:
 
68
            return False
 
69
 
 
70
    def __getitem__(self, package):
 
71
        """Get information about a package from apt and dpkg databases.
 
72
 
 
73
        :param package: Name of package
 
74
        :type package: str
 
75
        :returns: Package object
 
76
        :rtype: object
 
77
        :raises: KeyError, subprocess.CalledProcessError
 
78
        """
 
79
        apt_result = self._apt_cache_show([package])[package]
 
80
        apt_result['name'] = apt_result.pop('package')
 
81
        pkg = Package(apt_result)
 
82
        dpkg_result = self._dpkg_list([package]).get(package, {})
 
83
        current_ver = None
 
84
        installed_version = dpkg_result.get('version')
 
85
        if installed_version:
 
86
            current_ver = Version({'ver_str': installed_version})
 
87
        pkg.current_ver = current_ver
 
88
        pkg.architecture = dpkg_result.get('architecture')
 
89
        return pkg
 
90
 
 
91
    def _dpkg_list(self, packages):
 
92
        """Get data from system dpkg database for package.
 
93
 
 
94
        :param packages: Packages to get data from
 
95
        :type packages: List[str]
 
96
        :returns: Structured data about installed packages, keys like
 
97
                  ``dpkg-query --list``
 
98
        :rtype: dict
 
99
        :raises: subprocess.CalledProcessError
 
100
        """
 
101
        pkgs = {}
 
102
        cmd = ['dpkg-query', '--list']
 
103
        cmd.extend(packages)
 
104
        if locale.getlocale() == (None, None):
 
105
            # subprocess calls out to locale.getpreferredencoding(False) to
 
106
            # determine encoding.  Workaround for Trusty where the
 
107
            # environment appears to not be set up correctly.
 
108
            locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
 
109
        try:
 
110
            output = subprocess.check_output(cmd,
 
111
                                             stderr=subprocess.STDOUT,
 
112
                                             universal_newlines=True)
 
113
        except subprocess.CalledProcessError as cp:
 
114
            # ``dpkg-query`` may return error and at the same time have
 
115
            # produced useful output, for example when asked for multiple
 
116
            # packages where some are not installed
 
117
            if cp.returncode != 1:
 
118
                raise
 
119
            output = cp.output
 
120
        headings = []
 
121
        for line in output.splitlines():
 
122
            if line.startswith('||/'):
 
123
                headings = line.split()
 
124
                headings.pop(0)
 
125
                continue
 
126
            elif (line.startswith('|') or line.startswith('+') or
 
127
                  line.startswith('dpkg-query:')):
 
128
                continue
 
129
            else:
 
130
                data = line.split(None, 4)
 
131
                status = data.pop(0)
 
132
                if status != 'ii':
 
133
                    continue
 
134
                pkg = {}
 
135
                pkg.update({k.lower(): v for k, v in zip(headings, data)})
 
136
                if 'name' in pkg:
 
137
                    pkgs.update({pkg['name']: pkg})
 
138
        return pkgs
 
139
 
 
140
    def _apt_cache_show(self, packages):
 
141
        """Get data from system apt cache for package.
 
142
 
 
143
        :param packages: Packages to get data from
 
144
        :type packages: List[str]
 
145
        :returns: Structured data about package, keys like
 
146
                  ``apt-cache show``
 
147
        :rtype: dict
 
148
        :raises: subprocess.CalledProcessError
 
149
        """
 
150
        pkgs = {}
 
151
        cmd = ['apt-cache', 'show', '--no-all-versions']
 
152
        cmd.extend(packages)
 
153
        if locale.getlocale() == (None, None):
 
154
            # subprocess calls out to locale.getpreferredencoding(False) to
 
155
            # determine encoding.  Workaround for Trusty where the
 
156
            # environment appears to not be set up correctly.
 
157
            locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
 
158
        try:
 
159
            output = subprocess.check_output(cmd,
 
160
                                             stderr=subprocess.STDOUT,
 
161
                                             universal_newlines=True)
 
162
            previous = None
 
163
            pkg = {}
 
164
            for line in output.splitlines():
 
165
                if not line:
 
166
                    if 'package' in pkg:
 
167
                        pkgs.update({pkg['package']: pkg})
 
168
                        pkg = {}
 
169
                    continue
 
170
                if line.startswith(' '):
 
171
                    if previous and previous in pkg:
 
172
                        pkg[previous] += os.linesep + line.lstrip()
 
173
                    continue
 
174
                if ':' in line:
 
175
                    kv = line.split(':', 1)
 
176
                    key = kv[0].lower()
 
177
                    if key == 'n':
 
178
                        continue
 
179
                    previous = key
 
180
                    pkg.update({key: kv[1].lstrip()})
 
181
        except subprocess.CalledProcessError as cp:
 
182
            # ``apt-cache`` returns 100 if none of the packages asked for
 
183
            # exist in the apt cache.
 
184
            if cp.returncode != 100:
 
185
                raise
 
186
        return pkgs
 
187
 
 
188
 
 
189
class Config(_container):
 
190
    def __init__(self):
 
191
        super(Config, self).__init__(self._populate())
 
192
 
 
193
    def _populate(self):
 
194
        cfgs = {}
 
195
        cmd = ['apt-config', 'dump']
 
196
        output = subprocess.check_output(cmd,
 
197
                                         stderr=subprocess.STDOUT,
 
198
                                         universal_newlines=True)
 
199
        for line in output.splitlines():
 
200
            if not line.startswith("CommandLine"):
 
201
                k, v = line.split(" ", 1)
 
202
                cfgs[k] = v.strip(";").strip("\"")
 
203
 
 
204
        return cfgs
 
205
 
 
206
 
 
207
# Backwards compatibility with old apt_pkg module
 
208
sys.modules[__name__].config = Config()
 
209
 
 
210
 
 
211
def init():
 
212
    """Compability shim that does nothing."""
 
213
    pass
 
214
 
 
215
 
 
216
def upstream_version(version):
 
217
    """Extracts upstream version from a version string.
 
218
 
 
219
    Upstream reference: https://salsa.debian.org/apt-team/apt/blob/master/
 
220
                                apt-pkg/deb/debversion.cc#L259
 
221
 
 
222
    :param version: Version string
 
223
    :type version: str
 
224
    :returns: Upstream version
 
225
    :rtype: str
 
226
    """
 
227
    if version:
 
228
        version = version.split(':')[-1]
 
229
        version = version.split('-')[0]
 
230
    return version
 
231
 
 
232
 
 
233
def version_compare(a, b):
 
234
    """Compare the given versions.
 
235
 
 
236
    Call out to ``dpkg`` to make sure the code doing the comparison is
 
237
    compatible with what the ``apt`` library would do.  Mimic the return
 
238
    values.
 
239
 
 
240
    Upstream reference:
 
241
    https://apt-team.pages.debian.net/python-apt/library/apt_pkg.html
 
242
            ?highlight=version_compare#apt_pkg.version_compare
 
243
 
 
244
    :param a: version string
 
245
    :type a: str
 
246
    :param b: version string
 
247
    :type b: str
 
248
    :returns: >0 if ``a`` is greater than ``b``, 0 if a equals b,
 
249
              <0 if ``a`` is smaller than ``b``
 
250
    :rtype: int
 
251
    :raises: subprocess.CalledProcessError, RuntimeError
 
252
    """
 
253
    for op in ('gt', 1), ('eq', 0), ('lt', -1):
 
254
        try:
 
255
            subprocess.check_call(['dpkg', '--compare-versions',
 
256
                                   a, op[0], b],
 
257
                                  stderr=subprocess.STDOUT,
 
258
                                  universal_newlines=True)
 
259
            return op[1]
 
260
        except subprocess.CalledProcessError as cp:
 
261
            if cp.returncode == 1:
 
262
                continue
 
263
            raise
 
264
    else:
 
265
        raise RuntimeError('Unable to compare "{}" and "{}", according to '
 
266
                           'our logic they are neither greater, equal nor '
 
267
                           'less than each other.')