Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Tania Allard
2019-10-19 14:59:17 +01:00
84 changed files with 2083 additions and 130 deletions

View File

@@ -29,6 +29,10 @@ def run_systemd_image(image_name, container_name):
'--mount', 'type=bind,source=/sys/fs/cgroup,target=/sys/fs/cgroup',
'--detach',
'--name', container_name,
# This is the minimum VM size we support. JupyterLab extensions seem
# to need at least this much RAM to build. Boo?
# If we change this, need to change all other references to this number.
'--memory', '768M',
image_name
])

3
.gitignore vendored
View File

@@ -102,3 +102,6 @@ venv.bak/
# mypy
.mypy_cache/
# OS X stuff
.DS_Store

View File

@@ -25,25 +25,27 @@ more information.
Development Status
==================
This project is currently in **alpha** state. Most things work, but we might
still make breaking changes that have no clear upgrade pathway. We are targeting
a v0.1 release sometime in mid-August 2018. Follow `this milestone <https://github.com/jupyterhub/the-littlest-jupyterhub/milestone/1>`_
to see progress towards the release!
This project is currently in **beta** state. Folks have been using installations
of TLJH for more than a year now to great success. While we try hard not to, we
might still make breaking changes that have no clear upgrade pathway.
Installation
============
The Littlest JupyterHub (TLJH) can run on any server that is running at least
Ubuntu 18.04. We have a bunch of tutorials to get you started!
**Ubuntu 18.04**. Earlier versions of Ubuntu are not supported.
We have a bunch of tutorials to get you started.
- Tutorials to create a new server from scratch on a cloud provider & run TLJH
on it. These are **recommended** if you do not have much experience setting up
servers.
- `Digital Ocean <https://the-littlest-jupyterhub.readthedocs.io/en/latest/install/digitalocean.html>`_
- `OVH <https://the-littlest-jupyterhub.readthedocs.io/en/latest/install/ovh.html>`_
- `Google Cloud <https://the-littlest-jupyterhub.readthedocs.io/en/latest/install/google.html>`_
- `Jetstream <https://the-littlest-jupyterhub.readthedocs.io/en/latest/install/jetstream.html>`_
- `Amazon Web Services <https://the-littlest-jupyterhub.readthedocs.io/en/latest/install/amazon.html>`_
- `Microsoft Azure <https://the-littlest-jupyterhub.readthedocs.io/en/latest/install/azure.html>`_
- ... your favorite provider here, if you can contribute!
- `Tutorial to install TLJH on an already running server you have root access to

View File

@@ -16,7 +16,9 @@ import os
import subprocess
import sys
import logging
import shutil
logger = logging.getLogger(__name__)
def get_os_release_variable(key):
"""
@@ -31,8 +33,40 @@ def get_os_release_variable(key):
"source /etc/os-release && echo ${{{key}}}".format(key=key)
]).decode().strip()
def main():
# 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():
"""
Make sure TLJH is installable in current host
"""
# Support only Ubuntu 18.04+
distro = get_os_release_variable('ID')
version = float(get_os_release_variable('VERSION_ID'))
@@ -43,21 +77,40 @@ def main():
print('The Littlest JupyterHub requires Ubuntu 18.04 or higher')
sys.exit(1)
if sys.version_info < (3, 5):
print("bootstrap.py must be run with at least Python 3.5")
sys.exit(1)
if not (shutil.which('systemd') and shutil.which('systemctl')):
print("Systemd is required to run TLJH")
# Only fail running inside docker if systemd isn't present
if os.path.exists('/.dockerenv'):
print("Running inside a docker container without systemd isn't supported")
print("We recommend against running a production TLJH instance inside a docker container")
print("For local development, see http://tljh.jupyter.org/en/latest/contributing/dev-setup.html")
sys.exit(1)
def main():
validate_host()
install_prefix = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh')
hub_prefix = os.path.join(install_prefix, 'hub')
# Set up logging to print to a file and to stderr
logger = logging.getLogger(__name__)
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.setLevel(logging.DEBUG)
logger.addHandler(file_logger)
stderr_logger = logging.StreamHandler()
stderr_logger.setFormatter(logging.Formatter('%(message)s'))
stderr_logger.setLevel(logging.INFO)
logger.addHandler(stderr_logger)
logger.setLevel(logging.INFO)
logger.setLevel(logging.DEBUG)
logger.info('Checking if TLJH is already installed...')
if os.path.exists(os.path.join(hub_prefix, 'bin', 'python3')):
@@ -66,11 +119,25 @@ def main():
else:
logger.info('Setting up hub environment')
initial_setup = True
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT)
subprocess.check_output(['apt-get', 'install', '--yes', 'python3', 'python3-venv', 'git'], stderr=subprocess.STDOUT)
# Install software-properties-common, so we can get add-apt-repository
# That helps us make sure the universe repository is enabled, since
# 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,
# causing bootstrapping to fail.
run_subprocess(['apt-get', 'update', '--yes'])
run_subprocess(['apt-get', 'install', '--yes', 'software-properties-common'])
run_subprocess(['add-apt-repository', 'universe'])
run_subprocess(['apt-get', 'update', '--yes'])
run_subprocess(['apt-get', 'install', '--yes',
'python3',
'python3-venv',
'python3-pip',
'git'
])
logger.info('Installed python & virtual environment')
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')
if initial_setup:
@@ -86,10 +153,10 @@ def main():
'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git'
)
subprocess.check_output([
run_subprocess([
os.path.join(hub_prefix, 'bin', 'pip'),
'install'
] + pip_flags + [tljh_repo_path], stderr=subprocess.STDOUT)
] + pip_flags + [tljh_repo_path])
logger.info('Setup tljh package')
logger.info('Starting TLJH installer...')

View File

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

BIN
docs/.DS_Store vendored

Binary file not shown.

View File

@@ -1,4 +1,4 @@
.. _contributing_dev_setup:
.. _contributing/dev-setup:
==================================
Setting up Development Environment

View File

@@ -48,11 +48,12 @@ documentation is transformed into HTML, PDF, and any other output format.
__ http://sphinx-doc.org/
__ http://docutils.sourceforge.net/
To build the documentation locally, install Sphinx:
To build the documentation locally, install the Sphinx dependencies:
.. code-block:: console
$ pip install Sphinx
$ cd docs/
$ pip install -r requirements.txt
Then from the ``docs`` directory, build the HTML:

View File

@@ -88,7 +88,7 @@ A hook implementation is a function that has the following characteristics:
``tljh.hooks``.
The current list of available hooks and when they are called can be
seen in ```tljh/hooks.py`` <https://github.com/jupyterhub/the-littlest-jupyterhub/blob/master/tljh/hooks.py>`_
seen in `tljh/hooks.py <https://github.com/jupyterhub/the-littlest-jupyterhub/blob/master/tljh/hooks.py>`_
in the source repository.

View File

@@ -5,31 +5,46 @@ Enable HTTPS
============
Every JupyterHub deployment should enable HTTPS!
HTTPS encrypts traffic so that usernames and passwords and other potentially sensitive bits of information are communicated securely.
The Littlest JupyterHub supports automatically configuring HTTPS via `Let's Encrypt <https://letsencrypt.org>`_,
or setting it up :ref:`manually <manual_https>` with your own TLS key and certificate.
If you don't know how to do that,
then :ref:`Let's Encrypt <letsencrypt>` is probably the right path for you.
HTTPS encrypts traffic so that usernames, passwords and your data are
communicated securely. sensitive bits of information are communicated
securely. The Littlest JupyterHub supports automatically configuring HTTPS
via `Let's Encrypt <https://letsencrypt.org>`_, or setting it up
:ref:`manually <howto/admin/https/manual>` with your own TLS key and
certificate. Unless you have a strong reason to use the manual method,
you should use the :ref:`Let's Encrypt <howto/admin/https/letsencrypt>`
method.
.. _letsencrypt:
.. note::
You *must* have a domain name set up to point to the IP address on
which TLJH is accessible before you can set up HTTPS.
.. _howto/admin/https/letsencrypt:
Automatic HTTPS with Let's Encrypt
==================================
.. note::
If the machine you are running on is not reachable from the internet -
for example, if it is a machine internal to your organization that
is cut off from the internet - you can not use this method. Please
set up a DNS entry and HTTPS :ref:`manually <howto/admin/https/manual>`.
To enable HTTPS via letsencrypt::
sudo tljh-config set https.enabled true
sudo tljh-config set https.letsencrypt.email you@example.com
sudo tljh-config add-item https.letsencrypt.domains yourhub.yourdomain.edu
where ``you@example.com`` is your email address and ``yourhub.yourdomain.edu`` is the domain where your hub will be running.
where ``you@example.com`` is your email address and ``yourhub.yourdomain.edu``
is the domain where your hub will be running.
Once you have loaded this, your config should look like::
sudo tljh-config show
.. sourcecode:: yaml
https:
@@ -43,10 +58,15 @@ Finally, you can reload the proxy to load the new configuration::
sudo tljh-config reload proxy
At this point, the proxy should negotiate with Let's Encrypt to set up a trusted HTTPS certificate for you.
It may take a moment for the proxy to negotiate with Let's Encrypt to get your certificates, after which you can access your Hub securely at https://yourhub.yourdomain.edu.
At this point, the proxy should negotiate with Let's Encrypt to set up a
trusted HTTPS certificate for you. It may take a moment for the proxy to
negotiate with Let's Encrypt to get your certificates, after which you can
access your Hub securely at https://yourhub.yourdomain.edu.
.. _manual_https:
These certificates are valid for 3 months. The proxy will automatically
renew them for you before they expire.
.. _howto/admin/https/manual:
Manual HTTPS with existing key and certificate
==============================================
@@ -77,3 +97,9 @@ Finally, you can reload the proxy to load the new configuration::
sudo tljh-config reload proxy
and now access your Hub securely at https://yourhub.yourdomain.edu.
Troubleshooting
===============
If you're having trouble with HTTPS, looking at the :ref:`traefik
proxy logs <troubleshooting/logs/traefik>` might help.

View File

@@ -12,7 +12,8 @@ Memory
======
Memory is usually the biggest determinant of server size in most JupyterHub
installations.
installations. At minimum, your server must have at least **768MB** of RAM
for TLJH to install.
.. math::

View File

@@ -75,8 +75,8 @@ For more information on ``tljh-config``, see :ref:`topic/tljh-config`.
sudo tljh-config reload
Confirm that the new authentactor works
=======================================
Confirm that the new authenticator works
========================================
#. **Open an incognito window** in your browser (do not log out until you confirm
that the new authentication method works!)

119
docs/howto/auth/google.rst Normal file
View File

@@ -0,0 +1,119 @@
.. _howto/auth/google:
=========================
Authenticate using Google
=========================
The **Google Authenticator** lets users log into your JupyterHub using their
Google user ID / password. To do so, you'll first need to register an
application with Google, and then provide information about this
application to your ``tljh`` configuration.
See `Google's documentation <https://developers.google.com/identity/protocols/OAuth2>`_
on how to create OAUth 2.0 client credentials.
.. note::
You'll need a Google account in order to complete these steps.
Step 1: Create a Google project
===============================
Go to `Google Developers Console <https://console.developers.google.com>`_
and create a new project:
.. image:: ../../images/auth/google/create_new_project.png
:alt: Create a Google project
Step 2: Set up a Google OAuth client ID and secret
==================================================
1. After creating and selecting the project:
* Go to the credentials menu:
.. image:: ../../images/auth/google/credentials_button.png
:alt: Credentials menu
* Click "Create credentials" and from the dropdown menu select **"OAuth client ID"**:
.. image:: ../../images/auth/google/create_credentials.png
:alt: Generate credentials
* You will have to fill a form with:
* **Application type**: Choose *Web application*
* **Name**: A descriptive name for your OAuth client ID (e.g. ``tljh-client``)
* **Authorized JavaScript origins**: Use the IP address or URL of your JupyterHub. e.g. ``http(s)://<my-tljh-url>``.
* **Authorized redirect URIs**: Insert text with the following form::
http(s)://<my-tljh-ip-address>/hub/oauth_callback
* When you're done filling in the page, it should look something like this (ideally without the red warnings):
.. image:: ../../images/auth/google/create_oauth_client_id.png
:alt: Create a Google OAuth client ID
2. Click "Create". You'll be taken to a page with the registered application details.
3. Copy the **Client ID** and **Client Secret** from the application details
page. You will use these later to configure your JupyterHub authenticator.
.. image:: ../../images/auth/google/client_id_secret.png
:alt: Your client ID and secret
.. important::
If you are using a virtual machine from a cloud provider and
**stop the VM**, then when you re-start the VM, the provider will likely assign a **new public
IP address** to it. In this case, **you must update your Google application information**
with the new IP address.
Configure your JupyterHub to use the Google Oauthenticator
==========================================================
We'll use the ``tljh-config`` tool to configure your JupyterHub's authentication.
For more information on ``tljh-config``, see :ref:`topic/tljh-config`.
#. Log in as an administrator account to your JupyterHub.
#. Open a terminal window.
.. image:: ../../images/notebook/new-terminal-button.png
:alt: New terminal button.
#. Configure the Google OAuthenticator to use your client ID, client secret and callback URL with the following commands::
sudo tljh-config set auth.GoogleOAuthenticator.client_id '<my-tljh-client-id>'
::
sudo tljh-config set auth.GoogleOAuthenticator.client_secret '<my-tljh-client-secret>'
::
sudo tljh-config set auth.GoogleOAuthenticator.oauth_callback_url 'http(s)://<my-tljh-ip-address>/hub/oauth_callback'
#. Tell your JupyterHub to *use* the Google OAuthenticator for authentication::
sudo tljh-config set auth.type oauthenticator.google.GoogleOAuthenticator
#. Restart your JupyterHub so that new users see these changes::
sudo tljh-config reload
Confirm that the new authenticator works
========================================
#. **Open an incognito window** in your browser (do not log out until you confirm
that the new authentication method works!)
#. Go to your JupyterHub URL.
#. You should see a Google login button like below:
.. image:: ../../images/auth/google/login_button.png
:alt: The Google authenticator login button.
#. After you log in with your Google credentials, you should be directed to the
Jupyter interface used in this JupyterHub.
#. **If this does not work** you can revert back to the default
JupyterHub authenticator by following the steps in :ref:`howto/auth/firstuse`.

View File

@@ -67,7 +67,7 @@ follow these steps:
.. code-block:: bash
sudo ln -s /src/data/my_shared_data_folder my_shared_data_folder
sudo ln -s /srv/data/my_shared_data_folder my_shared_data_folder
#. **Confirm that this worked** by logging in as a new user. You can do this
by opening a new "incognito" browser window and accessing your JupyterHub.

View File

@@ -0,0 +1,28 @@
.. _howto/providers/azure:
==================================================
Perform common Microsoft Azure configuration tasks
==================================================
This page lists various common tasks you can perform on your
Microsoft Azure virtual machine.
.. _howto/providers/azure/resize:
Deleting or stopping your virtual machine
===========================================
After you have finished using your TLJH you might wanto to either Stop or completely delete the Virtual Machine to avoid incurring in subsequent costs.
The difference between these two approaches is that **Stop** will keep the VM resources but will effectively stop any compute / runtime activities.
If you choose to delete the VM then all the resources associated with it will be wiped out.
To do either of this:
* Go to "Virtual Machines"
* Click on your machine name
* Click on "Stop" to stop the machine temporarily, or "Delete" to delete it permanently.
.. image:: ../../images/providers/azure/delete-vm.png
:alt: Delete vm
.. note:: It is important to mention that even if you stop the machine you will still be charged for the use of the data disk.

BIN
docs/images/.DS_Store vendored

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -30,9 +30,11 @@ We have a bunch of tutorials to get you started.
:caption: Installation
install/digitalocean
install/ovh
install/jetstream
install/google
install/amazon
install/azure
install/custom-server
Once you are ready to run your server for real,
@@ -78,6 +80,7 @@ with your JupyterHub. For more information on Authentication, see
howto/auth/dummy
howto/auth/github
howto/auth/google
howto/auth/firstuse
howto/auth/nativeauth
@@ -103,6 +106,7 @@ Cloud provider configuration
:caption: Cloud provider configuration
howto/providers/digitalocean
howto/providers/azure
Topic Guides
============
@@ -121,6 +125,7 @@ Topic guides provide in-depth explanations of specific topics.
topic/tljh-config
topic/authenticator-configuration
topic/escape-hatch
topic/idle-culler
Troubleshooting

View File

@@ -78,7 +78,8 @@ Let's create the server on which we can run JupyterHub.
`Next: Configure Instance Details` in the lower right corner.
Check out our guide on How To :ref:`howto/admin/resource-estimation` to help pick
how much Memory / CPU your server needs.
how much Memory / CPU your server needs. You need to have at least **768MB** of
RAM.
You may wish to consult the listing `here <https://www.ec2instances.info/>`_
because it shows cost per hour. The **On Demand** price is the pertinent cost.
@@ -138,7 +139,7 @@ Let's create the server on which we can run JupyterHub.
#. Under **Step 6: Configure Security Group**, you'll set the firewall rules
that control the traffic for your instance. Specifically you'll want to add
rules can add rules to allow both **HTTP Traffic** and **HTTPS Traffic**. For
rules to allow both **HTTP Traffic** and **HTTPS Traffic**. For
advanced troubleshooting, it will be helpful to set rules so you can use
SSH to connect (port 22).
@@ -181,7 +182,7 @@ Let's create the server on which we can run JupyterHub.
triggered, you need to choose what to do about an identifying key pair and
acknowledge your choice in order to proceed. If you already have a key pair you
can select to associate it with this instance, otherwise you need to
**Create a new key pair**. Choosing to `Proceed with a key pair` is not
**Create a new key pair**. Choosing to `Proceed without a key pair` is not
recommended as you'll have no way to access your server via SSH if anything
goes wrong with the Jupyterhub and have no way to recover files via download.

175
docs/install/azure.rst Normal file
View File

@@ -0,0 +1,175 @@
.. _install/azure:
====================
Installing on Azure
====================
Goal
====
By the end of this tutorial, you should have a JupyterHub with some admin
users and a user environment with packages you want to be installed running on
`Microsoft Azure <https://azure.microsoft.com>`_.
Prerequisites
==============
* A Microsoft Azure account.
* To get started you can get a free account which includes 150 dollars worth of Azure credits (`get a free account here <https://azure.microsoft.com/en-us/free//?wt.mc_id=TLJH-github-taallard>`_)
These instructions cover how to set up a Virtual Machine
on Microsoft Azure. For subsequent information about creating
your JupyterHub and configuring it, see `The Littlest JupyterHub guide <https://the-littlest-jupyterhub.readthedocs.io/en/latest/>`_.
Step 1: Installing The Littlest JupyterHub
==========================================
We will start by creating the Virtual Machine in which we can run TLJH (The Littlest JupyterHub).
#. Go to `Azure portal <https://portal.azure.com/>`_ and login with your Azure account.
#. Expand the left-hand panel, find the Virtual Machines tab and click on it.
.. image:: ../images/providers/azure/azure-vms.png
:alt: Virtual machines on Azure portal
#. Click **+ add** to create a new Virtual Machine
.. image:: ../images/providers/azure/add-vm.png
:alt: Add a new virtual machine
#. Select **Create VM from Marketplace** in the next sreen. This will display a new screen with all the optiond for Virtual Machines in Azure.
.. image:: ../images/providers/azure/create-vm.png
:alt: Create VM from the marketplace
#. **Choose an Ubuntu server for your VM**:
* Click `Ubuntu Server 18.04 LTS`
* Make sure `Resource Manager` is selected in the next screen and click **Create**
.. image:: ../images/providers/azure/ubuntu-vm.png
:alt: Ubuntu VM
#. Customise the Virtual Machine basics:
* **Subscription**. Choose the "Free Trial" if this is what you're using. Otherwise, choose a different plan. This is the billing account that will be charged.
* **Resource group**. Resource groups let you bundle components that you request from Azure. If you already have one you'd like to use it select that resource.
* **Name**. Use a descriptive name for your virtual machine (note that you cannot use spaces or special characters).
* **Region**. Choose a location near where you expect your users to be located.
* **Availability options**. Choose "No infrastructure redundancy required".
* **Image**. Make sure "Ubuntu Server 18.04 LTS" is selected (from the previous step).
* **Authentication type**. Change authentication type to "password".
* **Username**. Choose a memorable username, this will be your "root" user and you'll need it later on.
* **Password**. Type in a password, this will be used later for admin access so make sure it is something memorable.
.. image:: ../images/providers/azure/password-vm.png
:alt: Add password to VM
* **Login with Azure Active Directory**. Choose "Off" (usually the default)
* **Inbound port rules**. Leave the defaults for now and we will update these later on in the Network configuration step.
#. Before clicking on "Next" we need to select the RAM size for the image.
* For this we need to make sure we have enough RAM to accommodate your users. For example, if each user needs 2GB of RAM, and you have 10 total users, you need at least 20GB of RAM on the machine. It's also good to have a few GB of "buffer" RAM beyond what you think you'll need.
* Click on **Change size** (see image below)
.. image:: ../images/providers/azure/size-vm.png
:alt: Choose vm size
.. note:: For more information about estimating memory, CPU and disk needs check `The memory section in the TLJH documentation <https://tljh.jupyter.org/en/latest/howto/admin/resource-estimation.html>`_
* Select a suitable image (to check available images and prices in your region `click on this link <https://azuremarketplace.microsoft.com/en-gb/marketplace/apps/Canonical.UbuntuServer?tab=PlansAndPrice/?wt.mc_id=TLJH-github-taallard>`_.
#. Disks (Storage):
* **Disk options**: slect the OS disk type there are options for SDD and HDD. **SSD persistent disk** gives you a faster but more expensive disk than HDD.
* **Data disk**. Click on create and attach a new disk. Select an appropriate type and size and click ok.
* Click "Next"
.. image:: ../images/providers/azure/disk-vm.png
:alt: Choose disk size
#. Networking
* **Virtual network**. Leave the default values selected.
* **Subnet**. Leave the default values selected.
* **Public IP address**.Leave the default values selected. This will make your server accessible from a browser.
* **Network Security Group**. Choose "Basic"
* **Public inbound ports**. Check **HTTP**, **HTTPS**, and **SSH**.
.. image:: ../images/providers/azure/networking-vm.png
:alt: Choose networking ports
#. Management
* Monitoring
* **Boot diagnostics**. Choose "On".
* **OS guest diagnostics**. Choose "Off".
* **Diagnostics storage account**. Leave as the default.
* Auto-Shutdown
* **Enable auto-shutdown**. Choose "Off".
* Backup
* **Backup**. Choose "Off".
* System assigned managed identity Select "Off"
.. image:: ../images/providers/azure/backup-vm.png
:alt: Choose VM Backup
#. Advanced settings
* **Extensions**. Make sure there are no extensions listed
* **Cloud init**. We are going to use this section to install TLJH directly into our Virtual Machine.
Copy the code snippet below:
.. code:: bash
#!/bin/bash
curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \
| sudo python3 - \
--admin <admin-user-name>
where the ``username`` is the root username you chose for your Virtual Machine.
.. image:: ../images/providers/azure/cloudinit-vm.png
:alt: Install TLJH
.. note::
See :ref:`topic/installer-actions` if you want to understand exactly what the installer is doing.
:ref:`topic/customizing-installer` documents other options that can be passed to the installer.
#. Check the summary and confirm the creation of your Virtual Machine.
#. Check that the creation of your Virtual Machine worked
* Wait for the virtual machine to be created. This might take about 5-10 minutes.
* After completion, you should see a similar screen to the one below:
.. image:: ../images/providers/azure/deployed-vm.png
:alt: Deployed VM
#. Note that the Littlest JupyterHub should be installing in the background on your new server.
It takes around 5-10 minutes for this installation to complete.
#. Click on the **Go to resource button**
.. image:: ../images/providers/azure/goto-vm.png
:alt: Go to VM
#. Check if the installation is complete by **copying** the **Public IP address** of your virtual machine, and trying to access it with a browser.
.. image:: ../images/providers/azure/ip-vm.png
:alt: Public IP address
Note that accessing the JupyterHub will fail until the installation is complete, so be patient.
#. When the installation is complete, it should give you a JupyterHub login page.
.. image:: ../images/first-login.png
:alt: JupyterHub log-in page
#. Login using the **admin user name** you used in step 6, and a password. Use a strong password & note it down somewhere, since this will be the password for the admin user account from now on.
#. Congratulations, you have a running working JupyterHub! 🎉
Step 2: Adding more users
==========================
.. include:: add_users.txt
Step 3: Install conda / pip packages for all users
==================================================
.. include:: add_packages.txt

View File

@@ -4,11 +4,21 @@
Installing on your own server
=============================
Follow this guide if your cloud provider doesn't have a direct tutorial, or
you are setting this up on a bare metal server.
.. warning::
Do **not** install TLJH directly on your laptop or personal computer!
It will most likely open up exploitable security holes when run directly
on your personal computer.
.. note::
You should use this if your cloud provider does not already have a direct tutorial,
or if you have experience setting up servers.
Running TLJH *inside* a docker container is not supported, since we depend
on systemd. If you want to run TLJH locally for development, see
:ref:`contributing/dev-setup`.
Goal
====
@@ -22,6 +32,7 @@ Pre-requisites
#. Some familiarity with the command line.
#. A server running Ubuntu 18.04 where you have root access.
#. At least **768MB** of RAM on your server.
#. Ability to ``ssh`` into the server & run commands from the prompt.
#. A **IP address** where the server can be reached from the browsers of your target audience.
@@ -34,15 +45,15 @@ Step 1: Installing The Littlest JupyterHub
#. Using a terminal program, SSH into your server. This should give you a prompt where you can
type commands.
#. Make sure you have ``Python3``, ``curl`` and ``git`` installed. On latest Ubuntu you can get all of these with:
#. Make sure you have ``python3``, ``curl`` and ``git`` installed.
.. code::
apt-get install python3 git curl
sudo apt install python3 git curl
#. Copy the text below, and paste it into the terminal. Replace
``<admin-user-name>`` with the name of the first **admin user** for this
JupyterHub. Choose any name you like (don't forget to replace the brackets!).
JupyterHub. Choose any name you like (don't forget to remove the brackets!).
This admin user can log in after the JupyterHub is set up, and
can configure it to their needs. **Remember to add your username**!

View File

@@ -69,7 +69,7 @@ Let's create the server on which we can run JupyterHub.
#. For **Zone**, pick any of the options. Leaving the default as is is fine.
#. Under **Machine** type, select the amount of CPU / RAM / GPU you want for your
server.
server. You need at least **768MB** of RAM.
You can select a preset combination in the default **basic view**.

View File

@@ -53,6 +53,7 @@ Let's create the server on which we can run JupyterHub.
#. Give your server a descriptive **Instance Name**.
#. Select an appropriate **Instance Size**. We suggest m1.medium or larger.
Make sure your instance has at least **768MB** of RAM.
Check out our guide on How To :ref:`howto/admin/resource-estimation` to help pick
how much Memory, CPU & disk space your server needs.

127
docs/install/ovh.rst Normal file
View File

@@ -0,0 +1,127 @@
.. _install/ovh:
=================
Installing on OVH
=================
Goal
====
By the end of this tutorial, you should have a JupyterHub with some admin
users and a user environment with packages you want installed running on
`OVH <https://www.ovh.com>`_.
Pre-requisites
==============
#. An OVH account.
Step 1: Installing The Littlest JupyterHub
==========================================
Let's create the server on which we can run JupyterHub.
#. Log in to the `OVH Control Panel <https://www.ovh.com/auth/>`_.
#. Click the **Public Cloud** button in the navigation bar.
.. image:: ../images/providers/ovh/public-cloud.png
:alt: Public Cloud entry in the navigation bar
#. If you don't have an OVH Stack, you can create one by clicking on the following button:
.. image:: ../images/providers/ovh/create-ovh-stack.png
:alt: Button to create an OVH stack
#. Select a name for the project:
.. image:: ../images/providers/ovh/project-name.png
:alt: Select a name for the project
#. If you don't have a payment method yet, select one and click on "Create my project":
.. image:: ../images/providers/ovh/payment.png
:alt: Select a payment method
#. Using the **Public Cloud interface**, click on **Create an instance**:
.. image:: ../images/providers/ovh/create-instance.png
:alt: Create a new instance
#. **Select a model** for the instance. A good start is the **S1-4** model under **Shared resources** which comes with 4GB RAM, 1 vCores and 20GB SSD.
#. **Select a region**.
#. Select **Ubuntu 18.04** as the image:
.. image:: ../images/providers/ovh/distribution.png
:alt: Select Ubuntu 18.04 as the image
#. OVH requires setting an SSH key to be able to connect to the instance.
You can create a new SSH by following
`these instructions <https://help.github.com/en/enterprise/2.16/user/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent>`_.
Be sure to copy the content of the ``~/.ssh/id_rsa.pub`` file, which corresponds to the **public part** of the SSH key.
#. Select **Configure your instance**, and select a name for the instance.
Under **Post-installation script**, copy the text below and paste it in the text box.
Replace ``<admin-user-name>`` with the name of the first **admin user** for this
JupyterHub. This admin user can log in after the JupyterHub is set up, and
can configure it to their needs. **Remember to add your username**!
.. code-block:: bash
#!/bin/bash
curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \
| sudo python3 - \
--admin <admin-user-name>
.. note::
See :ref:`topic/installer-actions` if you want to understand exactly what the installer is doing.
:ref:`topic/customizing-installer` documents other options that can be passed to the installer.
.. image:: ../images/providers/ovh/configuration.png
:alt: Add post-installation script
#. Select a billing period: monthly or hourly.
#. Click the **Create an instance** button! You will be taken to a different screen,
where you can see progress of your server being created.
.. image:: ../images/providers/ovh/create-instance.png
:alt: Select suitable hostname for your server
#. In a few seconds your server will be created, and you can see the **public IP**
used to access it.
.. image:: ../images/providers/ovh/public-ip.png
:alt: Server finished creating, public IP available
#. The Littlest JupyterHub is now installing in the background on your new server.
It takes around 5-10 minutes for this installation to complete.
#. Check if the installation is complete by copying the **public ip**
of your server, and trying to access it with a browser. This will fail until
the installation is complete, so be patient.
#. When the installation is complete, it should give you a JupyterHub login page.
.. image:: ../images/first-login.png
:alt: JupyterHub log-in page
#. Login using the **admin user name** you used in step 6, and a password. Use a
strong password & note it down somewhere, since this will be the password for
the admin user account from now on.
#. Congratulations, you have a running working JupyterHub!
Step 2: Adding more users
==========================
.. include:: add_users.txt
Step 3: Install conda / pip packages for all users
==================================================
.. include:: add_packages.txt

View File

@@ -16,6 +16,8 @@ can be used with TLJH. A number of them ship by default with TLJH:
available.
#. `FirstUseAuthenticator <https://github.com/yuvipanda/jupyterhub-firstuseauthenticator>`_ - Users set
their password when they log in for the first time. Default authenticator used in TLJH.
#. `TmpAuthenticator <https://github.com/jupyterhub/tmpauthenticator>`_ - Opens the JupyterHub to the
world, makes a new user every time someone logs in.
#. `NativeAuthenticator <https://native-authenticator.readthedocs.io/en/latest/>`_ - Allow users to signup, add password security verification and block users after failed attempts oflogin.
We try to have specific how-to guides & tutorials for common authenticators. Since we can not cover
@@ -48,7 +50,7 @@ to some value, you can do that with the following command:
.. code-block:: bash
sudo tljh-config set auth.LDAPAuthenticator.server_address = 'my-ldap-server'
sudo tljh-config set auth.LDAPAuthenticator.server_address 'my-ldap-server'
Most authenticators require you set multiple configuration options before you can
enable them. Read the authenticator's documentation carefully for more information.

View File

@@ -63,7 +63,7 @@ Installing TLJH plugins
The Littlest JupyterHub can install additional *plugins* that provide additional
features. They are most commonly used to install a particular *stack* - such as
the `PANGEO Stack <https://github.com/yuvipanda/tljh-pangeo>`_ for earth sciences
research, a stack for a praticular class, etc.
research, a stack for a particular class, etc.
``--plugin <plugin-to-install>`` installs and activates a plugin. You can pass it
however many times you want. Since plugins are distributed as python packages,

114
docs/topic/idle-culler.rst Normal file
View File

@@ -0,0 +1,114 @@
.. _topic/idle-culler:
=============================
Culling idle notebook servers
=============================
The idle culler automatically shuts down user notebook servers when they have
not been used for a certain time period, in order to reduce the total resource
usage on your JupyterHub.
JupyterHub pings the user's notebook server at certain time intervals. If no response
is received from the server during this checks and the timeout expires, the server is
considered to be *inactive (idle)* and will be culled.
Default settings
================
By default, JupyterHub will ping the user notebook servers every 60s to check their
status. Every server found to be idle for more than 10 minutes will be culled.
.. code-block:: python
services.cull.every = 60
services.cull.timeout = 600
Because the servers don't have a maximum age set, an active server will not be shut down
regardless of how long it has been up and running.
.. code-block:: python
services.cull.max_age = 0
If after the culling process, there are users with no active notebook servers, by default,
the users will not be culled alongside their notebooks and will continue to exist.
.. code-block:: python
services.cull.users = False
Configuring the idle culler
===========================
The available configuration options are:
Idle timeout
------------
The idle timeout is the maximum time (in seconds) a server can be inactive before it
will be culled. The timeout can be configured using:
.. code-block:: bash
sudo tljh-config set services.cull.timeout <max-idle-sec-before-server-is-culled>
sudo tljh-config reload
Idle check interval
-------------------
The idle check interval represents how frequent (in seconds) the Hub will
check if there are any idle servers to cull. It can be configured using:
.. code-block:: bash
sudo tljh-config set services.cull.every <number-of-sec-this-check-is-done>
sudo tljh-config reload
Maximum age
-----------
The maximum age sets the time (in seconds) a server should be running.
The servers that exceed the maximum age, will be culled even if they are active.
A maximum age of 0, will deactivate this option.
The maximum age can be configured using:
.. code-block:: bash
sudo tljh-config set services.cull.max_age <server-max-age>
sudo tljh-config reload
User culling
------------
In addition to servers, it is also possible to cull the users. This is usually
suited for temporary-user cases such as *tmpnb*.
User culling can be activated using the following command:
.. code-block:: bash
sudo tljh-config set services.cull.users True
sudo tljh-config reload
Concurrency
-----------
Deleting a lot of users at the same time can slow down the Hub.
The number of concurrent requests made to the Hub can be configured using:
.. code-block:: bash
sudo tljh-config set services.cull.concurrency <number-of-concurrent-hub-requests>
sudo tljh-config reload
Because TLJH it's used for a small number of users, the cases that may require to
modify the concurrency limit should be rare.
Disabling the idle culler
=========================
The idle culling service is enabled by default. To disable it, use the following
command:
.. code-block:: bash
sudo tljh-config set services.cull.enabled False
sudo tljh-config reload

View File

@@ -51,7 +51,7 @@ By default, ``sudo`` does not respect any custom environments you have activated
``tljh-config`` symlink
========================
We create a symlink from ``/usr/bin/tljh-config`` to ``/opt/tljh/hub/bin/tljh-cohnfig``, so users
We create a symlink from ``/usr/bin/tljh-config`` to ``/opt/tljh/hub/bin/tljh-config``, so users
can run ``sudo tljh-config <something>`` from their terminal. While the user environment is added
to users' ``$PATH`` when they launch through JupyterHub, the hub environment is not. This makes it
hard to access the ``tljh-config`` command used to change most config parameters. Hence we symlink the

View File

@@ -2,9 +2,9 @@
Security Considerations
=======================
The Littlest JupyterHub is in pre-alpha state & should not be used in
security critical situations. We will try to keep things as secure as possible,
but sometimes trade security for massive gains in convenience. This page contains
The Littlest JupyterHub is in beta state & should not be used in security
critical situations. We will try to keep things as secure as possible, but
sometimes trade security for massive gains in convenience. This page contains
information about the security model of The Littlest JupyterHub.
System user accounts

View File

@@ -22,8 +22,8 @@ You can run ``tljh-config`` in two ways:
.. _tljh-set:
Set a configuration property
============================
Set / Unset a configuration property
====================================
TLJH's configuration is organized in a nested tree structure. You can
set a particular property with the following command:
@@ -50,6 +50,17 @@ do so with the following:
This can only set string and numerical properties, not lists.
To unset a configuration property you can use the following command:
.. code-block:: bash
sudo tljh-config unset <property-path>
Unsetting a configuration property removes the property from the configuration
file. If what you want is only to change the property's value, you should use
``set`` and overwrite it with the desired value.
Some of the existing ``<property-path>`` are listed below by categories:
@@ -61,6 +72,23 @@ Authentication
Use ``auth.type`` to determine authenticator to use. All parameters
in the config under ``auth.{auth.type}`` will be passed straight to the
authenticators themselves.
.. _tljh-set-ports:
Ports
-----
Use ``http.port`` and ``https.port`` to set the ports that TLJH will listen on,
which are 80 and 443 by default. However, if you change these, note that
TLJH does a lot of other things to the system (with user accounts and sudo
rules primarily) that might break security assumptions your other
applications have, so use with extreme caution.
.. code-block:: bash
sudo tljh-config set http.port 8080
sudo tljh-config set https.port 8443
sudo tljh-config reload proxy
.. _tljh-set-user-lists:
@@ -121,6 +149,34 @@ User Environment
sudo tljh-config set user_environment.default_app jupyterlab
.. _tljh-set-extra-user-groups:
Extra User Groups
=================
``users.extra_user_groups`` is a configuration option that can be used
to automatically add a user to a specific group. By default, there are
no extra groups defined.
Users can be "paired" with the desired, **existing** groups using:
* ``tljh-config set``, if only one user is to be added to the
desired group:
.. code-block:: bash
tljh-config set users.extra_user_groups.group1 user1
* ``tljh-config add-item``, if multiple users are to be added to
the group:
.. code-block:: bash
tljh-config add-item users.extra_user_groups.group1 user1
tljh-config add-item users.extra_user_groups.group1 user2
.. _tljh-view-conf:
View current configuration
@@ -157,6 +213,6 @@ Advanced: ``config.yaml``
=========================
``tljh-config`` is a simple program that modifies the contents of the
``config.yaml`` file located at ``/opt/tljh/config.yaml``. ``tljh-config``
``config.yaml`` file located at ``/opt/tljh/config/config.yaml``. ``tljh-config``
is the recommended method of editing / viewing configuration since editing
YAML by hand in a terminal text editor is a large source of errors.

Binary file not shown.

View File

@@ -44,18 +44,20 @@ logs is a great first step.
This command displays logs from JupyterHub itself. See :ref:`journalctl_tips`
for tips on navigating the logs.
Configurable HTTP Proxy Logs
============================
.. _troubleshooting/logs/traefik:
Configurable HTTP Proxy redirects traffic to JupyterHub / user notebook servers
as necessary & handles HTTPS. It usually is the least problematic of the components,
but things do go wrong sometimes!
Traefik Proxy Logs
==================
`traefik <https://traefik.io/>`_ redirects traffic to JupyterHub / user notebook servers
as necessary & handles HTTPS. Look at this if all you can see in your browser
is one line cryptic error messages, or if you are having trouble with HTTPS.
.. code-block:: bash
sudo journalctl -u configurable-http-proxy
sudo journalctl -u traefik
This command displays logs from Configurable HTTP Proxy. See :ref:`journalctl_tips`
This command displays logs from Traefik. See :ref:`journalctl_tips`
for tips on navigating the logs.
User Server Logs

View File

@@ -17,6 +17,11 @@ def tljh_extra_user_pip_packages():
'django',
]
@hookimpl
def tljh_extra_hub_pip_packages():
return [
'there',
]
@hookimpl
def tljh_extra_apt_packages():
@@ -30,4 +35,14 @@ def tljh_config_post_install(config):
# Put an arbitrary marker we can test for
config['simplest_plugin'] = {
'present': True
}
}
@hookimpl
def tljh_custom_jupyterhub_config(c):
c.JupyterHub.authenticator_class = 'tmpauthenticator.TmpAuthenticator'
@hookimpl
def tljh_post_install():
with open('test_post_install', 'w') as f:
f.write('123456789')

View File

@@ -2,14 +2,9 @@
Test running bootstrap script in different circumstances
"""
import subprocess
from textwrap import dedent
def test_ubuntu_too_old():
"""
Error with a useful message when running in older Ubuntu
"""
container_name = 'old-distro-test'
def run_bootstrap(container_name, image):
# stop container if it is already running
subprocess.run([
'docker', 'rm', '-f', container_name
@@ -17,7 +12,7 @@ def test_ubuntu_too_old():
# Start a detached Ubuntu 16.04 container
subprocess.check_call([
'docker', 'run', '--detach', '--name', container_name, 'ubuntu:16.04',
'docker', 'run', '--detach', '--name', container_name, image,
'/bin/bash', '-c', 'sleep 1000s'
])
# Install python3 inside the ubuntu container
@@ -35,10 +30,27 @@ def test_ubuntu_too_old():
'bootstrap/', f'{container_name}:/srv'
])
# Run bootstrap script, validate that it fails appropriately
output = subprocess.run([
# Run bootstrap script, return the output
return subprocess.run([
'docker', 'exec', '-i', container_name,
'python3', '/srv/bootstrap/bootstrap.py'
], check=False, stdout=subprocess.PIPE, encoding='utf-8')
def test_ubuntu_too_old():
"""
Error with a useful message when running in older Ubuntu
"""
output = run_bootstrap('old-distro-test', 'ubuntu:16.04')
assert output.stdout == 'The Littlest JupyterHub requires Ubuntu 18.04 or higher\n'
assert output.returncode == 1
assert output.returncode == 1
def test_inside_no_systemd_docker():
output = run_bootstrap('plain-docker-test', 'ubuntu:18.04')
assert output.stdout.strip() == dedent("""
Systemd is required to run TLJH
Running inside a docker container without systemd isn't supported
We recommend against running a production TLJH instance inside a docker container
For local development, see http://tljh.jupyter.org/en/latest/contributing/dev-setup.html
""").strip()
assert output.returncode == 1

View File

@@ -1,6 +1,7 @@
import requests
from hubtraf.user import User
from hubtraf.auth.dummy import login_dummy
from jupyterhub.utils import exponential_backoff
import secrets
import pytest
from functools import partial
@@ -9,6 +10,7 @@ import pwd
import grp
import sys
import subprocess
from os import system
from tljh.normalize import generate_system_username
@@ -137,4 +139,141 @@ async def test_long_username():
'-u', 'jupyterhub',
'--no-pager'
])
raise
raise
@pytest.mark.asyncio
async def test_user_group_adding():
"""
User logs in, and we check if they are added to the specified group.
"""
# This *must* be localhost, not an IP
# aiohttp throws away cookies if we are connecting to an IP!
hub_url = 'http://localhost'
username = secrets.token_hex(8)
groups = {"somegroup": [username]}
# Create the group we want to add the user to
system('groupadd somegroup')
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.extra_user_groups.somegroup', username)).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
try:
async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login()
await u.ensure_server()
# Assert that the user exists
system_username = generate_system_username(f'jupyter-{username}')
assert pwd.getpwnam(system_username) is not None
# Assert that the user was added to the specified group
assert f'jupyter-{username}' in grp.getgrnam('somegroup').gr_mem
await u.stop_server()
# Delete the group
system('groupdel somegroup')
except:
# If we have any errors, print jupyterhub logs before exiting
subprocess.check_call([
'journalctl',
'-u', 'jupyterhub',
'--no-pager'
])
raise
@pytest.mark.asyncio
async def test_idle_server_culled():
"""
User logs in, starts a server & stays idle for 1 min.
(the user's server should be culled during this period)
"""
# This *must* be localhost, not an IP
# aiohttp throws away cookies if we are connecting to an IP!
hub_url = 'http://localhost'
username = secrets.token_hex(8)
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait()
# Check every 10s for idle servers to cull
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'services.cull.every', "10")).wait()
# Apart from servers, also cull users
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'services.cull.users', "True")).wait()
# Cull servers and users after 60s of activity
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'services.cull.max_age', "60")).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login()
# Start user's server
await u.ensure_server()
# Assert that the user exists
assert pwd.getpwnam(f'jupyter-{username}') is not None
# Check that we can get to the user's server
r = await u.session.get(u.hub_url / 'hub/api/users' / username,
headers={'Referer': str(u.hub_url / 'hub/')})
assert r.status == 200
async def _check_culling_done():
# Check that after 60s, the user and server have been culled and are not reacheable anymore
r = await u.session.get(u.hub_url / 'hub/api/users' / username,
headers={'Referer': str(u.hub_url / 'hub/')})
print(r.status)
return r.status == 403
await exponential_backoff(
_check_culling_done,
"Server culling failed!",
timeout=100,
)
@pytest.mark.asyncio
async def test_active_server_not_culled():
"""
User logs in, starts a server & stays idle for 30s
(the user's server should not be culled during this period).
"""
# This *must* be localhost, not an IP
# aiohttp throws away cookies if we are connecting to an IP!
hub_url = 'http://localhost'
username = secrets.token_hex(8)
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait()
# Check every 10s for idle servers to cull
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'services.cull.every', "10")).wait()
# Apart from servers, also cull users
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'services.cull.users', "True")).wait()
# Cull servers and users after 60s of activity
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'services.cull.max_age', "60")).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login()
# Start user's server
await u.ensure_server()
# Assert that the user exists
assert pwd.getpwnam(f'jupyter-{username}') is not None
# Check that we can get to the user's server
r = await u.session.get(u.hub_url / 'hub/api/users' / username,
headers={'Referer': str(u.hub_url / 'hub/')})
assert r.status == 200
async def _check_culling_done():
# Check that after 30s, we can still reach the user's server
r = await u.session.get(u.hub_url / 'hub/api/users' / username,
headers={'Referer': str(u.hub_url / 'hub/')})
print(r.status)
return r.status != 200
try:
await exponential_backoff(
_check_culling_done,
"User's server is still reacheable!",
timeout=30,
)
except TimeoutError:
# During the 30s timeout the user's server wasn't culled, which is what we intended.
pass

View File

@@ -117,6 +117,12 @@ def test_admin_writable():
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])
def test_user_env_readable(group):
# every file in user env should be readable by everyone

View File

@@ -2,9 +2,10 @@
Test simplest plugin
"""
from ruamel.yaml import YAML
import requests
import os
import subprocess
from tljh.config import CONFIG_FILE, USER_ENV_PREFIX
from tljh.config import CONFIG_FILE, USER_ENV_PREFIX, HUB_ENV_PREFIX
yaml = YAML(typ='rt')
@@ -18,7 +19,7 @@ def test_apt_packages():
def test_pip_packages():
"""
Test extra user pip packages are installed
Test extra user & hub pip packages are installed
"""
subprocess.check_call([
f'{USER_ENV_PREFIX}/bin/python3',
@@ -26,6 +27,12 @@ def test_pip_packages():
'import django'
])
subprocess.check_call([
f'{HUB_ENV_PREFIX}/bin/python3',
'-c',
'import there'
])
def test_conda_packages():
"""
@@ -46,3 +53,22 @@ def test_config_hook():
data = yaml.load(f)
assert data['simplest_plugin']['present']
def test_jupyterhub_config_hook():
"""
Test that tmpauthenticator is enabled by our custom config plugin
"""
resp = requests.get('http://localhost/hub/tmplogin', allow_redirects=False)
assert resp.status_code == 302
assert resp.headers['Location'] == '/hub/spawn'
def test_post_install_hook():
"""
Test that the test_post_install file has the correct content
"""
with open("test_post_install") as f:
content = f.read()
assert content == "123456789"

View File

@@ -15,6 +15,8 @@ setup(
'jinja2',
'pluggy>0.7<1.0',
'passlib',
'backoff',
'requests',
'jupyterhub-traefik-proxy==0.1.*'
],
entry_points={

248
template Normal file
View File

@@ -0,0 +1,248 @@
{
"$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"type": "string"
},
"networkInterfaceName": {
"type": "string"
},
"networkSecurityGroupName": {
"type": "string"
},
"networkSecurityGroupRules": {
"type": "array"
},
"subnetName": {
"type": "string"
},
"virtualNetworkName": {
"type": "string"
},
"addressPrefixes": {
"type": "array"
},
"subnets": {
"type": "array"
},
"publicIpAddressName": {
"type": "string"
},
"publicIpAddressType": {
"type": "string"
},
"publicIpAddressSku": {
"type": "string"
},
"virtualMachineName": {
"type": "string"
},
"virtualMachineRG": {
"type": "string"
},
"osDiskType": {
"type": "string"
},
"dataDisks": {
"type": "array"
},
"dataDiskResources": {
"type": "array"
},
"virtualMachineSize": {
"type": "string"
},
"adminUsername": {
"type": "string"
},
"adminPassword": {
"type": "secureString"
},
"customData": {
"type": "string"
},
"diagnosticsStorageAccountName": {
"type": "string"
},
"diagnosticsStorageAccountId": {
"type": "string"
},
"diagnosticsStorageAccountType": {
"type": "string"
},
"diagnosticsStorageAccountKind": {
"type": "string"
}
},
"variables": {
"nsgId": "[resourceId(resourceGroup().name, 'Microsoft.Network/networkSecurityGroups', parameters('networkSecurityGroupName'))]",
"vnetId": "[resourceId(resourceGroup().name,'Microsoft.Network/virtualNetworks', parameters('virtualNetworkName'))]",
"subnetRef": "[concat(variables('vnetId'), '/subnets/', parameters('subnetName'))]"
},
"resources": [
{
"name": "[parameters('networkInterfaceName')]",
"type": "Microsoft.Network/networkInterfaces",
"apiVersion": "2018-10-01",
"location": "[parameters('location')]",
"dependsOn": [
"[concat('Microsoft.Network/networkSecurityGroups/', parameters('networkSecurityGroupName'))]",
"[concat('Microsoft.Network/virtualNetworks/', parameters('virtualNetworkName'))]",
"[concat('Microsoft.Network/publicIpAddresses/', parameters('publicIpAddressName'))]"
],
"properties": {
"ipConfigurations": [
{
"name": "ipconfig1",
"properties": {
"subnet": {
"id": "[variables('subnetRef')]"
},
"privateIPAllocationMethod": "Dynamic",
"publicIpAddress": {
"id": "[resourceId(resourceGroup().name, 'Microsoft.Network/publicIpAddresses', parameters('publicIpAddressName'))]"
}
}
}
],
"networkSecurityGroup": {
"id": "[variables('nsgId')]"
}
}
},
{
"name": "[parameters('networkSecurityGroupName')]",
"type": "Microsoft.Network/networkSecurityGroups",
"apiVersion": "2018-08-01",
"location": "[parameters('location')]",
"properties": {
"securityRules": "[parameters('networkSecurityGroupRules')]"
}
},
{
"name": "[parameters('virtualNetworkName')]",
"type": "Microsoft.Network/virtualNetworks",
"apiVersion": "2018-08-01",
"location": "[parameters('location')]",
"properties": {
"addressSpace": {
"addressPrefixes": "[parameters('addressPrefixes')]"
},
"subnets": "[parameters('subnets')]"
}
},
{
"name": "[parameters('publicIpAddressName')]",
"type": "Microsoft.Network/publicIpAddresses",
"apiVersion": "2018-08-01",
"location": "[parameters('location')]",
"properties": {
"publicIpAllocationMethod": "[parameters('publicIpAddressType')]"
},
"sku": {
"name": "[parameters('publicIpAddressSku')]"
}
},
{
"name": "[parameters('dataDiskResources')[copyIndex()].name]",
"type": "Microsoft.Compute/disks",
"apiVersion": "2018-06-01",
"location": "[parameters('location')]",
"properties": {
"diskSizeGB": "[parameters('dataDiskResources')[copyIndex()].diskSizeGB]",
"creationData": "[parameters('dataDiskResources')[copyIndex()].creationData]"
},
"sku": {
"name": "[parameters('dataDiskResources')[copyIndex()].sku]"
},
"copy": {
"name": "managedDiskResources",
"count": "[length(parameters('dataDiskResources'))]"
}
},
{
"name": "[parameters('virtualMachineName')]",
"type": "Microsoft.Compute/virtualMachines",
"apiVersion": "2018-10-01",
"location": "[parameters('location')]",
"dependsOn": [
"managedDiskResources",
"[concat('Microsoft.Network/networkInterfaces/', parameters('networkInterfaceName'))]",
"[concat('Microsoft.Storage/storageAccounts/', parameters('diagnosticsStorageAccountName'))]"
],
"properties": {
"hardwareProfile": {
"vmSize": "[parameters('virtualMachineSize')]"
},
"storageProfile": {
"osDisk": {
"createOption": "fromImage",
"managedDisk": {
"storageAccountType": "[parameters('osDiskType')]"
}
},
"imageReference": {
"publisher": "Canonical",
"offer": "UbuntuServer",
"sku": "18.04-LTS",
"version": "latest"
},
"copy": [
{
"name": "dataDisks",
"count": "[length(parameters('dataDisks'))]",
"input": {
"lun": "[parameters('dataDisks')[copyIndex('dataDisks')].lun]",
"createOption": "[parameters('dataDisks')[copyIndex('dataDisks')].createOption]",
"caching": "[parameters('dataDisks')[copyIndex('dataDisks')].caching]",
"writeAcceleratorEnabled": "[parameters('dataDisks')[copyIndex('dataDisks')].writeAcceleratorEnabled]",
"diskSizeGB": "[parameters('dataDisks')[copyIndex('dataDisks')].diskSizeGB]",
"managedDisk": {
"id": "[coalesce(parameters('dataDisks')[copyIndex('dataDisks')].id, if(equals(parameters('dataDisks')[copyIndex('dataDisks')].name, json('null')), json('null'), resourceId('Microsoft.Compute/disks', parameters('dataDisks')[copyIndex('dataDisks')].name)))]",
"storageAccountType": "[parameters('dataDisks')[copyIndex('dataDisks')].storageAccountType]"
}
}
}
]
},
"networkProfile": {
"networkInterfaces": [
{
"id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('networkInterfaceName'))]"
}
]
},
"osProfile": {
"computerName": "[parameters('virtualMachineName')]",
"adminUsername": "[parameters('adminUsername')]",
"adminPassword": "[parameters('adminPassword')]",
"customData": "[parameters('customData')]"
},
"diagnosticsProfile": {
"bootDiagnostics": {
"enabled": true,
"storageUri": "[concat('https://', parameters('diagnosticsStorageAccountName'), '.blob.core.windows.net/')]"
}
}
}
},
{
"name": "[parameters('diagnosticsStorageAccountName')]",
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2018-07-01",
"location": "[parameters('location')]",
"properties": {},
"kind": "[parameters('diagnosticsStorageAccountKind')]",
"sku": {
"name": "[parameters('diagnosticsStorageAccountType')]"
}
}
],
"outputs": {
"adminUsername": {
"type": "string",
"value": "[parameters('adminUsername')]"
}
}
}

View File

@@ -18,6 +18,9 @@ def prefix():
with tempfile.TemporaryDirectory() as tmpdir:
with conda.download_miniconda_installer(miniconda_version, miniconda_installer_md5) as installer_path:
conda.install_miniconda(installer_path, tmpdir)
conda.ensure_conda_packages(tmpdir, [
'conda==4.5.8'
])
yield tmpdir

View File

@@ -58,6 +58,57 @@ def test_set_overwrite():
assert new_conf == {'a': 'hi'}
def test_unset_no_mutate():
conf = {'a': 'b'}
new_conf = config.unset_item_from_config(conf, 'a')
assert conf == {'a': 'b'}
def test_unset_one_level():
conf = {'a': 'b'}
new_conf = config.unset_item_from_config(conf, 'a')
assert new_conf == {}
def test_unset_multi_level():
conf = {
'a': {'b': 'c', 'd': 'e'},
'f': 'g'
}
new_conf = config.unset_item_from_config(conf, 'a.b')
assert new_conf == {
'a': {'d': 'e'},
'f': 'g'
}
new_conf = config.unset_item_from_config(new_conf, 'a.d')
assert new_conf == {'f': 'g'}
new_conf = config.unset_item_from_config(new_conf, 'f')
assert new_conf == {}
def test_unset_and_clean_empty_configs():
conf = {
'a': {'b': {'c': {'d': {'e': 'f'}}}}
}
new_conf = config.unset_item_from_config(conf, 'a.b.c.d.e')
assert new_conf == {}
def test_unset_config_error():
with pytest.raises(ValueError):
config.unset_item_from_config({}, 'a')
with pytest.raises(ValueError):
config.unset_item_from_config({'a': 'b'}, 'b')
with pytest.raises(ValueError):
config.unset_item_from_config({'a': {'b': 'c'}}, 'a.z')
def test_add_to_config_one_level():
conf = {}
@@ -161,6 +212,18 @@ def test_cli_set_int(tljh_dir):
assert cfg['https']['port'] == 123
def test_cli_unset(tljh_dir):
config.main(["set", "foo.bar", "1"])
config.main(["set", "foo.bar2", "2"])
cfg = configurer.load_config()
assert cfg['foo'] == {'bar': 1, 'bar2': 2}
config.main(["unset", "foo.bar"])
cfg = configurer.load_config()
assert cfg['foo'] == {'bar2': 2}
def test_cli_add_float(tljh_dir):
config.main(["add-item", "foo.bar", "1.25"])
cfg = configurer.load_config()

View File

@@ -3,6 +3,7 @@ Test configurer
"""
import os
import sys
from tljh import configurer
@@ -66,7 +67,7 @@ def test_default_memory_limit():
Test default per user memory limit
"""
c = apply_mock_config({})
assert c.SystemdSpawner.mem_limit is None
assert c.Spawner.mem_limit is None
def test_set_memory_limit():
@@ -74,7 +75,7 @@ def test_set_memory_limit():
Test setting per user memory limit
"""
c = apply_mock_config({'limits': {'memory': '42G'}})
assert c.SystemdSpawner.mem_limit == '42G'
assert c.Spawner.mem_limit == '42G'
def test_app_default():
@@ -128,6 +129,24 @@ def test_auth_dummy():
assert c.JupyterHub.authenticator_class == 'dummyauthenticator.DummyAuthenticator'
assert c.DummyAuthenticator.password == 'test'
from traitlets import Dict
def test_user_groups():
"""
Test setting user groups
"""
c = apply_mock_config({
'users': {
'extra_user_groups': {
"g1": ["u1", "u2"],
"g2": ["u3", "u4"]
},
}
})
assert c.UserCreatingSpawner.user_groups == {
"g1": ["u1", "u2"],
"g2": ["u3", "u4"]
}
def test_auth_firstuse():
"""
@@ -187,6 +206,49 @@ def test_set_traefik_api():
assert c.TraefikTomlProxy.traefik_api_password == '1234'
def test_cull_service_default():
"""
Test default cull service settings with no overrides
"""
c = apply_mock_config({})
cull_cmd = [
sys.executable, '-m', 'tljh.cull_idle_servers',
'--timeout=600', '--cull-every=60', '--concurrency=5',
'--max-age=0'
]
assert c.JupyterHub.services == [{
'name': 'cull-idle',
'admin': True,
'command': cull_cmd,
}]
def test_set_cull_service():
"""
Test setting cull service options
"""
c = apply_mock_config({
'services': {
'cull': {
'every': 10,
'users': True,
'max_age': 60
}
}
})
cull_cmd = [
sys.executable, '-m', 'tljh.cull_idle_servers',
'--timeout=600', '--cull-every=10', '--concurrency=5',
'--max-age=60', '--cull-users'
]
assert c.JupyterHub.services == [{
'name': 'cull-idle',
'admin': True,
'command': cull_cmd,
}]
def test_load_secrets(tljh_dir):
"""
Test loading secret files

View File

@@ -4,6 +4,7 @@ Unit test functions in installer.py
import os
from tljh import installer
from tljh.yaml import yaml
def test_ensure_node():
@@ -19,3 +20,16 @@ def test_ensure_config_yaml(tljh_dir):
assert os.path.isdir(os.path.join(installer.CONFIG_DIR, 'jupyterhub_config.d'))
# verify that old config doesn't exist
assert not os.path.exists(os.path.join(tljh_dir, 'config.yaml'))
def test_ensure_admins(tljh_dir):
# --admin option called multiple times on the installer
# creates a list of argument lists.
admins = [['a1'], ['a2'], ['a3']]
installer.ensure_admins(admins)
config_path = installer.CONFIG_FILE
with open(config_path, 'r') as f:
config = yaml.load(f)
# verify the list was flattened
assert config['users']['admin'] == ['a1', 'a2', 'a3']

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 subprocess
from tljh import utils
def trust_gpg_key(key):
@@ -14,7 +15,7 @@ def trust_gpg_key(key):
# If gpg2 doesn't exist, install it.
if not os.path.exists('/usr/bin/gpg2'):
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):
@@ -32,7 +33,7 @@ def add_source(name, source_url, section):
f.seek(0)
f.write(line)
f.truncate()
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT)
utils.run_subprocess(['apt-get', 'update', '--yes'])
def install_packages(packages):
@@ -41,9 +42,12 @@ def install_packages(packages):
"""
# Check if an apt-get update is required
if len(os.listdir('/var/lib/apt/lists')) == 0:
subprocess.check_output(['apt-get', 'update', '--yes'], stderr=subprocess.STDOUT)
subprocess.check_output([
utils.run_subprocess(['apt-get', 'update', '--yes'])
env = os.environ.copy()
# Stop apt from asking questions!
env['DEBIAN_FRONTEND'] = 'noninteractive'
utils.run_subprocess([
'apt-get',
'install',
'--yes'
] + packages, stderr=subprocess.STDOUT)
] + packages, env=env)

View File

@@ -7,8 +7,9 @@ import json
import hashlib
import contextlib
import tempfile
import urllib.request
import requests
from distutils.version import LooseVersion as V
from tljh import utils
def md5_file(fname):
@@ -50,7 +51,8 @@ def download_miniconda_installer(version, md5sum):
"""
with tempfile.NamedTemporaryFile() as f:
installer_url = "https://repo.continuum.io/miniconda/Miniconda3-{}-Linux-x86_64.sh".format(version)
urllib.request.urlretrieve(installer_url, f.name)
with open(f.name, 'wb') as f:
f.write(requests.get(installer_url).content)
if md5_file(f.name) != md5sum:
raise Exception('md5 hash mismatch! Downloaded file corrupted')
@@ -67,22 +69,22 @@ def fix_permissions(prefix):
Run after each install command.
"""
subprocess.check_call(
utils.run_subprocess(
["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):
"""
Install miniconda with installer at installer_path under prefix
"""
subprocess.check_output([
utils.run_subprocess([
'/bin/bash',
installer_path,
'-u', '-b',
'-p', prefix
], stderr=subprocess.STDOUT)
])
# fix permissions on initial install
# a few files have the wrong ownership and permissions initially
# when the installer is run as root
@@ -127,10 +129,10 @@ def ensure_pip_packages(prefix, packages):
abspath = os.path.abspath(prefix)
pip_executable = [os.path.join(abspath, 'bin', 'python'), '-m', 'pip']
subprocess.check_output(pip_executable + [
utils.run_subprocess(pip_executable + [
'install',
'--no-cache-dir',
] + packages, stderr=subprocess.STDOUT)
] + packages)
fix_permissions(prefix)
@@ -143,9 +145,9 @@ def ensure_pip_requirements(prefix, requirements_path):
abspath = os.path.abspath(prefix)
pip_executable = [os.path.join(abspath, 'bin', 'python'), '-m', 'pip']
subprocess.check_output(pip_executable + [
utils.run_subprocess(pip_executable + [
'install',
'-r',
requirements_path
], stderr=subprocess.STDOUT)
])
fix_permissions(prefix)

View File

@@ -14,7 +14,7 @@ tljh-config show firstlevel.second_level
import argparse
import asyncio
from collections import Sequence, Mapping
from collections.abc import Sequence, Mapping
from copy import deepcopy
import os
import re
@@ -61,6 +61,50 @@ def set_item_in_config(config, property_path, value):
return config_copy
def unset_item_from_config(config, property_path):
"""
Unset key at property_path in config & return new config.
config is not mutated.
property_path is a series of dot separated values.
"""
path_components = property_path.split('.')
# Mutate a copy of the config, not config itself
cur_part = config_copy = deepcopy(config)
def remove_empty_configs(configuration, path):
"""
Delete the keys that hold an empty dict.
This might happen when we delete a config property
that has no siblings from a multi-level config.
"""
if not path:
return configuration
conf_iter = configuration
for cur_path in path:
if conf_iter[cur_path] == {}:
del conf_iter[cur_path]
remove_empty_configs(configuration, path[:-1])
else:
conf_iter = conf_iter[cur_path]
for i, cur_path in enumerate(path_components):
if i == len(path_components) - 1:
if cur_path not in cur_part:
raise ValueError(f'{property_path} does not exist in config!')
del cur_part[cur_path]
remove_empty_configs(config_copy, path_components[:-1])
break
else:
if cur_path not in cur_part:
raise ValueError(f'{property_path} does not exist in config!')
cur_part = cur_part[cur_path]
return config_copy
def add_item_to_config(config, property_path, value):
"""
Add an item to a list in config.
@@ -97,7 +141,7 @@ def remove_item_from_config(config, property_path, value):
cur_part = config_copy = deepcopy(config)
for i, cur_path in enumerate(path_components):
if i == len(path_components) - 1:
# Final component, it must be a list and we append to it
# Final component, it must be a list and we delete from it
if cur_path not in cur_part or not _is_list(cur_part[cur_path]):
raise ValueError(f'{property_path} is not a list')
cur_part = cur_part[cur_path]
@@ -141,6 +185,24 @@ def set_config_value(config_path, key_path, value):
yaml.dump(config, f)
def unset_config_value(config_path, key_path):
"""
Unset key at key_path in config_path
"""
# FIXME: Have a file lock here
# FIXME: Validate schema here
try:
with open(config_path) as f:
config = yaml.load(f)
except FileNotFoundError:
config = {}
config = unset_item_from_config(config, key_path)
with open(config_path, 'w') as f:
yaml.dump(config, f)
def add_config_value(config_path, key_path, value):
"""
Add value to list at key_path
@@ -260,6 +322,15 @@ def main(argv=None):
help='Show current configuration'
)
unset_parser = subparsers.add_parser(
'unset',
help='Unset a configuration property'
)
unset_parser.add_argument(
'key_path',
help='Dot separated path to configuration key to unset'
)
set_parser = subparsers.add_parser(
'set',
help='Set a configuration property'
@@ -317,6 +388,8 @@ def main(argv=None):
show_config(args.config_path)
elif args.action == 'set':
set_config_value(args.config_path, args.key_path, parse_value(args.value))
elif args.action == 'unset':
unset_config_value(args.config_path, args.key_path)
elif args.action == 'add-item':
add_config_value(args.config_path, args.key_path, parse_value(args.value))
elif args.action == 'remove-item':

View File

@@ -9,6 +9,7 @@ FIXME: A strong feeling that JSON Schema should be involved somehow.
"""
import os
import sys
from .config import CONFIG_FILE, STATE_DIR
from .yaml import yaml
@@ -26,6 +27,7 @@ default = {
'allowed': [],
'banned': [],
'admin': [],
'extra_user_groups': {}
},
'limits': {
'memory': None,
@@ -55,6 +57,16 @@ default = {
'user_environment': {
'default_app': 'classic',
},
'services': {
'cull': {
'enabled': True,
'timeout': 600,
'every': 60,
'concurrency': 5,
'users': False,
'max_age': 0
}
}
}
def load_config(config_file=CONFIG_FILE):
@@ -82,10 +94,12 @@ def apply_config(config_overrides, c):
update_auth(c, tljh_config)
update_userlists(c, tljh_config)
update_usergroups(c, tljh_config)
update_limits(c, tljh_config)
update_user_environment(c, tljh_config)
update_user_account_config(c, tljh_config)
update_traefik_api(c, tljh_config)
update_services(c, tljh_config)
def set_if_not_none(parent, key, value):
@@ -156,14 +170,22 @@ def update_userlists(c, config):
c.Authenticator.admin_users = set(users['admin'])
def update_usergroups(c, config):
"""
Set user groups
"""
users = config['users']
c.UserCreatingSpawner.user_groups = users['extra_user_groups']
def update_limits(c, config):
"""
Set user server limits
"""
limits = config['limits']
c.SystemdSpawner.mem_limit = limits['memory']
c.SystemdSpawner.cpu_limit = limits['cpu']
c.Spawner.mem_limit = limits['memory']
c.Spawner.cpu_limit = limits['cpu']
def update_user_environment(c, config):
@@ -191,6 +213,38 @@ def update_traefik_api(c, config):
c.TraefikTomlProxy.traefik_api_password = config['traefik_api']['password']
def set_cull_idle_service(config):
"""
Set Idle Culler service
"""
cull_cmd = [
sys.executable, '-m', 'tljh.cull_idle_servers'
]
cull_config = config['services']['cull']
print()
cull_cmd += ['--timeout=%d' % cull_config['timeout']]
cull_cmd += ['--cull-every=%d' % cull_config['every']]
cull_cmd += ['--concurrency=%d' % cull_config['concurrency']]
cull_cmd += ['--max-age=%d' % cull_config['max_age']]
if cull_config['users']:
cull_cmd += ['--cull-users']
cull_service = {
'name': 'cull-idle',
'admin': True,
'command': cull_cmd,
}
return cull_service
def update_services(c, config):
c.JupyterHub.services = []
if config['services']['cull']['enabled']:
c.JupyterHub.services.append(set_cull_idle_service(config))
def _merge_dictionaries(a, b, path=None, update=True):
"""
Merge two dictionaries recursively.

342
tljh/cull_idle_servers.py Normal file
View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""script to monitor and cull idle single-user servers
Imported from https://github.com/jupyterhub/jupyterhub/blob/6b1046697/examples/cull-idle/cull_idle_servers.py
Caveats:
last_activity is not updated with high frequency,
so cull timeout should be greater than the sum of:
- single-user websocket ping interval (default: 30s)
- JupyterHub.last_activity_interval (default: 5 minutes)
You can run this as a service managed by JupyterHub with this in your config::
c.JupyterHub.services = [
{
'name': 'cull-idle',
'admin': True,
'command': 'python cull_idle_servers.py --timeout=3600'.split(),
}
]
Or run it manually by generating an API token and storing it in `JUPYTERHUB_API_TOKEN`:
export JUPYTERHUB_API_TOKEN=`jupyterhub token`
python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
"""
from datetime import datetime, timezone
from functools import partial
import json
import os
try:
from urllib.parse import quote
except ImportError:
from urllib import quote
import dateutil.parser
from tornado.gen import coroutine, multi
from tornado.locks import Semaphore
from tornado.log import app_log
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.options import define, options, parse_command_line
def parse_date(date_string):
"""Parse a timestamp
If it doesn't have a timezone, assume utc
Returned datetime object will always be timezone-aware
"""
dt = dateutil.parser.parse(date_string)
if not dt.tzinfo:
# assume naïve timestamps are UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt
def format_td(td):
"""
Nicely format a timedelta object
as HH:MM:SS
"""
if td is None:
return "unknown"
if isinstance(td, str):
return td
seconds = int(td.total_seconds())
h = seconds // 3600
seconds = seconds % 3600
m = seconds // 60
seconds = seconds % 60
return f"{h:02}:{m:02}:{seconds:02}"
@coroutine
def cull_idle(url, api_token, inactive_limit, cull_users=False, max_age=0, concurrency=10):
"""Shutdown idle single-user servers
If cull_users, inactive *users* will be deleted as well.
"""
auth_header = {
'Authorization': 'token %s' % api_token,
}
req = HTTPRequest(
url=url + '/users',
headers=auth_header,
)
now = datetime.now(timezone.utc)
client = AsyncHTTPClient()
if concurrency:
semaphore = Semaphore(concurrency)
@coroutine
def fetch(req):
"""client.fetch wrapped in a semaphore to limit concurrency"""
yield semaphore.acquire()
try:
return (yield client.fetch(req))
finally:
yield semaphore.release()
else:
fetch = client.fetch
resp = yield fetch(req)
users = json.loads(resp.body.decode('utf8', 'replace'))
futures = []
@coroutine
def handle_server(user, server_name, server):
"""Handle (maybe) culling a single server
Returns True if server is now stopped (user removable),
False otherwise.
"""
log_name = user['name']
if server_name:
log_name = '%s/%s' % (user['name'], server_name)
if server.get('pending'):
app_log.warning(
"Not culling server %s with pending %s",
log_name, server['pending'])
return False
if server.get('started'):
age = now - parse_date(server['started'])
else:
# started may be undefined on jupyterhub < 0.9
age = None
# check last activity
# last_activity can be None in 0.9
if server['last_activity']:
inactive = now - parse_date(server['last_activity'])
else:
# no activity yet, use start date
# last_activity may be None with jupyterhub 0.9,
# which introduces the 'started' field which is never None
# for running servers
inactive = age
should_cull = (inactive is not None and
inactive.total_seconds() >= inactive_limit)
if should_cull:
app_log.info(
"Culling server %s (inactive for %s)",
log_name, format_td(inactive))
if max_age and not should_cull:
# only check started if max_age is specified
# so that we can still be compatible with jupyterhub 0.8
# which doesn't define the 'started' field
if age is not None and age.total_seconds() >= max_age:
app_log.info(
"Culling server %s (age: %s, inactive for %s)",
log_name, format_td(age), format_td(inactive))
should_cull = True
if not should_cull:
app_log.debug(
"Not culling server %s (age: %s, inactive for %s)",
log_name, format_td(age), format_td(inactive))
return False
req = HTTPRequest(
url=url + '/users/%s/server' % quote(user['name']),
method='DELETE',
headers=auth_header,
)
resp = yield fetch(req)
if resp.code == 202:
app_log.warning(
"Server %s is slow to stop",
log_name,
)
# return False to prevent culling user with pending shutdowns
return False
return True
@coroutine
def handle_user(user):
"""Handle one user.
Create a list of their servers, and async exec them. Wait for
that to be done, and if all servers are stopped, possibly cull
the user.
"""
# shutdown servers first.
# Hub doesn't allow deleting users with running servers.
# named servers contain the 'servers' dict
if 'servers' in user:
servers = user['servers']
# Otherwise, server data is intermingled in with the user
# model
else:
servers = {}
if user['server']:
servers[''] = {
'started': user.get('started'),
'last_activity': user['last_activity'],
'pending': user['pending'],
'url': user['server'],
}
server_futures = [
handle_server(user, server_name, server)
for server_name, server in servers.items()
]
results = yield multi(server_futures)
if not cull_users:
return
# some servers are still running, cannot cull users
still_alive = len(results) - sum(results)
if still_alive:
app_log.debug(
"Not culling user %s with %i servers still alive",
user['name'], still_alive)
return False
should_cull = False
if user.get('created'):
age = now - parse_date(user['created'])
else:
# created may be undefined on jupyterhub < 0.9
age = None
# check last activity
# last_activity can be None in 0.9
if user['last_activity']:
inactive = now - parse_date(user['last_activity'])
else:
# no activity yet, use start date
# last_activity may be None with jupyterhub 0.9,
# which introduces the 'created' field which is never None
inactive = age
should_cull = (inactive is not None and
inactive.total_seconds() >= inactive_limit)
if should_cull:
app_log.info(
"Culling user %s (inactive for %s)",
user['name'], inactive)
if max_age and not should_cull:
# only check created if max_age is specified
# so that we can still be compatible with jupyterhub 0.8
# which doesn't define the 'started' field
if age is not None and age.total_seconds() >= max_age:
app_log.info(
"Culling user %s (age: %s, inactive for %s)",
user['name'], format_td(age), format_td(inactive))
should_cull = True
if not should_cull:
app_log.debug(
"Not culling user %s (created: %s, last active: %s)",
user['name'], format_td(age), format_td(inactive))
return False
req = HTTPRequest(
url=url + '/users/%s' % user['name'],
method='DELETE',
headers=auth_header,
)
yield fetch(req)
return True
for user in users:
futures.append((user['name'], handle_user(user)))
for (name, f) in futures:
try:
result = yield f
except Exception:
app_log.exception("Error processing %s", name)
else:
if result:
app_log.debug("Finished culling %s", name)
if __name__ == '__main__':
define(
'url',
default=os.environ.get('JUPYTERHUB_API_URL'),
help="The JupyterHub API URL",
)
define('timeout', type=int, default=600, help="The idle timeout (in seconds)")
define('cull_every', type=int, default=0,
help="The interval (in seconds) for checking for idle servers to cull")
define('max_age', type=int, default=0,
help="The maximum age (in seconds) of servers that should be culled even if they are active")
define('cull_users', type=bool, default=False,
help="""Cull users in addition to servers.
This is for use in temporary-user cases such as tmpnb.""",
)
define('concurrency', type=int, default=10,
help="""Limit the number of concurrent requests made to the Hub.
Deleting a lot of users at the same time can slow down the Hub,
so limit the number of API requests we have outstanding at any given time.
"""
)
parse_command_line()
if not options.cull_every:
options.cull_every = options.timeout // 2
api_token = os.environ['JUPYTERHUB_API_TOKEN']
try:
AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
except ImportError as e:
app_log.warning(
"Could not load pycurl: %s\n"
"pycurl is recommended if you have a large number of users.",
e)
loop = IOLoop.current()
cull = partial(
cull_idle,
url=options.url,
api_token=api_token,
inactive_limit=options.timeout,
cull_users=options.cull_users,
max_age=options.max_age,
concurrency=options.concurrency,
)
# schedule first cull immediately
# because PeriodicCallback doesn't start until the end of the first interval
loop.add_callback(cull)
# schedule periodic cull
pc = PeriodicCallback(cull, 1e3 * options.cull_every)
pc.start()
try:
loop.start()
except KeyboardInterrupt:
pass

View File

@@ -22,6 +22,12 @@ def tljh_extra_user_pip_packages():
"""
pass
@hookspec
def tljh_extra_hub_pip_packages():
"""
Return list of extra pip packages to install in the hub environment.
"""
pass
@hookspec
def tljh_extra_apt_packages():
@@ -32,6 +38,15 @@ def tljh_extra_apt_packages():
"""
pass
@hookspec
def tljh_custom_jupyterhub_config(c):
"""
Provide custom traitlet based config to JupyterHub.
Anything you can put in `jupyterhub_config.py` can
be here.
"""
pass
@hookspec
def tljh_config_post_install(config):
@@ -43,4 +58,14 @@ def tljh_config_post_install(config):
be the serialized contents of config, so try to not
overwrite anything the user might have explicitly set.
"""
pass
@hookspec
def tljh_post_install():
"""
Post install script to be executed after installation
and after all the other hooks.
This can be arbitrary Python code.
"""
pass

View File

@@ -8,10 +8,11 @@ import secrets
import subprocess
import sys
import time
from urllib.error import HTTPError
from urllib.request import urlopen, URLError
import warnings
import pluggy
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
from tljh import (
apt,
@@ -21,6 +22,7 @@ from tljh import (
systemd,
traefik,
user,
utils
)
from .config import (
CONFIG_DIR,
@@ -34,7 +36,6 @@ from .yaml import yaml
HERE = os.path.abspath(os.path.dirname(__file__))
logger = logging.getLogger("tljh")
def ensure_node():
@@ -171,7 +172,7 @@ def ensure_jupyterlab_extensions():
'@jupyterlab/hub-extension',
'@jupyter-widgets/jupyterlab-manager'
]
subprocess.check_output([
utils.run_subprocess([
os.path.join(USER_ENV_PREFIX, 'bin/jupyter'),
'labextension',
'install'
@@ -188,14 +189,27 @@ def ensure_jupyterhub_package(prefix):
hub environment be installed with pip prevents accidental mixing of python
and conda packages!
"""
# Install pycurl. JupyterHub prefers pycurl over SimpleHTTPClient automatically
# pycurl is generally more bugfree - see https://github.com/jupyterhub/the-littlest-jupyterhub/issues/289
# build-essential is also generally useful to everyone involved, and required for pycurl
apt.install_packages([
'libssl-dev',
'libcurl4-openssl-dev',
'build-essential'
])
conda.ensure_pip_packages(prefix, [
'jupyterhub==0.9.6',
'pycurl==7.43.*'
])
conda.ensure_pip_packages(prefix, [
'jupyterhub==1.0.0',
'jupyterhub-dummyauthenticator==0.3.1',
'jupyterhub-systemdspawner==0.13',
'jupyterhub-firstuseauthenticator==0.12',
'jupyterhub-nativeauthenticator==0.0.4',
'jupyterhub-ldapauthenticator==1.2.2',
'oauthenticator==0.8.1'
'jupyterhub-tmpauthenticator==0.6',
'oauthenticator==0.8.2',
])
traefik.ensure_traefik_binary(prefix)
@@ -230,11 +244,6 @@ def ensure_user_environment(user_requirements_txt_file):
with conda.download_miniconda_installer(miniconda_version, miniconda_installer_md5) as installer_path:
conda.install_miniconda(installer_path, USER_ENV_PREFIX)
# nbresuse needs psutil, which requires gcc
apt.install_packages([
'gcc'
])
conda.ensure_conda_packages(USER_ENV_PREFIX, [
# Conda's latest version is on conda much more so than on PyPI.
'conda==4.5.8'
@@ -242,7 +251,7 @@ def ensure_user_environment(user_requirements_txt_file):
conda.ensure_pip_packages(USER_ENV_PREFIX, [
# JupyterHub + notebook package are base requirements for user environment
'jupyterhub==0.9.6',
'jupyterhub==1.0.0',
'notebook==5.7.8',
# Install additional notebook frontends!
'jupyterlab==0.35.4',
@@ -254,7 +263,7 @@ def ensure_user_environment(user_requirements_txt_file):
# Most people consider ipywidgets to be part of the core notebook experience
'ipywidgets==7.4.2',
# Pin tornado
'tornado<6.0'
'tornado<6.0',
])
if user_requirements_txt_file:
@@ -277,7 +286,9 @@ def ensure_admins(admins):
config = {}
config['users'] = config.get('users', {})
config['users']['admin'] = list(admins)
# Flatten admin lists
config['users']['admin'] = [admin for admin_sublist in admins
for admin in admin_sublist]
with open(config_path, 'w+') as f:
yaml.dump(config, f)
@@ -293,20 +304,24 @@ def ensure_jupyterhub_running(times=20):
for i in range(times):
try:
logger.info('Waiting for JupyterHub to come up ({}/{} tries)'.format(i + 1, times))
urlopen('http://127.0.0.1')
# Because we don't care at this level that SSL is valid, we can suppress
# InsecureRequestWarning for this request.
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=InsecureRequestWarning)
requests.get('http://127.0.0.1', verify=False)
return
except HTTPError as h:
if h.code in [404, 502, 503]:
except requests.HTTPError as h:
if h.response.status_code in [404, 502, 503]:
# May be transient
time.sleep(1)
continue
# Everything else should immediately abort
raise
except URLError as e:
if isinstance(e.reason, ConnectionRefusedError):
except requests.ConnectionError:
# Hub isn't up yet, sleep & loop
time.sleep(1)
continue
except Exception:
# Everything else should immediately abort
raise
@@ -368,21 +383,32 @@ def run_plugin_actions(plugin_manager, plugins):
))
apt.install_packages(apt_packages)
# Install hub pip packages
hub_pip_packages = list(set(itertools.chain(*hook.tljh_extra_hub_pip_packages())))
if hub_pip_packages:
logger.info('Installing {} hub pip packages collected from plugins: {}'.format(
len(hub_pip_packages), ' '.join(hub_pip_packages)
))
conda.ensure_pip_packages(HUB_ENV_PREFIX, hub_pip_packages)
# Install conda packages
conda_packages = list(set(itertools.chain(*hook.tljh_extra_user_conda_packages())))
if conda_packages:
logger.info('Installing {} conda packages collected from plugins: {}'.format(
logger.info('Installing {} user conda packages collected from plugins: {}'.format(
len(conda_packages), ' '.join(conda_packages)
))
conda.ensure_conda_packages(USER_ENV_PREFIX, conda_packages)
# Install pip packages
pip_packages = list(set(itertools.chain(*hook.tljh_extra_user_pip_packages())))
if pip_packages:
logger.info('Installing {} pip packages collected from plugins: {}'.format(
len(pip_packages), ' '.join(pip_packages)
user_pip_packages = list(set(itertools.chain(*hook.tljh_extra_user_pip_packages())))
if user_pip_packages:
logger.info('Installing {} user pip packages collected from plugins: {}'.format(
len(user_pip_packages), ' '.join(user_pip_packages)
))
conda.ensure_pip_packages(USER_ENV_PREFIX, pip_packages)
conda.ensure_pip_packages(USER_ENV_PREFIX, user_pip_packages)
# Custom post install actions
hook.tljh_post_install()
def ensure_config_yaml(plugin_manager):
@@ -416,6 +442,7 @@ def main():
argparser.add_argument(
'--admin',
nargs='*',
action='append',
help='List of usernames set to be admin'
)
argparser.add_argument(

View File

@@ -4,20 +4,25 @@ JupyterHub config for the littlest jupyterhub.
from glob import glob
import os
import pluggy
from systemdspawner import SystemdSpawner
from tljh import configurer, user
from tljh import configurer, user, hooks
from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR
from tljh.normalize import generate_system_username
from tljh.yaml import yaml
from jupyterhub_traefik_proxy import TraefikTomlProxy
from traitlets import Dict, Unicode, List
class UserCreatingSpawner(SystemdSpawner):
"""
SystemdSpawner with user creation on spawn.
FIXME: Remove this somehow?
"""
user_groups = Dict(key_trait=Unicode(), value_trait=List(Unicode()), config=True)
def start(self):
"""
Perform system user activities before starting server
@@ -33,6 +38,10 @@ class UserCreatingSpawner(SystemdSpawner):
user.ensure_user_group(system_username, 'jupyterhub-admins')
else:
user.remove_user_group(system_username, 'jupyterhub-admins')
if self.user_groups:
for group, users in self.user_groups.items():
if self.user.name in users:
user.ensure_user_group(system_username, group)
return super().start()
c.JupyterHub.spawner_class = UserCreatingSpawner
@@ -57,6 +66,15 @@ c.SystemdSpawner.unit_name_template = 'jupyter-{USERNAME}'
tljh_config = configurer.load_config()
configurer.apply_config(tljh_config, c)
# Let TLJH hooks modify `c` if they want
# Set up plugin infrastructure
pm = pluggy.PluginManager('tljh')
pm.add_hookspecs(hooks)
pm.load_setuptools_entrypoints('tljh')
# Call our custom configuration plugin
pm.hook.tljh_custom_jupyterhub_config(c=c)
# Load arbitrary .py config files if they exist.
# This is our escape hatch
extra_configs = sorted(glob(os.path.join(CONFIG_DIR, 'jupyterhub_config.d', '*.py')))

View File

@@ -8,8 +8,6 @@ After=traefik.service
[Service]
User=root
Restart=always
# jupyterhub process should have no access to home directories
ProtectHome=tmpfs
WorkingDirectory={install_prefix}/state
# Protect bits that are normally shared across the system
PrivateTmp=yes
@@ -17,7 +15,9 @@ PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
Environment=TLJH_INSTALL_PREFIX={install_prefix}
ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path}
# Run upgrade-db before starting, in case Hub version has changed
# This is a no-op when no db exists or no upgrades are needed
ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} --upgrade-db
[Install]
# Start service when system boots

View File

@@ -7,7 +7,8 @@ After=network.target
[Service]
User=root
Restart=always
ProtectHome=tmpfs
# traefik process should have no access to home directories
ProtectHome=yes
ProtectSystem=strict
PrivateTmp=yes
PrivateDevices=yes

View File

@@ -1,20 +1,21 @@
"""Traefik installation and setup"""
import hashlib
import os
from urllib.request import urlretrieve
from jinja2 import Template
from passlib.apache import HtpasswdFile
import backoff
import requests
from tljh.configurer import load_config
# FIXME: support more than one platform here
plat = "linux-amd64"
traefik_version = "1.7.5"
traefik_version = "1.7.18"
# record sha256 hashes for supported platforms here
checksums = {
"linux-amd64": "4417a9d83753e1ad6bdd64bbbeaeb4b279bcc71542e779b7bcb3b027c6e3356e"
"linux-amd64": "3c2d153d80890b6fc8875af9f8ced32c4d684e1eb5a46d9815337cb343dfd92e"
}
@@ -26,7 +27,16 @@ def checksum_file(path):
hasher.update(chunk)
return hasher.hexdigest()
def fatal_error(e):
# Retry only when connection is reset or we think we didn't download entire file
return str(e) != "ContentTooShort" and not isinstance(e, ConnectionResetError)
@backoff.on_exception(
backoff.expo,
Exception,
max_tries=2,
giveup=fatal_error
)
def ensure_traefik_binary(prefix):
"""Download and install the traefik binary"""
traefik_bin = os.path.join(prefix, "bin", "traefik")
@@ -47,7 +57,11 @@ def ensure_traefik_binary(prefix):
)
print(f"Downloading traefik {traefik_version}...")
# download the file
urlretrieve(traefik_url, traefik_bin)
response = requests.get(traefik_url)
if response.status_code == 206:
raise Exception("ContentTooShort")
with open(traefik_bin, 'wb') as f:
f.write(response.content)
os.chmod(traefik_bin, 0o755)
# verify that we got what we expected

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())