Merge pull request #337 from yuvipanda/better-output

Provide much better error messages
This commit is contained in:
Yuvi Panda
2019-05-29 13:13:08 -07:00
committed by GitHub
8 changed files with 131 additions and 30 deletions

View File

@@ -18,6 +18,7 @@ import sys
import logging import logging
import shutil import shutil
logger = logging.getLogger(__name__)
def get_os_release_variable(key): def get_os_release_variable(key):
""" """
@@ -32,6 +33,36 @@ def get_os_release_variable(key):
"source /etc/os-release && echo ${{{key}}}".format(key=key) "source /etc/os-release && echo ${{{key}}}".format(key=key)
]).decode().strip() ]).decode().strip()
# Copied into tljh/utils.py. Make sure the copies are exactly the same!
def run_subprocess(cmd, *args, **kwargs):
"""
Run given cmd with smart output behavior.
If command succeeds, print output to debug logging.
If it fails, print output to info logging.
In TLJH, this sends successful output to the installer log,
and failed output directly to the user's screen
"""
logger = logging.getLogger('tljh')
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, *args, **kwargs)
printable_command = ' '.join(cmd)
if proc.returncode != 0:
# Our process failed! Show output to the user
logger.error('Ran {command} with exit code {code}'.format(
command=printable_command, code=proc.returncode
))
logger.error(proc.stdout.decode())
raise subprocess.CalledProcessError(cmd=cmd, returncode=proc.returncode)
else:
# This goes into installer.log
logger.debug('Ran {command} with exit code {code}'.format(
command=printable_command, code=proc.returncode
))
# This produces multi line log output, unfortunately. Not sure how to fix.
# For now, prioritizing human readability over machine readability.
logger.debug(proc.stdout.decode())
def validate_host(): def validate_host():
""" """
Make sure TLJH is installable in current host Make sure TLJH is installable in current host
@@ -65,17 +96,21 @@ def main():
hub_prefix = os.path.join(install_prefix, 'hub') hub_prefix = os.path.join(install_prefix, 'hub')
# Set up logging to print to a file and to stderr # Set up logging to print to a file and to stderr
logger = logging.getLogger(__name__)
os.makedirs(install_prefix, exist_ok=True) os.makedirs(install_prefix, exist_ok=True)
file_logger = logging.FileHandler(os.path.join(install_prefix, 'installer.log')) file_logger_path = os.path.join(install_prefix, 'installer.log')
file_logger = logging.FileHandler(file_logger_path)
# installer.log should be readable only by root
os.chmod(file_logger_path, 0o500)
file_logger.setFormatter(logging.Formatter('%(asctime)s %(message)s')) file_logger.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
file_logger.setLevel(logging.DEBUG)
logger.addHandler(file_logger) logger.addHandler(file_logger)
stderr_logger = logging.StreamHandler() stderr_logger = logging.StreamHandler()
stderr_logger.setFormatter(logging.Formatter('%(message)s')) stderr_logger.setFormatter(logging.Formatter('%(message)s'))
stderr_logger.setLevel(logging.INFO)
logger.addHandler(stderr_logger) logger.addHandler(stderr_logger)
logger.setLevel(logging.INFO) logger.setLevel(logging.DEBUG)
logger.info('Checking if TLJH is already installed...') logger.info('Checking if TLJH is already installed...')
if os.path.exists(os.path.join(hub_prefix, 'bin', 'python3')): if os.path.exists(os.path.join(hub_prefix, 'bin', 'python3')):
@@ -89,20 +124,20 @@ def main():
# that's where the python3-pip package lives. In some very minimal base # that's where the python3-pip package lives. In some very minimal base
# VM images, it looks like the universe repository is disabled by default, # VM images, it looks like the universe repository is disabled by default,
# causing bootstrapping to fail. # causing bootstrapping to fail.
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT) run_subprocess(['apt-get', 'update', '--yes'])
subprocess.check_output(['apt-get', 'install', '--yes', 'software-properties-common'], stderr=subprocess.STDOUT) run_subprocess(['apt-get', 'install', '--yes', 'software-properties-common'])
subprocess.check_output(['add-apt-repository', 'universe'], stderr=subprocess.STDOUT) run_subprocess(['add-apt-repository', 'universe'])
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT) run_subprocess(['apt-get', 'update', '--yes'])
subprocess.check_output(['apt-get', 'install', '--yes', run_subprocess(['apt-get', 'install', '--yes',
'git',
'python3', 'python3',
'python3-venv', 'python3-venv',
'python3-pip' 'python3-pip',
], stderr=subprocess.STDOUT) 'git'
])
logger.info('Installed python & virtual environment') logger.info('Installed python & virtual environment')
os.makedirs(hub_prefix, exist_ok=True) os.makedirs(hub_prefix, exist_ok=True)
subprocess.check_output(['python3', '-m', 'venv', hub_prefix], stderr=subprocess.STDOUT) run_subprocess(['python3', '-m', 'venv', hub_prefix])
logger.info('Set up hub virtual environment') logger.info('Set up hub virtual environment')
if initial_setup: if initial_setup:
@@ -118,10 +153,10 @@ def main():
'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git' 'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git'
) )
subprocess.check_output([ run_subprocess([
os.path.join(hub_prefix, 'bin', 'pip'), os.path.join(hub_prefix, 'bin', 'pip'),
'install' 'install'
] + pip_flags + [tljh_repo_path], stderr=subprocess.STDOUT) ] + pip_flags + [tljh_repo_path])
logger.info('Setup tljh package') logger.info('Setup tljh package')
logger.info('Starting TLJH installer...') logger.info('Starting TLJH installer...')

View File

@@ -1,4 +1,5 @@
pytest pytest
pytest-cov pytest-cov
pytest-mock
codecov codecov
pytoml pytoml

View File

@@ -117,6 +117,12 @@ def test_admin_writable():
permissions_test(ADMIN_GROUP, sys.prefix, writable=True, dirs_only=True) permissions_test(ADMIN_GROUP, sys.prefix, writable=True, dirs_only=True)
def test_installer_log_readable():
# Test that installer.log is owned by root, and not readable by anyone else
file_stat = os.stat('/opt/tljh/installer.log')
assert file_stat.st_uid == 0
assert file_stat.st_mode == 0o100500
@pytest.mark.parametrize("group", [ADMIN_GROUP, USER_GROUP]) @pytest.mark.parametrize("group", [ADMIN_GROUP, USER_GROUP])
def test_user_env_readable(group): def test_user_env_readable(group):
# every file in user env should be readable by everyone # every file in user env should be readable by everyone

21
tests/test_utils.py Normal file
View File

@@ -0,0 +1,21 @@
import pytest
from tljh import utils
import subprocess
import logging
def test_run_subprocess_exception(mocker):
logger = logging.getLogger('tljh')
mocker.patch.object(logger, 'error')
with pytest.raises(subprocess.CalledProcessError):
utils.run_subprocess(
['/bin/bash', '-c', 'echo error; exit 1']
)
logger.error.assert_called_with('error\n')
def test_run_subprocess(mocker):
logger = logging.getLogger('tljh')
mocker.patch.object(logger, 'debug')
utils.run_subprocess(['/bin/bash', '-c', 'echo success'])
logger.debug.assert_called_with('success\n')

View File

@@ -3,6 +3,7 @@ Utilities for working with the apt package manager
""" """
import os import os
import subprocess import subprocess
from tljh import utils
def trust_gpg_key(key): def trust_gpg_key(key):
@@ -14,7 +15,7 @@ def trust_gpg_key(key):
# If gpg2 doesn't exist, install it. # If gpg2 doesn't exist, install it.
if not os.path.exists('/usr/bin/gpg2'): if not os.path.exists('/usr/bin/gpg2'):
install_packages(['gnupg2']) install_packages(['gnupg2'])
subprocess.check_output(['apt-key', 'add', '-'], input=key, stderr=subprocess.STDOUT) utils.run_subprocess(['apt-key', 'add', '-'], input=key)
def add_source(name, source_url, section): def add_source(name, source_url, section):
@@ -32,7 +33,7 @@ def add_source(name, source_url, section):
f.seek(0) f.seek(0)
f.write(line) f.write(line)
f.truncate() f.truncate()
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT) utils.run_subprocess(['apt-get', 'update', '--yes'])
def install_packages(packages): def install_packages(packages):
@@ -41,9 +42,9 @@ def install_packages(packages):
""" """
# Check if an apt-get update is required # Check if an apt-get update is required
if len(os.listdir('/var/lib/apt/lists')) == 0: if len(os.listdir('/var/lib/apt/lists')) == 0:
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT) utils.run_subprocess(['apt-get', 'update', '--yes'])
subprocess.check_output([ utils.run_subprocess([
'apt-get', 'apt-get',
'install', 'install',
'--yes' '--yes'
] + packages, stderr=subprocess.STDOUT) ] + packages)

View File

@@ -9,6 +9,7 @@ import contextlib
import tempfile import tempfile
import requests import requests
from distutils.version import LooseVersion as V from distutils.version import LooseVersion as V
from tljh import utils
def md5_file(fname): def md5_file(fname):
@@ -68,22 +69,22 @@ def fix_permissions(prefix):
Run after each install command. Run after each install command.
""" """
subprocess.check_call( utils.run_subprocess(
["chown", "-R", "{}:{}".format(os.getuid(), os.getgid()), prefix] ["chown", "-R", "{}:{}".format(os.getuid(), os.getgid()), prefix]
) )
subprocess.check_call(["chmod", "-R", "o-w", prefix]) utils.run_subprocess(["chmod", "-R", "o-w", prefix])
def install_miniconda(installer_path, prefix): def install_miniconda(installer_path, prefix):
""" """
Install miniconda with installer at installer_path under prefix Install miniconda with installer at installer_path under prefix
""" """
subprocess.check_output([ utils.run_subprocess([
'/bin/bash', '/bin/bash',
installer_path, installer_path,
'-u', '-b', '-u', '-b',
'-p', prefix '-p', prefix
], stderr=subprocess.STDOUT) ])
# fix permissions on initial install # fix permissions on initial install
# a few files have the wrong ownership and permissions initially # a few files have the wrong ownership and permissions initially
# when the installer is run as root # when the installer is run as root
@@ -128,10 +129,10 @@ def ensure_pip_packages(prefix, packages):
abspath = os.path.abspath(prefix) abspath = os.path.abspath(prefix)
pip_executable = [os.path.join(abspath, 'bin', 'python'), '-m', 'pip'] pip_executable = [os.path.join(abspath, 'bin', 'python'), '-m', 'pip']
subprocess.check_output(pip_executable + [ utils.run_subprocess(pip_executable + [
'install', 'install',
'--no-cache-dir', '--no-cache-dir',
] + packages, stderr=subprocess.STDOUT) ] + packages)
fix_permissions(prefix) fix_permissions(prefix)
@@ -144,9 +145,9 @@ def ensure_pip_requirements(prefix, requirements_path):
abspath = os.path.abspath(prefix) abspath = os.path.abspath(prefix)
pip_executable = [os.path.join(abspath, 'bin', 'python'), '-m', 'pip'] pip_executable = [os.path.join(abspath, 'bin', 'python'), '-m', 'pip']
subprocess.check_output(pip_executable + [ utils.run_subprocess(pip_executable + [
'install', 'install',
'-r', '-r',
requirements_path requirements_path
], stderr=subprocess.STDOUT) ])
fix_permissions(prefix) fix_permissions(prefix)

View File

@@ -22,6 +22,7 @@ from tljh import (
systemd, systemd,
traefik, traefik,
user, user,
utils
) )
from .config import ( from .config import (
CONFIG_DIR, CONFIG_DIR,
@@ -35,7 +36,6 @@ from .yaml import yaml
HERE = os.path.abspath(os.path.dirname(__file__)) HERE = os.path.abspath(os.path.dirname(__file__))
logger = logging.getLogger("tljh") logger = logging.getLogger("tljh")
def ensure_node(): def ensure_node():
@@ -172,7 +172,7 @@ def ensure_jupyterlab_extensions():
'@jupyterlab/hub-extension', '@jupyterlab/hub-extension',
'@jupyter-widgets/jupyterlab-manager' '@jupyter-widgets/jupyterlab-manager'
] ]
subprocess.check_output([ utils.run_subprocess([
os.path.join(USER_ENV_PREFIX, 'bin/jupyter'), os.path.join(USER_ENV_PREFIX, 'bin/jupyter'),
'labextension', 'labextension',
'install' 'install'

36
tljh/utils.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Miscelaneous functions useful in at least two places unrelated to each other
"""
import subprocess
import logging
# Copied into bootstrap/bootstrap.py. Make sure these two copies are exactly the same!
def run_subprocess(cmd, *args, **kwargs):
"""
Run given cmd with smart output behavior.
If command succeeds, print output to debug logging.
If it fails, print output to info logging.
In TLJH, this sends successful output to the installer log,
and failed output directly to the user's screen
"""
logger = logging.getLogger('tljh')
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, *args, **kwargs)
printable_command = ' '.join(cmd)
if proc.returncode != 0:
# Our process failed! Show output to the user
logger.error('Ran {command} with exit code {code}'.format(
command=printable_command, code=proc.returncode
))
logger.error(proc.stdout.decode())
raise subprocess.CalledProcessError(cmd=cmd, returncode=proc.returncode)
else:
# This goes into installer.log
logger.debug('Ran {command} with exit code {code}'.format(
command=printable_command, code=proc.returncode
))
# This produces multi line log output, unfortunately. Not sure how to fix.
# For now, prioritizing human readability over machine readability.
logger.debug(proc.stdout.decode())