From a6373200ec974e5240d6ff08fae1deea3940666e Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Tue, 31 Jul 2018 13:16:57 -0700 Subject: [PATCH 1/7] Symlink tljh-config to /usr/local/bin This lets `sudo -E` work when calling tljh-config. It currently does not, since we do not add the hub venv to PATH --- tljh/installer.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tljh/installer.py b/tljh/installer.py index a137fa6..c8a99c3 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -275,6 +275,22 @@ def ensure_jupyterhub_running(times=4): raise Exception("Installation failed: JupyterHub did not start in {}s".format(times)) +def ensure_symlinks(prefix): + """ + Ensure we symlink appropriate things into /usr/local/bin + + We add the user conda environment to PATH for notebook terminals, + but not the hub venv. This means tljh-config is not actually accessible. + + We symlink to /usr/local/bin to 'fix' this. /usr/local/bin is the appropriate + place, and works with sudo -E + """ + tljh_config_src = os.path.join(prefix, 'bin', 'tljh-config') + tljh_config_dest = '/usr/local/bin/tljh-config' + if not os.is_symlink(tljh_config_dest): + os.symlink(tljh_config_src, tljh_config_dest) + + def main(): argparser = argparse.ArgumentParser() argparser.add_argument( @@ -302,6 +318,7 @@ def main(): ensure_chp_package(HUB_ENV_PREFIX) ensure_jupyterhub_service(HUB_ENV_PREFIX) ensure_jupyterhub_running() + ensure_symlinks(HUB_ENV_PREFIX) logger.info("Done!") From 816e9a01a15c9dda9dab8188c09bbaf4efb29c81 Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Tue, 31 Jul 2018 13:19:45 -0700 Subject: [PATCH 2/7] Test symlinking tljh-config --- integration-tests/test_install.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 975955e..2c4ac70 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -204,3 +204,9 @@ def test_pip_upgrade(group, allowed): [python, "-m", "pip", "install", "--upgrade", "testpath"], preexec_fn=partial(setgroup, group), ) + +def test_symlinks(): + """ + Test we symlink tljh-config to /usr/local/bin + """ + assert os.path.exists('/usr/local/bin/tljh-config') \ No newline at end of file From 639465202597eeade7b6f8ed232ae5e56f4559c0 Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Tue, 31 Jul 2018 13:23:43 -0700 Subject: [PATCH 3/7] Fix ensure_symlinks to use method that actually exists --- tljh/installer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tljh/installer.py b/tljh/installer.py index c8a99c3..cb86f62 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -287,7 +287,9 @@ def ensure_symlinks(prefix): """ tljh_config_src = os.path.join(prefix, 'bin', 'tljh-config') tljh_config_dest = '/usr/local/bin/tljh-config' - if not os.is_symlink(tljh_config_dest): + if not os.path.exists(tljh_config_dest): + # If this exists, we leave it alone. Do *not* remove it, + # since we are running as root and it could be anything! os.symlink(tljh_config_src, tljh_config_dest) From 48441e2d22ee8346bdd62355f3a2b80081d6e572 Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Tue, 31 Jul 2018 13:31:12 -0700 Subject: [PATCH 4/7] Use sudo -E to invoke tljh-config in integration tests This is how users invoke it, so we should use this to validate that this works. --- integration-tests/test_hub.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 12efad9..a70028a 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -10,6 +10,10 @@ import grp import sys +# Use sudo to invoke it, since this is how users invoke it. +# This catches issues with PATH +TLJH_CONFIG_PATH = ['sudo', '-E', 'tljh-config'] + def test_hub_up(): r = requests.get('http://127.0.0.1') r.raise_for_status() @@ -45,10 +49,9 @@ async def test_user_admin_add(): hub_url = 'http://localhost' username = secrets.token_hex(8) - tljh_config_path = [sys.executable, '-m', 'tljh.config'] - assert 0 == await (await asyncio.create_subprocess_exec(*tljh_config_path, 'add-item', 'users.admin', username)).wait() - assert 0 == await (await asyncio.create_subprocess_exec(*tljh_config_path, 'reload')).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() # FIXME: wait for reload to finish & hub to come up # Should be part of tljh-config reload @@ -76,10 +79,8 @@ async def test_user_admin_remove(): hub_url = 'http://localhost' username = secrets.token_hex(8) - tljh_config_path = [sys.executable, '-m', 'tljh.config'] - - assert 0 == await (await asyncio.create_subprocess_exec(*tljh_config_path, 'add-item', 'users.admin', username)).wait() - assert 0 == await (await asyncio.create_subprocess_exec(*tljh_config_path, 'reload')).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() # FIXME: wait for reload to finish & hub to come up # Should be part of tljh-config reload @@ -95,8 +96,8 @@ async def test_user_admin_remove(): assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem - assert 0 == await (await asyncio.create_subprocess_exec(*tljh_config_path, 'remove-item', 'users.admin', username)).wait() - assert 0 == await (await asyncio.create_subprocess_exec(*tljh_config_path, 'reload')).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'remove-item', 'users.admin', username)).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() await asyncio.sleep(1) await u.stop_server() From 912d5a5e1779ee92303c5b43c6144d57274ffb4e Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Tue, 31 Jul 2018 13:37:31 -0700 Subject: [PATCH 5/7] Install sudo in the integration-test container --- integration-tests/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile index 9a8cdd4..7fc244e 100644 --- a/integration-tests/Dockerfile +++ b/integration-tests/Dockerfile @@ -3,7 +3,7 @@ FROM ubuntu:18.04 RUN apt-get update --yes -RUN apt-get install --yes systemd curl git +RUN apt-get install --yes systemd curl git sudo # Kill all the things we don't need RUN find /etc/systemd/system \ From 381deaa39967afeeb19fc3cd4f9c50cc0c84ed4d Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Wed, 1 Aug 2018 18:05:34 -0700 Subject: [PATCH 6/7] Document all the actions the installer performs --- docs/index.rst | 1 + docs/topic/customizing-installer.rst | 2 + docs/topic/installer-actions.rst | 92 ++++++++++++++++++++++++++++ docs/tutorials/custom.rst | 11 +++- docs/tutorials/digitalocean.rst | 13 ++-- docs/tutorials/google.rst | 13 ++-- docs/tutorials/jetstream.rst | 13 ++-- 7 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 docs/topic/installer-actions.rst diff --git a/docs/index.rst b/docs/index.rst index a8c887d..5c54c13 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -80,6 +80,7 @@ Topic guides provide in-depth explanations of specific topics. topic/requirements topic/security topic/customizing-installer + topic/installer-actions topic/tljh-config topic/authenticator-configuration diff --git a/docs/topic/customizing-installer.rst b/docs/topic/customizing-installer.rst index 7a68fe8..30f1f99 100644 --- a/docs/topic/customizing-installer.rst +++ b/docs/topic/customizing-installer.rst @@ -1,3 +1,5 @@ +.. _topic/customizing-installer: + ========================= Customizing the Installer ========================= diff --git a/docs/topic/installer-actions.rst b/docs/topic/installer-actions.rst new file mode 100644 index 0000000..dc50260 --- /dev/null +++ b/docs/topic/installer-actions.rst @@ -0,0 +1,92 @@ +.. _topic/installer-actions: + +=========================== +What does the installer do? +=========================== + +This document details what exactly the installer does to the machine it is +run on. + +``apt`` Packages installed +========================== + +The packages ``python3`` and ``python3-venv`` are installed from the apt repositories. +Since we need an recent & supported version of ``nodejs``, we install it from +`nodesource `_. + +Hub environment +=============== + +JupyterHub is run from a python3 virtual environment located in ``/opt/tljh/hub``. It +uses the system's installed python and is owned by root. It also contains a ``npm`` +install of `configurable-http-proxy `_ +and a binary install of `traefik `_. This virtual environment is +completely managed by TLJH. + +User environment +================ + +By default, a ``miniconda`` environment is installed in ``/opt/tljh/user``. This contains +the notebook interface used to launch all users, and the various packages available to all +users. The environment is owned by the ``root`` user. JupyterHub admins may use +to ``sudo -E conda install`` or ``sudo -E pip install`` packages into this environment. + +This conda environment is added to ``$PATH`` for all users started with JupyterHub. If you +are using ``ssh`` instead, you can activate this environment by running the following: + +.. code-block:: bash + + source /opt/tljh/user/bin/activate + +This should let you run various ``conda`` and ``pip`` commands. If you run into errors like +``Command 'conda' not found``, try prefixing your command with: + +.. code-block:: bash + + sudo PATH=${PATH} + +By default, ``sudo`` does not respect any custom environments you have activated. The ``PATH=${PATH}`` +'fixes' that. + +``tljh-config`` symlink +======================== + +We create a symlink from ``/usr/local/bin/tljh-config`` to ``/opt/tljh/hub/bin/tljh-cohnfig``, so users +can run ``sudo -E tljh-config `` 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 +``tljh-config`` command to ``/usr/local/bin``, so it is directly accessible with ``sudo -E tljh-config ``. + +Systemd Units +============= + +TLJH places 3 systemd units on your computer. They all start on system startup. + +#. ``jupyterhub.service`` - starts the JupyterHub service. +#. ``configurable-http-proxy.service`` - starts the nodejs based proxy that is used by JupyterHub. +#. ``traefik.service`` - starts traefik proxy that manages HTTPS + +In addition, each running Jupyter user gets their own systemd unit of the name ``jupyter-``. + +User groups +=========== + +TLJH creates two user groups when installed: + +#. ``jupyterhub-users`` contains all users managed by this JupyterHub +#. ``jupyterhub-admins`` contains all users with admin rights managed by this JupyterHub. + +When a new JupyterHub user logs in, a unix user is created for them. The unix user is always added +to the ``jupyterhub-users`` group. If the user is an admin, they are added to the ``jupyterhub-admins`` +group whenever they start / stop their notebook server. + +If you uninstall TLJH, you should probably remove all user accounts associated with both these +user groups, and then remove the groups themselves. You might have to archive or delete the home +directories of these users under ``/home/``. + +Passwordless ``sudo`` for JupyterHub admins +============================================ + +``/etc/sudoers.d/jupyterhub-admins`` is created to provide passwordless sudo for all JupyterHub +admins. We also set it up to inherit ``$PATH`` with ``sudo -E``, to more easily call ``conda``, +``pip``, etc. diff --git a/docs/tutorials/custom.rst b/docs/tutorials/custom.rst index 75ba1e4..e9405cb 100644 --- a/docs/tutorials/custom.rst +++ b/docs/tutorials/custom.rst @@ -32,11 +32,16 @@ Step 1: Installing The Littlest JupyterHub .. code-block:: bash - #!/bin/bash - curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ - | sudo python3 - \ + #!/bin/bash + curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ + | sudo python3 - \ --admin + .. 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. + #. Press ``Enter`` to start the installation process. This will take 5-10 minutes, and will say 'Done!' when the installation process is complete. diff --git a/docs/tutorials/digitalocean.rst b/docs/tutorials/digitalocean.rst index 0cac0e8..d625727 100644 --- a/docs/tutorials/digitalocean.rst +++ b/docs/tutorials/digitalocean.rst @@ -62,10 +62,15 @@ Let's create the server on which we can run JupyterHub. .. code-block:: bash - #!/bin/bash - curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ - | sudo python3 - \ - --admin + #!/bin/bash + curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ + | sudo python3 - \ + --admin + + .. 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. #. Under the **Finalize and create** section, enter a ``hostname`` that descriptively identifies this server for you. diff --git a/docs/tutorials/google.rst b/docs/tutorials/google.rst index e9fe1b1..e5c3dce 100644 --- a/docs/tutorials/google.rst +++ b/docs/tutorials/google.rst @@ -143,14 +143,19 @@ Let's create the server on which we can run JupyterHub. .. code-block:: bash - #!/bin/bash - curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ - | sudo python3 - \ - --admin + #!/bin/bash + curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ + | sudo python3 - \ + --admin .. image:: ../images/providers/google/startup-script.png :alt: Install JupyterHub with the Startup script textbox + .. 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. + #. Click the **Create** button at the bottom to start your server! .. image:: ../images/providers/google/create-vm-button.png diff --git a/docs/tutorials/jetstream.rst b/docs/tutorials/jetstream.rst index 6d320eb..f695c4e 100644 --- a/docs/tutorials/jetstream.rst +++ b/docs/tutorials/jetstream.rst @@ -84,10 +84,15 @@ Let's create the server on which we can run JupyterHub. .. code-block:: bash - #!/bin/bash - curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ - | sudo python3 - \ - --admin + #!/bin/bash + curl https://raw.githubusercontent.com/jupyterhub/the-littlest-jupyterhub/master/bootstrap/bootstrap.py \ + | sudo python3 - \ + --admin + + .. 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. #. Under **Execution Strategy Type**, select **Run script on first boot**. From 6e29dd5db9b4c5a2847edcac2c721c1229fa2111 Mon Sep 17 00:00:00 2001 From: yuvipanda Date: Wed, 1 Aug 2018 18:53:50 -0700 Subject: [PATCH 7/7] Error out if there's a tljh-config that isn't ours in PATH --- tljh/installer.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index cb86f62..66daf1e 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -287,10 +287,16 @@ def ensure_symlinks(prefix): """ tljh_config_src = os.path.join(prefix, 'bin', 'tljh-config') tljh_config_dest = '/usr/local/bin/tljh-config' - if not os.path.exists(tljh_config_dest): - # If this exists, we leave it alone. Do *not* remove it, - # since we are running as root and it could be anything! - os.symlink(tljh_config_src, tljh_config_dest) + if os.path.exists(tljh_config_dest): + if os.path.realpath(tljh_config_dest) != tljh_config_src: + # tljh-config exists that isn't ours. We should *not* delete this file, + # instead we throw an error and abort. Deleting files owned by other people + # while running as root is dangerous, especially with symlinks involved. + raise FileExistsError(f'/usr/local/bin/tljh-config exists but is not a symlink to {tljh_config_src}') + else: + # We have a working symlink, so do nothing + return + os.symlink(tljh_config_src, tljh_config_dest) def main():