summaryrefslogtreecommitdiff
path: root/src/mongo/installer/compass/install_compass
blob: 844d770fed23e0a1ccbea2f3ca6306f6ad4fd4e5 (plain)
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
#!/usr/bin/env python

"""This script downloads the latest version of Compass and installs it on
non-windows platforms."""


import itertools
import logging
import os
import os.path as path
import platform
import re
import shutil
import subprocess
import sys
import tempfile

try:
    from urllib2 import urlopen
except ImportError:
    from urllib.request import urlopen

try:
    from shlex import quote as cmd_quote
except ImportError:
    from pipes import quote as cmd_quote


DOWNLOAD_BASE_URL = 'https://compass.mongodb.com/api/v2/download/latest/compass/stable'

# Some systems may have multiple package managers installed - Debian systems may
# have yum installed, and newer RHEL/Fedora systems may have both dnf and yum.
# These must be checked in the given order for consistent results.
SUPPORTED_LINUX_PKG_MANAGERS = ['apt', 'dnf', 'yum']

LINUX_PKG_MANAGER_ARGS = {
    'apt': ['install', '--yes'],
    'yum': ['install', '--assumeyes'],
    'dnf': ['install', '--assumeyes'],
}

PKG_MANAGER_SUFFIXES = {
    'apt': '.deb',
    'dnf': '.rpm',
    'yum': '.rpm',
    'dmg': '.dmg',
}

SUPPORTED_OSX_VERSION = 10.10
SUPPORTED_RHEL_VERSION = 7
SUPPORTED_DEBIAN_VERSION = 9
SUPPORTED_DEBIAN_RELEASES = [
    'stretch',
    'buster',
    'bullseye',
    'bookworm',
]


class LogLevelFilter(logging.Filter):
    """Filters log messages by level"""

    def __init__(self, levelno, *args, **kwargs):
        self._levelno = levelno
        # In Python 2.6, logging.Filter is not a new-style class, so super()
        # will not work with it. We need to call the base class __init__()
        # directly here so it works with all versions of Python.
        logging.Filter.__init__(self, *args, **kwargs)

    def filter(self, record):
        return record.levelno <= self._levelno


class FileDownloadError(Exception):
    """Exception raised on file download errors"""


class InstallationError(Exception):
    """Exception raised on installation errors"""


def run_command(*args, **kwargs):
    """Runs a command, with optional stdout and stderr."""

    with open(os.devnull, 'w') as fnull:
        stdout = kwargs.pop('stdout', fnull)
        stderr = kwargs.pop('stderr', fnull)

        process = subprocess.Popen([cmd_quote(arg) for arg in args],
                                   stdout=stdout, stderr=stderr, **kwargs)
        stdout, stderr = process.communicate()

        if stdout is not None:
            stdout = stdout.decode('utf-8')
            process.stdout.close()
        if stderr is not None:
            stderr = stderr.decode('utf-8')
            process.stderr.close()

    return (process.wait(), stdout, stderr)


def get_package_manager():
    """Determine the package manager for this Linux distro."""

    if sys.platform == 'darwin':
        return 'dmg'
    else:
        for package_manager in SUPPORTED_LINUX_PKG_MANAGERS:
            try:
                retcode, _, _ = run_command(package_manager, '--help')
                if retcode == 0:
                    return package_manager
            except OSError:
                pass

    return None


def download_progress(count, block_size, total_size):
    """Log the download progress."""

    percent = min(int(count * block_size * 100 / total_size), 100)

    if sys.stdout.isatty():
        sys.stdout.write("\x1b[KDownloading ... " + str(percent) + "%\r")
        sys.stdout.flush()
    else:
        # If we're not writing to a terminal, don't bother being too fancy. This
        # prevents us from filling up logs with many progress lines.
        if count == 1:
            LOG.info('Downloading ...')
        if count % 10 == 0:
            LOG.info("    %dK (%d%%)", block_size / 1024, percent)

    if (count * block_size) >= total_size:
        LOG.info("Complete!")


def download_file(url, filename, block_size=16 * 1024):
    """Download the package from link, logging progress. Returns the filename."""

    try:
        response = urlopen(url)
        total_size = int(response.info()["Content-Length"])
        with open(filename, 'wb') as out_file:
            for count in itertools.count(1):
                chunk = response.read(block_size)
                if not chunk:
                    LOG.info('\nDownload complete!')
                    break
                download_progress(count, block_size, total_size)
                out_file.write(chunk)
        response.close()
    except IOError:
        raise FileDownloadError('Unable to download MongoDB Compass, please check your internet' \
                                ' connection. If the issue persists go to' \
                                ' https://www.mongodb.com/download-center?jmp=hero#compass' \
                                ' to download the compass installer for your platform.')
    else:
        retcode, stdout, stderr = run_command('file', filename,
                                              shell=False, stdout=subprocess.PIPE,
                                              stderr=subprocess.PIPE)
        if retcode != 0:
            raise Exception(stderr)
        if 'ASCII Text' in stdout:
            raise FileDownloadError('Downloaded file is text, but binary was expected.')


def install_mac(dmg):
    """Use CLI tools to mount the dmg and extract all .apps into Applications."""

    tmp = tempfile.mkdtemp()

    retcode, _, stderr = run_command(
        'hdiutil', 'attach', '-nobrowse', '-noautoopen', '-mountpoint', tmp, dmg,
        stderr=subprocess.PIPE)
    if retcode != 0:
        raise InstallationError('Problem running hdiutil: ' + stderr)

    try:
        apps = [f for f in os.listdir(tmp) if f.endswith('.app')]
        for app in apps:
            if path.isdir('/Applications/' + app):
                LOG.info('Old version found removing...')
                shutil.rmtree('/Applications/' + app)
            LOG.info('Copying %s to /Applications', app)
            shutil.copytree(path.join(tmp, app), '/Applications/' + app)
    # We don't really care about what errors come up here. Just log the failure
    # and use the finally to make sure we always unmount the dmg.
    except IOError as error:
        raise InstallationError('Error copying MongoDB Compass to /Applications: ' + error.message)
    finally:
        run_command('hdiutil', 'detach', tmp)

    if path.isdir('/Applications/MongoDB Compass.app'):
        retcode, _, stderr = run_command('open', '/Applications/MongoDB Compass.app',
                                         stderr=subprocess.PIPE)
        if retcode == 0:
            return

    if path.isdir('/Applications/MongoDB Compass Community.app'):
        retcode, _, stderr = run_command('open', '/Applications/MongoDB Compass Community.app',
                                         stderr=subprocess.PIPE)
        if retcode == 0:
            return

    raise InstallationError('Problem opening application: ' + stderr)


def install_linux(package_manager, pkg_file):
    """Use the package manager indicated by pkg_format to install pkg_file."""

    install = tuple([package_manager] + LINUX_PKG_MANAGER_ARGS[package_manager] + [pkg_file])

    retcode, _, stderr = run_command(*install, stderr=subprocess.PIPE)
    if retcode != 0:
        raise InstallationError('Problem running package manager: ' + stderr)


def get_osx_version():
    """Gets the OSX version."""

    mac_version, _, _ = platform.mac_ver()
    # Get Mac OSX Major.Minor verson as a float
    return float('.'.join(mac_version.split('.')[:2]))


def get_rhel_version():
    """Gets the RHEL compatibility version."""

    rhel_version_re = re.compile(r' release (\d+\.\d+)', re.MULTILINE)

    with open('/etc/redhat-release', 'r') as release_file:
        match = rhel_version_re.search(release_file.read())

        if not match:
            return None

        return float('.'.join(match.group(1).split('.')))


def get_debian_release():
    """Gets the Debian release."""

    # Matches from the beginning of the line to a slash or the end of the line
    debian_version_re = re.compile(r'^([^/]+)(?:/|$)', re.MULTILINE)

    with open('/etc/debian_version', 'r') as version_file:
        match = debian_version_re.search(version_file.read())

        if not match:
            return None

        return match.group(1)


def is_supported_linux():  # pylint: disable=too-many-branches,too-many-return-statements
    """Checks whether the Linux version is supported by the Compass package."""

    # This isn't intended to be an exhaustive check of all compatible
    # distributions. We're only checking the ones that are likely to be
    # used when installing either a .deb or a .rpm package.
    if os.path.exists('/etc/redhat-release'):
        rhel_version = get_rhel_version()
        if rhel_version is not None and rhel_version > SUPPORTED_RHEL_VERSION:
            return True

    if os.path.exists('/etc/debian_version'):
        debian_release = get_debian_release()
        try:
            # The debian version string is sometimes just a float. This appears
            # to be only for older releases, but we'll try to be safe here.
            if float(debian_release) >= SUPPORTED_DEBIAN_VERSION:
                return True
            return False
        except ValueError:
            if debian_release is not None and debian_release in SUPPORTED_DEBIAN_RELEASES:
                return True
            return False

    # Matches the key and value separated by an =, with the value optionally
    # being enclosed in quotes.
    os_release_re = re.compile(r'^([A-Z_]+)="?([^"]+)(?:"|$)')
    if os.path.exists('/etc/os-release'):
        os_id = None
        version_id = None
        with open('/etc/os-release', 'r') as os_release:
            for line in os_release.readlines():
                match = os_release_re.search(line)
                if match:
                    if match.group(1) == "ID":
                        os_id = match.group(2)
                    if match.group(1) == "VERSION_ID":
                        version_id = match.group(2)
        # The only two known incompatible distributions that have a
        # /etc/os-release provided by systemd are these two. It's best here to
        # assume the rest will succeed and take the ones that fail individually.
        if os_id == "amzn":
            if version_id.startswith("2018") or version_id.startswith("2016"):
                return False
        if os_id == "opensuse":
            return False
        return True

    return False


def prerequisites_satisfied(package_manager):
    """Check that prerequisites are satisfied before downloading and installing."""

    if package_manager is None:
        LOG.error('You are using an unsupported platform.\n' \
              'Please visit: https://compass.mongodb.com/community-supported-platforms' \
              ' to view available community supported packages.')
        return False

    if platform.machine() != 'x86_64':
        LOG.error('Sorry, MongoDB Compass is only supported on 64-bit Intel platforms.' \
              ' If you believe you\'re seeing this message in error please open a' \
              ' ticket on the SERVER project at https://jira.mongodb.org/')
        return False

    if sys.platform == 'osx' and get_osx_version() < SUPPORTED_OSX_VERSION:
        LOG.error('You are using an unsupported Mac OSX version. Please upgrade\n' \
              'to at least version 10.10 (Yosemite) to install Compass.')
        return False

    if sys.platform.startswith('linux'):
        if os.getuid() != 0:
            LOG.error('You must run this script as root.')
            return False

        if not is_supported_linux():
            LOG.error('You are using an unsupported Linux distribution.\n' \
                      'Please visit: https://compass.mongodb.com/community-supported-platforms' \
                      ' to view available community supported packages.')
            return False

    return True


def get_package_type(package_manager):
    """Gets the package type from the detected package manager."""

    if sys.platform.startswith('linux'):
        package_type = 'linux'
        if package_manager == 'apt':
            package_type += '_deb'
        elif package_manager in ('yum', 'dnf'):
            package_type += '_rpm'
    elif sys.platform == 'darwin':
        package_type = 'osx'

    return package_type


def download_and_install_compass():
    """Download and install compass for this platform."""

    package_manager = get_package_manager()

    if not prerequisites_satisfied(package_manager):
        return sys.exit(1)

    _, filename = tempfile.mkstemp(suffix=PKG_MANAGER_SUFFIXES[package_manager])
    is_successful = True

    url = DOWNLOAD_BASE_URL + "/" + get_package_type(package_manager)
    LOG.info('Retrieving the Compass package from %s', url)

    try:
        download_file(url=url, filename=filename)
    except FileDownloadError as error:
        LOG.exception(error)
        is_successful = False
    except Exception as error:  # pylint: disable=broad-except
        LOG.error('Unexpected error when downloading: %s', error.message)
        is_successful = False

    else:
        LOG.info('Installing the package...')
        try:
            if package_manager == 'dmg':
                install_mac(filename)
            else:
                install_linux(package_manager, filename)
        except InstallationError  as error:
            LOG.exception(error)
            is_successful = False
        except Exception as error:  # pylint: disable=broad-except
            LOG.error('Unexpected error when installing: %s', error.message)
            is_successful = False

    LOG.info('Cleaning up...')
    os.remove(filename)
    LOG.info('Done!')

    return is_successful


# Set up logging to stdout and stderr for the console only
LOG = logging.getLogger()
LOG.setLevel(logging.DEBUG)

LOG_STDOUT_HANDLER = logging.StreamHandler(sys.stdout)
LOG_STDOUT_HANDLER.setLevel(logging.INFO)
LOG_STDOUT_HANDLER.addFilter(LogLevelFilter(logging.INFO))
LOG.addHandler(LOG_STDOUT_HANDLER)

LOG_STDERR_HANDLER = logging.StreamHandler(sys.stderr)
LOG_STDERR_HANDLER.setLevel(logging.ERROR)
LOG_STDERR_HANDLER.addFilter(LogLevelFilter(logging.ERROR))
LOG.addHandler(LOG_STDERR_HANDLER)


if __name__ == '__main__':
    # Set up a log file for both stdout and stderr, but only when this is being
    # executed as a script. This facilitates testing without polluting the test
    # environment.
    LOG_FILE_NAME = '/tmp/install_compass.log'
    LOG_FILE_HANDLER = logging.FileHandler(LOG_FILE_NAME)
    LOG_FILE_HANDLER.setLevel(logging.DEBUG)
    LOG_FILE_FORMATTER = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    LOG_FILE_HANDLER.setFormatter(LOG_FILE_FORMATTER)
    LOG.addHandler(LOG_FILE_HANDLER)

    SUCCESS = download_and_install_compass()
    LOG.info('A log file for this installation can be found at %s', LOG_FILE_NAME)

    if not SUCCESS:
        LOG.error('Please see our documentation for how to download and install ' \
                  'Compass manually: https://docs.mongodb.com/compass/current/install/#download-and-install-compass')
        sys.exit(1)