diff --git a/.circleci/config.yml b/.circleci/config.yml index f6a3d46..2aa989d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -110,7 +110,7 @@ commands: - run: name: Run bootstrap checks command: | - py.test integration-tests/test_bootstrap.py + py.test integration-tests/test_bootstrap.py -s jobs: @@ -169,15 +169,14 @@ jobs: - build_systemd_image + - bootstrap_checks + - basic_tests - admin_tests - plugin_tests - - bootstrap_checks - - upgrade-test: docker: - image: docker:18.05.0-ce-git diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 434ea35..55cfbc0 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -13,10 +13,94 @@ Constraints: - Use stdlib modules only """ import os +from http.server import SimpleHTTPRequestHandler, HTTPServer +import multiprocessing import subprocess import sys import logging import shutil +import urllib.request + +html = """ + + + The Littlest Jupyterhub + + + + + + +
+
Please wait while your TLJH is building...
+
Click the button below to see the logs
+
Tip: to update the logs, refresh the page
+ + + + + + + +""" logger = logging.getLogger(__name__) @@ -90,7 +174,65 @@ def validate_host(): print("For local development, see http://tljh.jupyter.org/en/latest/contributing/dev-setup.html") sys.exit(1) +class LoaderPageRequestHandler(SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == "/logs": + with open("/opt/tljh/installer.log", "r") as log_file: + logs = log_file.read() + + self.send_response(200) + self.send_header('Content-Type', 'text/plain; charset=utf-8') + self.end_headers() + self.wfile.write(logs.encode('utf-8')) + elif self.path == "/index.html": + self.path = "/var/run/index.html" + return SimpleHTTPRequestHandler.do_GET(self) + elif self.path == "/favicon.ico": + self.path = "/var/run/favicon.ico" + return SimpleHTTPRequestHandler.do_GET(self) + elif self.path == "/": + self.send_response(302) + self.send_header('Location','/index.html') + self.end_headers() + else: + SimpleHTTPRequestHandler.send_error(self, code=403) + +def serve_forever(server): + try: + server.serve_forever() + except KeyboardInterrupt: + pass + def main(): + flags = sys.argv[1:] + temp_page_flag = "--show-progress-page" + + # Check for flag in the argv list. This doesn't use argparse + # because it's the only argument that's meant for the boostrap script. + # All the other flags will be passed to and parsed by the installer. + if temp_page_flag in flags: + with open("/var/run/index.html", "w+") as f: + f.write(html) + favicon_url="https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/share/jupyterhub/static/favicon.ico" + urllib.request.urlretrieve(favicon_url, "/var/run/favicon.ico") + + # If the bootstrap is run to upgrade TLJH, then this will raise an "Address already in use" error + try: + loading_page_server = HTTPServer(("", 80), LoaderPageRequestHandler) + p = multiprocessing.Process(target=serve_forever, args=(loading_page_server,)) + # Serves the loading page until TLJH builds + p.start() + + # Remove the flag from the args list, since it was only relevant to this script. + flags.remove("--show-progress-page") + + # Pass the server's pid as a flag to the istaller + pid_flag = "--progress-page-server-pid" + flags.extend([pid_flag, str(p.pid)]) + except OSError: + # Only serve the loading page when installing TLJH + pass + validate_host() install_prefix = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') hub_prefix = os.path.join(install_prefix, 'hub') @@ -175,9 +317,8 @@ def main(): os.path.join(hub_prefix, 'bin', 'python3'), '-m', 'tljh.installer', - ] + sys.argv[1:] + ] + flags ) - if __name__ == '__main__': main() diff --git a/docs/topic/customizing-installer.rst b/docs/topic/customizing-installer.rst index dd45d2b..b18febc 100644 --- a/docs/topic/customizing-installer.rst +++ b/docs/topic/customizing-installer.rst @@ -17,6 +17,35 @@ This page documents the various options you can pass as commandline parameters t .. _topic/customizing-installer/admin: +Serving a temporary "TLJH is building" page +=========================================== +``--show-progress-page`` serves a temporary "TLJH is building" progress page while TLJH is building. + +.. image:: ../images/tljh-is-building-page.gif + :alt: Temporary progress page while TLJH is building + +* The page will be accessible at ``http:///index.html`` in your browser. + When TLJH installation is complete, the progress page page will stop and you will be able + to access TLJH as usually at ``http://``. +* From the progress page, you will also be able to access the installation logs, by clicking the + **Logs** button or by going directly to ``http:///logs`` in your browser. + To update the logs, refresh the page. + +.. note:: + + The ``http:///index.html`` page refreshes itself automatically every 30s. + When JupyterHub starts, a JupyterHub 404 HTTP error message (*Jupyter has lots of moons, but this is not one...*) + will be shown instead of the progress page. This means JupyterHub was started succesfully and you can access it + either by clicking the `Control Panel` button or by going to ``http:///`` directly. + +For example, to enable the progress page and add the first *admin* user, you would run: + +.. code-block:: bash + + curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin admin --showprogress-page + Adding admin users =================== diff --git a/docs/topic/installer-actions.rst b/docs/topic/installer-actions.rst index e5991a2..e655f66 100644 --- a/docs/topic/installer-actions.rst +++ b/docs/topic/installer-actions.rst @@ -139,6 +139,24 @@ to be used and modified solely by these services. sudo rm -rf /opt/tljh/state +Progress page files +=================== + +If you ran the TLJH installer with the `--show-progress-page` flag, then two files have been +added to your system to help serving the progress page: + +* ``/var/run/index.html`` - the main progress page +* ``/var/run/favicon.ico`` - the JupyterHub icon + +.. note:: + If you try to remove TLJH, revert this action using: + + .. code-block:: bash + + sudo rm /var/run/index.html + sudo rm /var/run/favicon.ico + + User groups =========== diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index fa02b68..4d3aea5 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -1,56 +1,148 @@ """ Test running bootstrap script in different circumstances """ +import concurrent.futures +import os import subprocess from textwrap import dedent +import time -def run_bootstrap(container_name, image): - # stop container if it is already running - subprocess.run([ - 'docker', 'rm', '-f', container_name - ]) - - # Start a detached Ubuntu 16.04 container - subprocess.check_call([ - 'docker', 'run', '--detach', '--name', container_name, image, - '/bin/bash', '-c', 'sleep 1000s' - ]) + +def install_pkgs(container_name, show_progress_page): # Install python3 inside the ubuntu container # There is no trusted Ubuntu+Python3 container we can use - subprocess.check_output([ - 'docker', 'exec', container_name, 'apt-get', 'update' - ]) - subprocess.check_output([ - 'docker', 'exec', container_name, 'apt-get', 'install', '--yes', 'python3' - ]) - # Copy only the bootstrap script to container, so this is faster - subprocess.check_call([ - 'docker', - 'cp', - 'bootstrap/', f'{container_name}:/srv' - ]) + pkgs = ["python3"] + if show_progress_page: + pkgs += ["systemd", "git", "curl"] + # Create the sudoers dir, so that the installer succesfully gets to the + # point of starting jupyterhub and stopping the progress page server. + subprocess.check_output( + ["docker", "exec", container_name, "mkdir", "-p", "etc/sudoers.d"] + ) + + subprocess.check_output(["docker", "exec", container_name, "apt-get", "update"]) + subprocess.check_output( + ["docker", "exec", container_name, "apt-get", "install", "--yes"] + pkgs + ) + + +def get_bootstrap_script_location(container_name, show_progress_page): + # Copy only the bootstrap script to container when progress page not enabled, to be faster + source_path = "bootstrap/" + bootstrap_script = "/srv/src/bootstrap.py" + if show_progress_page: + source_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir) + ) + bootstrap_script = "/srv/src/bootstrap/bootstrap.py" + + subprocess.check_call(["docker", "cp", source_path, f"{container_name}:/srv/src"]) + return bootstrap_script + + +def run_bootstrap(container_name, image, show_progress_page=False): + # stop container if it is already running + subprocess.run(["docker", "rm", "-f", container_name]) + + # Start a detached container + subprocess.check_call( + [ + "docker", + "run", + "--detach", + "--name", + container_name, + image, + "/bin/bash", + "-c", + "sleep 1000s", + ] + ) + + install_pkgs(container_name, show_progress_page) + + bootstrap_script = get_bootstrap_script_location(container_name, show_progress_page) + + exec_flags = ["-i", container_name, "python3", bootstrap_script] + if show_progress_page: + exec_flags = ( + ["-e", "TLJH_BOOTSTRAP_DEV=yes", "-e", "TLJH_BOOTSTRAP_PIP_SPEC=/srv/src"] + + exec_flags + + ["--show-progress-page"] + ) # 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') + return subprocess.run( + ["docker", "exec"] + exec_flags, + 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' + 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 def test_inside_no_systemd_docker(): - output = run_bootstrap('plain-docker-test', 'ubuntu:18.04') - assert output.stdout.strip() == dedent(""" + 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() + """ + ).strip() + ) assert output.returncode == 1 + + +def verify_progress_page(expected_status_code, timeout): + progress_page_status = False + start = time.time() + while not progress_page_status and (time.time() - start < timeout): + try: + resp = subprocess.check_output( + [ + "docker", + "exec", + "progress-page", + "curl", + "-i", + "http://localhost/index.html", + ] + ) + if b"HTTP/1.0 200 OK" in resp: + progress_page_status = True + break + except Exception as e: + time.sleep(2) + continue + + return progress_page_status + + +def test_progress_page(): + with concurrent.futures.ThreadPoolExecutor() as executor: + installer = executor.submit( + run_bootstrap, "progress-page", "ubuntu:18.04", True + ) + + # Check if progress page started + started = verify_progress_page(expected_status_code=200, timeout=120) + assert started + + # This will fail start tljh but should successfully get to the point + # Where it stops the progress page server. + output = installer.result() + + # Check if progress page stopped + assert "Progress page server stopped successfully." in output.stdout diff --git a/tljh/installer.py b/tljh/installer.py index 3b9e0fb..de56be5 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -6,6 +6,7 @@ import itertools import logging import os import secrets +import signal import subprocess import sys import time @@ -485,6 +486,11 @@ def main(): nargs='*', help='Plugin pip-specs to install' ) + argparser.add_argument( + '--progress-page-server-pid', + type=int, + help='The pid of the progress page server' + ) args = argparser.parse_args() @@ -499,6 +505,17 @@ def main(): ensure_node() ensure_jupyterhub_package(HUB_ENV_PREFIX) ensure_jupyterlab_extensions() + + # Stop the http server with the progress page before traefik starts + if args.progress_page_server_pid: + try: + os.kill(args.progress_page_server_pid, signal.SIGINT) + # Log and print the message to make testing easier + print("Progress page server stopped successfully.") + except Exception as e: + logger.error(f"Couldn't stop the progress page server. Exception was {e}.") + pass + ensure_jupyterhub_service(HUB_ENV_PREFIX) ensure_jupyterhub_running() ensure_symlinks(HUB_ENV_PREFIX)