From 94f57fc3dd7a52acac5a11d91fc561095037d17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20T=2E=20Jochym?= Date: Sun, 20 Feb 2022 10:33:23 +0100 Subject: [PATCH 001/232] Add Debian >10 to supported distros --- bootstrap/bootstrap.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index dab7f8e..f88a6d2 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -130,7 +130,6 @@ progress_page_html = """ logger = logging.getLogger(__name__) - # This function is needed both by the process starting this script, and by the # TLJH installer that this script execs in the end. Make sure its replica at # tljh/utils.py stays in sync with this version! @@ -199,12 +198,15 @@ def ensure_host_system_can_install_tljh(): # Require Ubuntu 18.04+ distro = get_os_release_variable("ID") version = float(get_os_release_variable("VERSION_ID")) - if distro != "ubuntu": - print("The Littlest JupyterHub currently supports Ubuntu Linux only") + if distro in ["ubuntu", "debian"]: + print("The Littlest JupyterHub currently supports Ubuntu or Debian Linux only") sys.exit(1) - elif float(version) < 18.04: + elif distro == "ubuntu" and float(version) < 18.04: print("The Littlest JupyterHub requires Ubuntu 18.04 or higher") sys.exit(1) + elif distro == "debian" and float(version) < 10: + print("The Littlest JupyterHub requires Debian 10 or higher") + sys.exit(1) # Require Python 3.6+ if sys.version_info < (3, 6): @@ -224,6 +226,7 @@ def ensure_host_system_can_install_tljh(): "For local development, see http://tljh.jupyter.org/en/latest/contributing/dev-setup.html" ) sys.exit(1) + return distro, version class ProgressPageRequestHandler(SimpleHTTPRequestHandler): @@ -259,7 +262,7 @@ def main(): start a local webserver temporarily and report its installation progress via a web site served locally on port 80. """ - ensure_host_system_can_install_tljh() + distro, version = ensure_host_system_can_install_tljh() # Various related constants install_prefix = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") @@ -345,7 +348,8 @@ def main(): ["apt-get", "install", "--yes", "software-properties-common"], env=apt_get_adjusted_env, ) - run_subprocess(["add-apt-repository", "universe", "--yes"]) + if distro == "ubuntu" + run_subprocess(["add-apt-repository", "universe", "--yes"]) run_subprocess(["apt-get", "update"]) run_subprocess( [ @@ -356,6 +360,7 @@ def main(): "python3-venv", "python3-pip", "git", + "sudo", ], env=apt_get_adjusted_env, ) From 48a8e5fcb5f2c91389f8c919ed59034e73d10f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20T=2E=20Jochym?= Date: Sun, 20 Feb 2022 10:41:36 +0100 Subject: [PATCH 002/232] Typo --- bootstrap/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index f88a6d2..a5fe97b 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -348,7 +348,7 @@ def main(): ["apt-get", "install", "--yes", "software-properties-common"], env=apt_get_adjusted_env, ) - if distro == "ubuntu" + if distro == "ubuntu": run_subprocess(["add-apt-repository", "universe", "--yes"]) run_subprocess(["apt-get", "update"]) run_subprocess( From e408d99a5adbd82d8c1aff5cc08cfe860ff49ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20T=2E=20Jochym?= Date: Sun, 20 Feb 2022 10:44:24 +0100 Subject: [PATCH 003/232] Missing negation --- bootstrap/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index a5fe97b..14b6fce 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -198,7 +198,7 @@ def ensure_host_system_can_install_tljh(): # Require Ubuntu 18.04+ distro = get_os_release_variable("ID") version = float(get_os_release_variable("VERSION_ID")) - if distro in ["ubuntu", "debian"]: + if distro not in ["ubuntu", "debian"]: print("The Littlest JupyterHub currently supports Ubuntu or Debian Linux only") sys.exit(1) elif distro == "ubuntu" and float(version) < 18.04: From 2e5d275c7e88a6080d02a4c05069534b027d2fd6 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 9 May 2022 12:25:45 -0600 Subject: [PATCH 004/232] ci: run int. and unit tests on 22.04 LTS + py3.10 --- .github/workflows/integration-test.yaml | 4 ++++ .github/workflows/unit-test.yaml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 50d2fd3..8c4e490 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -71,6 +71,10 @@ jobs: ubuntu_version: "21.10" python_version: "3.9" extra_flags: "" + - name: "Int. tests: Ubuntu 22.04, Py 3.10" + ubuntu_version: "22.04" + python_version: "3.10" + extra_flags: "" - name: "Int. tests: Ubuntu 20.04, Py 3.9, --upgrade" ubuntu_version: "20.04" python_version: "3.9" diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 5de90ce..5895f2d 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -48,6 +48,9 @@ jobs: - name: "Unit tests: Ubuntu 20.04, Py 3.9" ubuntu_version: "20.04" python_version: "3.9" + - name: "Unit tests: Ubuntu 22.04, Py 3.10" + ubuntu_version: "22.04" + python_version: "3.10" # Test against Ubuntu 21.10 fails as of 2021-10-18 fail with the error # described in: https://github.com/jupyterhub/the-littlest-jupyterhub/issues/714#issuecomment-945154101 # From 8324e49ed91b3e24e1c0985bb21fafae2faa19bd Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 16 Jun 2022 20:54:54 +0100 Subject: [PATCH 005/232] TLJH version can be specified on the command line --- bootstrap/bootstrap.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 07cd01c..e151289 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -38,6 +38,7 @@ Command line flags: passed, it will pass --progress-page-server-pid= to the tljh installer for later termination. """ +from argparse import ArgumentParser import os from http.server import SimpleHTTPRequestHandler, HTTPServer import multiprocessing @@ -252,8 +253,8 @@ class ProgressPageRequestHandler(SimpleHTTPRequestHandler): def main(): """ - This script intercepts the --show-progress-page flag, but all other flags - are passed through to the TLJH installer script. + This bootstrap script intercepts some command line flags, everything else is + passed through to the TLJH installer script. The --show-progress-page flag indicates that the bootstrap script should start a local webserver temporarily and report its installation progress via @@ -261,6 +262,13 @@ def main(): """ ensure_host_system_can_install_tljh() + parser = ArgumentParser() + parser.add_argument("--show-progress-page", action="store_true") + parser.add_argument( + "--version", default="main", help="TLJH version (Git reference)" + ) + args, tljh_installer_flags = parser.parse_known_args() + # Various related constants install_prefix = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") hub_prefix = os.path.join(install_prefix, "hub") @@ -270,12 +278,7 @@ def main(): # Attempt to start a web server to serve a progress page reporting # installation progress. - tljh_installer_flags = sys.argv[1:] - if "--show-progress-page" in tljh_installer_flags: - # Remove the bootstrap specific flag and let all other flags pass - # through to the installer. - tljh_installer_flags.remove("--show-progress-page") - + if args.show_progress_page: # Write HTML and a favicon to be served by our webserver with open("/var/run/index.html", "w+") as f: f.write(progress_page_html) @@ -378,7 +381,9 @@ def main(): tljh_install_cmd.append( os.environ.get( "TLJH_BOOTSTRAP_PIP_SPEC", - "git+https://github.com/jupyterhub/the-littlest-jupyterhub.git", + "git+https://github.com/jupyterhub/the-littlest-jupyterhub.git@{version}".format( + version=args.version + ), ) ) if initial_setup: From 3340ac84173ae674e50a872b1065ba20d34fe6b8 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 16 Jun 2022 23:32:35 +0100 Subject: [PATCH 006/232] Handle request for partial git tags --- bootstrap/bootstrap.py | 80 +++++++++++++++++++++-- mock-tests/test_bootstrap.py | 123 +++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 mock-tests/test_bootstrap.py diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index e151289..f3759b7 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -42,6 +42,7 @@ from argparse import ArgumentParser import os from http.server import SimpleHTTPRequestHandler, HTTPServer import multiprocessing +import re import subprocess import sys import logging @@ -166,9 +167,11 @@ def run_subprocess(cmd, *args, **kwargs): command=printable_command, code=proc.returncode ) ) + output = proc.stdout.decode() # 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()) + logger.debug(output) + return output def ensure_host_system_can_install_tljh(): @@ -251,6 +254,73 @@ class ProgressPageRequestHandler(SimpleHTTPRequestHandler): SimpleHTTPRequestHandler.send_error(self, code=403) +def _find_matching_version(all_versions, requested): + """ + Find the latest version that is less than or equal to requested. + all_versions must be int-tuples. + requested must be an int-tuple or "latest" + + Returns None if no version is found. + """ + sorted_versions = sorted(all_versions, reverse=True) + if requested == "latest": + return sorted_versions[0] + components = len(requested) + for v in sorted_versions: + if v[:components] == requested: + return v + return None + + +def _resolve_git_version(version): + """ + Resolve the version argument to a git ref using git ls-remote + - If version looks like MAJOR.MINOR.PATCH or a partial tag then fetch all tags + and return the most latest tag matching MAJOR.MINOR.PATCH + (e.g. version=0.1 -> 0.1.PATCH). This should ignore dev tags + - If version='latest' then return the latest release tag + - Otherwise assume version is a branch or hash and return it without checking + """ + + if version != "latest" and not re.match(r"\d+(\.\d+)?(\.\d+)?$", version): + return version + + all_versions = set() + out = run_subprocess( + [ + "git", + "ls-remote", + "--tags", + "--refs", + "https://github.com/jupyterhub/the-littlest-jupyterhub.git", + ] + ) + + for line in out.splitlines(): + m = re.match(r"(?P[a-f0-9]+)\s+refs/tags/(?P[\S]+)$", line) + if not m: + raise Exception("Unexpected git ls-remote output: {}".format(line)) + tag = m.group("tag") + if tag == version: + return tag + if re.match(r"\d+\.\d+\.\d+$", tag): + all_versions.add(tuple(int(v) for v in tag.split("."))) + + if not all_versions: + raise Exception("No MAJOR.MINOR.PATCH git tags found") + + if version == "latest": + requested = "latest" + else: + requested = tuple(int(v) for v in version.split(".")) + found = _find_matching_version(all_versions, requested) + if not found: + raise Exception( + "No version matching {} found {}".format(version, sorted(all_versions)) + ) + return ".".join(str(f) for f in found) + + def main(): """ This bootstrap script intercepts some command line flags, everything else is @@ -265,7 +335,7 @@ def main(): parser = ArgumentParser() parser.add_argument("--show-progress-page", action="store_true") parser.add_argument( - "--version", default="main", help="TLJH version (Git reference)" + "--version", default="main", help="TLJH version or Git reference" ) args, tljh_installer_flags = parser.parse_known_args() @@ -378,11 +448,13 @@ def main(): if os.environ.get("TLJH_BOOTSTRAP_DEV", "no") == "yes": logger.info("Selected TLJH_BOOTSTRAP_DEV=yes...") tljh_install_cmd.append("--editable") + version = _resolve_git_version(args.version) + tljh_install_cmd.append( os.environ.get( "TLJH_BOOTSTRAP_PIP_SPEC", - "git+https://github.com/jupyterhub/the-littlest-jupyterhub.git@{version}".format( - version=args.version + "git+https://github.com/jupyterhub/the-littlest-jupyterhub.git@{}".format( + version ), ) ) diff --git a/mock-tests/test_bootstrap.py b/mock-tests/test_bootstrap.py new file mode 100644 index 0000000..ba4b3f5 --- /dev/null +++ b/mock-tests/test_bootstrap.py @@ -0,0 +1,123 @@ +# Unit test some functions from bootstrap.py +# Since bootstrap.py isn't part of the package, it's not automatically importable +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from bootstrap import bootstrap +import pytest + + +@pytest.mark.parametrize( + "requested,expected", + [ + ((1,), (1, 1, 0)), + ((1, 0), (1, 0, 1)), + ((1, 2), None), + ((2, 0, 0), (2, 0, 0)), + ("latest", (2, 0, 1)), + ], +) +def test_find_matching_version(requested, expected): + all_versions = [ + (0, 0, 1), + (1, 0, 0), + (1, 0, 1), + (1, 1, 0), + (2, 0, 0), + (2, 0, 1), + ] + r = bootstrap._find_matching_version(all_versions, requested) + assert r == expected + + +# git ls-remote --tags --refs https://github.com/jupyterhub/jupyterhub.git +mock_git_ls_remote = """\ +c345aa658eb482b8102b51f6ec3f0fc667b60520 refs/tags/0.1.0 +e2cbc2fb41c04cfe416b19e83f36ea67b1a4e693 refs/tags/0.2.0 +7fd56fe943bf0038cb14e7aa2af5a3e5ad929d47 refs/tags/0.3.0 +89cf4c60a905b4093ba7584c4561073a9faa0d3d refs/tags/0.4.0 +bd08efc4a5485a9ecd17882e9bfcab9486d9956a refs/tags/0.4.1 +19c77d0c7016c08a4a0e883446114a430a551882 refs/tags/0.5.0 +b4621d354b6bbc865373b7c033f29c6872237780 refs/tags/0.6.0 +ed38f08daf4d5cf84f04b2d96327681221c579dd refs/tags/0.6.1 +d5cf9657f2ca16df080e2be21da792288e9f4f99 refs/tags/0.7.0 +2cdb4be46a0eb291850fc706cfe044873889a9bc refs/tags/0.7.1 +e4dd65d2611e3c85fe486c561fc0efe9ca720042 refs/tags/0.7.2 +4179668e49ceedb805cb1a38dc5a70e6a21fa685 refs/tags/0.8.0 +9bab206eb96c6726ac936cf5da3f61eb9c9aa519 refs/tags/0.8.0b1 +5f2c6d25fefcbe91d86945f530e6533822449c46 refs/tags/0.8.0b2 +0d720103c5207d008947580b7b453e1eb0e7594a refs/tags/0.8.0b3 +a2458ffa402fa2d2905c558100c673e98789a8a8 refs/tags/0.8.0b4 +b9e2838a4d9b35a9ad7c3353e62ab006b4ec10a4 refs/tags/0.8.0b5 +a62fc1bc1c9b2408713511cb56e7751403ed5503 refs/tags/0.8.0rc1 +a77ca08e3e25c14552e246e8ad3ca65a354ba192 refs/tags/0.8.0rc2 +6eef64842a7d7939b3f1986558849d1977a0e121 refs/tags/0.8.1 +de46a16029b7ae217293e7e64e14a9c2e06e5e60 refs/tags/0.9.0 +9f612b52187db878f529458e304bd519fda82e42 refs/tags/0.9.0b1 +ec4b038b93495eb769007a0d3d56e6d6a5ff000c refs/tags/0.9.0b2 +ea8a30b1a5b189b2f2f0dbfdb22f83427d1c9163 refs/tags/0.9.0b3 +99c155a61a1d95a3a8ca41ebb684cdedc1fb170f refs/tags/0.9.0rc1 +1aeebd4e4937ea5185ce02f693f94272c30f4ebd refs/tags/0.9.1 +01b3601a12b52259b541b48eaa7a7afb3f7d988c refs/tags/0.9.2 +70ddc22e071bb7797528831d25c567f6f4920c67 refs/tags/0.9.3 +7ecb093163a453ae2edfa6bc8bf1f7cfc2320627 refs/tags/0.9.4 +3e83bc440b8d5abdc0a4336978bd542435402f77 refs/tags/0.9.5 +cc07c706931c78f46367db9c0c20e6ed9f0f6f85 refs/tags/0.9.6 +4e24276d40ad83fd113c7c2f1f8619a9ba3af0d8 refs/tags/1.0.0 +582162c760e81995f4f5405e9c8908d2a76f4abf refs/tags/1.0.0b1 +1193f6a74c38b36594f6f57c786fa923a2747150 refs/tags/1.0.0b2 +512dae6cd8a846dd490d77d21fd4e13f59c38961 refs/tags/1.1.0 +a420c55391273852332ef5f454a0a3b9e0e5b71f refs/tags/1.1.0b1 +317f0efaf25eb7cb2de4503817cf20937ce110bd refs/tags/1.2.0 +f66e5d35b5f89a28f6328c91801a8f99e0855a8e refs/tags/1.2.0b1 +27e1196471729cf6f25fd3786286797e32de973a refs/tags/1.2.1 +af0c1ed932d00fa26ac91f066a5a9eafb49b7cb1 refs/tags/1.2.2 +3794abfbdda0a92237f4c31985420691da70da36 refs/tags/1.3.0 +e22ab5dc93dd8e724b828a0880032f6b5dc00231 refs/tags/1.4.0 +0656586b75b30091583c0573b3d272cb3add24d2 refs/tags/1.4.1 +5744ce73bcf0014cc3de6c946f12027448b136da refs/tags/1.4.2 +c6fb64d8f30686c2c2667b69b53402d506a3bac5 refs/tags/1.5.0 +4ceb906435dbd4cf800b0480d413303f056e4900 refs/tags/2.0.0 +61233698dfb353c703ea2e085312b9066ea2e92e refs/tags/2.0.0b1 +fe61c932409550dc352abf68bd6aaaa8871ac81f refs/tags/2.0.0b2 +a79c5c5a6bfe553af277f2835419d65b98ae0cb9 refs/tags/2.0.0b3 +fa1098a998561321de29c6147235032fd6b0c3f5 refs/tags/2.0.0rc1 +75b115c356983c138c2d8d92cb45f068ad3d9c9d refs/tags/2.0.0rc2 +ed8e25ef3f471d60b671f2a1cf2db17581c778a2 refs/tags/2.0.0rc3 +4083307b3f37039075862034963ed42a459b1bdb refs/tags/2.0.0rc4 +baf1f36dbfe8d8264b3914650b4db6daed843389 refs/tags/2.0.0rc5 +12961be3b13a10617d8f95f333da2bb67390a2c7 refs/tags/2.0.1 +e40df3f1a5e284926f5c9ce66a1e57a814bb98f8 refs/tags/2.0.2 +11d40c13860bd02816ad724979ad2e08b8bd103a refs/tags/2.1.0 +bde6b66287e3d157f2577bcaf2e986af020139f4 refs/tags/2.1.1 +29f51794db562ecc4c7653525193d6e210151fdb refs/tags/2.2.0 +8cbafd25425b7eaf2fdc46e183cee437c09b53c1 refs/tags/2.2.1 +1eada986101f2385ee7498395a799f28bcd167e8 refs/tags/2.2.2 +1bb0ec38ae4c5e4e5c8b6cc3b89b7b20ea8bd400 refs/tags/2.3.0 +69f926706be03505f9b9e30a5ad2d4f8c9f9d48d refs/tags/2.3.1 +""" + + +@pytest.mark.parametrize( + "requested,expected", + [ + ("1", "1.5.0"), + ("1.0", "1.0.0"), + ("1.2", "1.2.2"), + # ("1.100", None), + ("2.0.0", "2.0.0"), + ("random-branch", "random-branch"), + ("1234567890abcdef", "1234567890abcdef"), + ("2.0.0rc4", "2.0.0rc4"), + ("latest", "2.3.1"), + ], +) +def test_resolve_git_version(monkeypatch, requested, expected): + def mock_run_subprocess(*args, **kwargs): + return mock_git_ls_remote + + monkeypatch.setattr(bootstrap, "run_subprocess", mock_run_subprocess) + + assert bootstrap._resolve_git_version(requested) == expected From a7ae7f7eeaccec91a7b3dd32f03e0b3a7ee2e275 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 17 Jun 2022 13:47:28 +0100 Subject: [PATCH 007/232] Move bootstrap func tests to tests/test_bootstrap_functions.py --- mock-tests/test_bootstrap.py => tests/test_bootstrap_functions.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mock-tests/test_bootstrap.py => tests/test_bootstrap_functions.py (100%) diff --git a/mock-tests/test_bootstrap.py b/tests/test_bootstrap_functions.py similarity index 100% rename from mock-tests/test_bootstrap.py rename to tests/test_bootstrap_functions.py From dfffac343b5715177fcabadec4b43e4090f078dc Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 17 Jun 2022 17:36:25 +0100 Subject: [PATCH 008/232] pre-commit autoupdate --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a075ff9..e63b7cb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v2.29.0 + rev: v2.34.0 hooks: - id: pyupgrade args: @@ -22,19 +22,19 @@ repos: # Autoformat: Python code - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 22.3.0 hooks: - id: black # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.4.1 + rev: v2.7.1 hooks: - id: prettier # Misc... - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.3.0 # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available hooks: # Autoformat: Makes sure files end in a newline and only a newline. From 65336e21eea779ce8c94044aefbbedc6571fe2dc Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 17 Jun 2022 17:36:42 +0100 Subject: [PATCH 009/232] pre-commit run -a --- tljh/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/installer.py b/tljh/installer.py index 8da932c..42ba070 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -447,7 +447,7 @@ def main(): ensure_admins(args.admin) ensure_usergroups() if args.user_requirements_txt_url: - logger.info("installing packages from user_requirements_txt_url") + logger.info("installing packages from user_requirements_txt_url") ensure_user_environment(args.user_requirements_txt_url) logger.info("Setting up JupyterHub...") From 9328c6bb781520509bb64ffdc31152e4c7c3734f Mon Sep 17 00:00:00 2001 From: Simon Li Date: Wed, 22 Jun 2022 20:22:44 +0100 Subject: [PATCH 010/232] Test non-existent version request --- tests/test_bootstrap_functions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_bootstrap_functions.py b/tests/test_bootstrap_functions.py index ba4b3f5..c579ac8 100644 --- a/tests/test_bootstrap_functions.py +++ b/tests/test_bootstrap_functions.py @@ -106,7 +106,7 @@ bde6b66287e3d157f2577bcaf2e986af020139f4 refs/tags/2.1.1 ("1", "1.5.0"), ("1.0", "1.0.0"), ("1.2", "1.2.2"), - # ("1.100", None), + ("1.100", None), ("2.0.0", "2.0.0"), ("random-branch", "random-branch"), ("1234567890abcdef", "1234567890abcdef"), @@ -120,4 +120,9 @@ def test_resolve_git_version(monkeypatch, requested, expected): monkeypatch.setattr(bootstrap, "run_subprocess", mock_run_subprocess) - assert bootstrap._resolve_git_version(requested) == expected + if expected is None: + with pytest.raises(Exception) as exc: + bootstrap._resolve_git_version(requested) + assert exc.value.args[0].startswith("No version matching 1.100 found") + else: + assert bootstrap._resolve_git_version(requested) == expected From c93e5e4cba44d9d4f58642bb29cf7ab2f6e7f51f Mon Sep 17 00:00:00 2001 From: Rowan Molony Date: Mon, 8 Aug 2022 15:30:13 +0100 Subject: [PATCH 011/232] Update nbgitpuller URL Previous URL is broken --- docs/howto/content/nbgitpuller.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/content/nbgitpuller.rst b/docs/howto/content/nbgitpuller.rst index 152fd55..66fe2c8 100644 --- a/docs/howto/content/nbgitpuller.rst +++ b/docs/howto/content/nbgitpuller.rst @@ -41,7 +41,7 @@ Step 1: Generate nbgitpuller link **Generate the link with a Binder app**. #. The easiest way to generate an nbgitpuller link is to use the - `mybinder.org based application `_. + `mybinder.org based application `_. Open it, and wait for it to load. .. image:: ../../images/nbgitpuller/binder-progress.png From 5b8545241c0457453eb7dcd2b032205b55d7ed36 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Sep 2022 21:06:48 +0200 Subject: [PATCH 012/232] docs: reference nbgitpullers docs to fix outdated tljh docs --- docs/howto/content/nbgitpuller.rst | 82 ++---------------- docs/images/nbgitpuller/binder-progress.png | Bin 30883 -> 0 bytes docs/images/nbgitpuller/blank-application.png | Bin 54919 -> 0 bytes .../nbgitpuller/filepath-application.png | Bin 43577 -> 0 bytes .../nbgitpuller/git-url-application.png | Bin 30574 -> 0 bytes .../nbgitpuller/hub-url-application.png | Bin 9111 -> 0 bytes 6 files changed, 5 insertions(+), 77 deletions(-) delete mode 100644 docs/images/nbgitpuller/binder-progress.png delete mode 100644 docs/images/nbgitpuller/blank-application.png delete mode 100644 docs/images/nbgitpuller/filepath-application.png delete mode 100644 docs/images/nbgitpuller/git-url-application.png delete mode 100644 docs/images/nbgitpuller/hub-url-application.png diff --git a/docs/howto/content/nbgitpuller.rst b/docs/howto/content/nbgitpuller.rst index 66fe2c8..aeed71d 100644 --- a/docs/howto/content/nbgitpuller.rst +++ b/docs/howto/content/nbgitpuller.rst @@ -24,7 +24,7 @@ Instructors should be able to: 1. Use modern collaborative version control tools to author & store their materials. This currently means using Git. -**nbgitpuller** is a Jupyter Notebook extension that helps achieve these goals. +**nbgitpuller** is a Jupyter server extension that helps achieve these goals. This tutorial will walk you through the process of creating a magic nbgitpuller link that users of your JupyterHub can click to fetch the latest version of materials from a git repo. @@ -38,76 +38,10 @@ Pre-requisites Step 1: Generate nbgitpuller link ================================= -**Generate the link with a Binder app**. - -#. The easiest way to generate an nbgitpuller link is to use the - `mybinder.org based application `_. - Open it, and wait for it to load. - - .. image:: ../../images/nbgitpuller/binder-progress.png - :alt: Progress bar as the binder application loads - -#. A blank form with some help text will open up. - - .. image:: ../../images/nbgitpuller/blank-application.png - :alt: Blank application to make nbgitpuller links - -#. Enter the IP address or URL to your JupyterHub under ``hub_url``. - Include ``http://`` or ``https://`` as appropriate. - - .. image:: ../../images/nbgitpuller/hub-url-application.png - :alt: Application with hub_url filled out - -#. Enter the URL to your Git repository. This could be from GitHub, - GitLab or any other git provider - including the disk of the - server The Littlest JupyterHub is installed on. As you start - typing the URL here, you'll notice that the link is already - being printed below! - - .. image:: ../../images/nbgitpuller/git-url-application.png - :alt: Application with git_url filled out - -#. If your git repository is using a non-default branch name, - you can specify that under ``branch``. Most people do not - need to customize this. - -#. If you want to open a specific notebook when the user clicks - on the link, specify the path to the notebook under ``filepath``. - Make sure this file exists, otherwise users will get a 'File not found' - error. - - .. image:: ../../images/nbgitpuller/filepath-application.png - :alt: Application with filepath filled out - - If you do not specify a file path, the user will be shown the - directory listing for the repository. - -#. By default, notebooks will be opened in the classic Jupyter Notebook - interface. You can select ``lab`` under ``application`` to open it in the - `JupyterLab `_ instead. - -The link printed at the bottom of the form can be distributed to students -now! You can also click it to test that it is working as intended, -and adjust the form values until you get something you are happy with. - -**Hand-craft your nbgitpuller link** - -If you'd prefer to hand-craft your ``nbgitpuller`` link (e.g. if the Binder -link above doesn't work), you can use the following pattern:: - - http:///hub/user-redirect/git-pull?repo=&branch=&subPath=&app= - -- **repo** is the URL of the git repository you want to clone. This parameter is required. -- **branch** is the branch name to use when cloning from the repository. - This parameter is optional and defaults to ``master``. -- **subPath** is the path of the directory / notebook inside the repo to launch after cloning. - This parameter is optional, and defaults to opening the base directory of the linked Git repository. -- **app** This parameter is optional and defaults to either the environment variable - `NBGITPULLER_APP`'s value or `notebook` if it is undefined. The allowed values - are `lab` and `notebook`, the value will determine in what application view - you end up in. -- **urlPath** will, if specified, override `app` and `subPath` and redirect - blindly to the specified path. +The quickest way to generate a link is to use `nbgitpuller.link +`_, but other options exist as described in the +`nbgitpuller project's documentation +`_. Step 2: Users click on the nbgitpuller link =========================================== @@ -130,9 +64,3 @@ Step 2: Users click on the nbgitpuller link This workflow lets users land directly in the notebook you specified without having to understand much about git or the JupyterHub interface. - -Advanced: hand-crafting an nbgitpuller link -=========================================== - -For information on hand-crafting an ``nbgitpuller`` link, see -`the nbgitpuller README `_. diff --git a/docs/images/nbgitpuller/binder-progress.png b/docs/images/nbgitpuller/binder-progress.png deleted file mode 100644 index bc5045b17e6aa08f67920a3a8cc16bd30ea10511..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30883 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 V;XURUd{iNXw1k3qx#+iF{|9r{0Wkmo diff --git a/docs/images/nbgitpuller/blank-application.png b/docs/images/nbgitpuller/blank-application.png deleted file mode 100644 index a02f87c0586748c9f4b2f0b22ecb1b1ddf689777..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54919 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 G?*1D{yZeg( diff --git a/docs/images/nbgitpuller/filepath-application.png b/docs/images/nbgitpuller/filepath-application.png deleted file mode 100644 index 2871ce5b66428fb2d030b6ced0bd0d4973578316..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43577 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 &P!?igw_f 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 SZ#6gI=}?kWmj%DE2>(B7RX&yg diff --git a/docs/images/nbgitpuller/git-url-application.png b/docs/images/nbgitpuller/git-url-application.png deleted file mode 100644 index d1bc1f3a9107b8d16d4f8b11ece2723efa0415bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30574 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 JR|x6{{0DQ{9%}#q diff --git a/docs/images/nbgitpuller/hub-url-application.png b/docs/images/nbgitpuller/hub-url-application.png deleted file mode 100644 index f3d593005736a389a1e4e276647dad12b620df08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9111 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 Date: Fri, 9 Sep 2022 22:19:55 +0200 Subject: [PATCH 013/232] ci: don't test 21.10, only 22.04 --- .github/workflows/integration-test.yaml | 17 ++++++----------- .github/workflows/unit-test.yaml | 9 +-------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 8c4e490..04ba853 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -66,26 +66,21 @@ jobs: ubuntu_version: "20.04" python_version: "3.9" extra_flags: "" - - name: "Int. tests: Ubuntu 21.10, Py 3.9" - runs_on: "20.04" - ubuntu_version: "21.10" - python_version: "3.9" - extra_flags: "" - name: "Int. tests: Ubuntu 22.04, Py 3.10" ubuntu_version: "22.04" python_version: "3.10" extra_flags: "" - - name: "Int. tests: Ubuntu 20.04, Py 3.9, --upgrade" - ubuntu_version: "20.04" - python_version: "3.9" + - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade" + ubuntu_version: "22.04" + python_version: "3.10" extra_flags: --upgrade dont_run_on_ref: refs/heads/master integration-tests: needs: decide-on-test-jobs-to-run - # runs-on can only be configured to the LTS releases of ubuntu (18.04, - # 20.04, ...), so if we want to test against the latest non-LTS release, we + # runs-on can only be configured to the LTS releases of ubuntu (20.04, + # 22.04, ...), so if we want to test against the latest non-LTS release, we # must compromise when configuring runs-on and configure runs-on to be the # latest LTS release instead. # @@ -103,7 +98,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python_version }} + python-version: "${{ matrix.python_version }}" - name: Install pytest run: python3 -m pip install pytest diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 5895f2d..957a492 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -51,19 +51,12 @@ jobs: - name: "Unit tests: Ubuntu 22.04, Py 3.10" ubuntu_version: "22.04" python_version: "3.10" - # Test against Ubuntu 21.10 fails as of 2021-10-18 fail with the error - # described in: https://github.com/jupyterhub/the-littlest-jupyterhub/issues/714#issuecomment-945154101 - # - # - name: "Unit tests: Ubuntu 21.10, Py 3.9" - # runs_on: "20.04" - # ubuntu_version: "21.10" - # python_version: "3.9" steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python_version }} + python-version: "${{ matrix.python_version }}" - name: Install venv, git and setup venv run: | From 6e3f250dae9dda80c14d02e6cea814e63c497756 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Sep 2022 22:26:42 +0200 Subject: [PATCH 014/232] ci: add dependabot bumping of github actions --- .github/dependabot.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..28f1c9b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# dependabot.yml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Notes: +# - Status and logs from dependabot are provided at +# https://github.com/jupyterhub/the-littlest-jupyterhub/network/updates. +# - YAML anchors are not supported here or in GitHub Workflows. +# +version: 2 +updates: + # Maintain dependencies in our GitHub Workflows + - package-ecosystem: github-actions + directory: "/" # This should be / rather than .github/workflows + schedule: + interval: weekly + time: "05:00" + timezone: "Etc/UTC" From c56035ec0bffbccfbe95f0139e7b6f225ebe77e2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Sep 2022 22:29:41 +0200 Subject: [PATCH 015/232] ci: update github action versions --- .github/workflows/integration-test.yaml | 4 ++-- .github/workflows/unit-test.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 04ba853..a0cf9fa 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -95,8 +95,8 @@ jobs: matrix: ${{ fromJson(needs.decide-on-test-jobs-to-run.outputs.matrix) }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: "${{ matrix.python_version }}" diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 957a492..71804f3 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -53,8 +53,8 @@ jobs: python_version: "3.10" steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: "${{ matrix.python_version }}" @@ -74,7 +74,7 @@ jobs: # completion. Make sure to update the key to bust the cache # properly if you make a change that should influence it. - name: Load cached Python dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /srv/venv/ key: >- From 48ef17cbee10849bbacec1cff4b2fc54d33c0421 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 14 Sep 2022 19:08:43 +0200 Subject: [PATCH 016/232] docs: use nbgitpuller link without dedicated domain --- docs/howto/content/nbgitpuller.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/content/nbgitpuller.rst b/docs/howto/content/nbgitpuller.rst index aeed71d..5daf7b9 100644 --- a/docs/howto/content/nbgitpuller.rst +++ b/docs/howto/content/nbgitpuller.rst @@ -39,7 +39,7 @@ Step 1: Generate nbgitpuller link ================================= The quickest way to generate a link is to use `nbgitpuller.link -`_, but other options exist as described in the +`_, but other options exist as described in the `nbgitpuller project's documentation `_. From 45abba3c374b41c6a326a48cee1ed28950aa84bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20T=2E=20Jochym?= Date: Thu, 15 Sep 2022 17:34:25 +0200 Subject: [PATCH 017/232] Add requested comments specific to debian --- bootstrap/bootstrap.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index a34df09..5b14db7 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -421,6 +421,7 @@ def main(): ["apt-get", "install", "--yes", "software-properties-common"], env=apt_get_adjusted_env, ) + # Section "universe" exists and is required only in ubuntu. if distro == "ubuntu": run_subprocess(["add-apt-repository", "universe", "--yes"]) run_subprocess(["apt-get", "update"]) @@ -433,7 +434,7 @@ def main(): "python3-venv", "python3-pip", "git", - "sudo", + "sudo", # sudo is missing in default debian install ], env=apt_get_adjusted_env, ) From 5f2e3b1726de1506e6657861752dab5ac76e4177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20T=2E=20Jochym?= Date: Wed, 21 Sep 2022 20:28:25 +0200 Subject: [PATCH 018/232] Reflect distro support i comment --- bootstrap/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 5b14db7..b912dde 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -199,7 +199,7 @@ def ensure_host_system_can_install_tljh(): .strip() ) - # Require Ubuntu 18.04+ + # Require Ubuntu 18.04+ or Debian 10+ distro = get_os_release_variable("ID") version = float(get_os_release_variable("VERSION_ID")) if distro not in ["ubuntu", "debian"]: From edd7cdf3c67c8137df4b8e1e1df49460d3b03e0a Mon Sep 17 00:00:00 2001 From: Luong Vo Date: Mon, 10 Oct 2022 01:46:17 -0700 Subject: [PATCH 019/232] fix typo with --show-progress-page argument in example close https://github.com/jupyterhub/the-littlest-jupyterhub/issues/827 --- docs/topic/customizing-installer.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topic/customizing-installer.rst b/docs/topic/customizing-installer.rst index 3699ded..e3a2b04 100644 --- a/docs/topic/customizing-installer.rst +++ b/docs/topic/customizing-installer.rst @@ -44,7 +44,7 @@ For example, to enable the progress page and add the first *admin* user, you wou curl -L https://tljh.jupyter.org/bootstrap.py \ | sudo python3 - \ - --admin admin --showprogress-page + --admin admin --show-progress-page Adding admin users =================== From 27dba29e049b8b509a41cc377ad942ab56d684b3 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 14 Nov 2022 13:26:59 +0100 Subject: [PATCH 020/232] ci: fix deprecation of set-output in github workflows The -c flag on jq was used to emit JSON on a single line instead of ending up with multi-line output, which would make it harder to pass as output to an environment variable. --- .github/workflows/integration-test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index a0cf9fa..c1d8e16 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -50,9 +50,9 @@ jobs: matrix_post_filter=$( echo "$matrix_include_pre_filter" \ | yq e --output-format=json '.' - \ - | jq '{"include": map( . | select(.dont_run_on_ref != "${{ github.ref }}" ))}' + | jq -c '{"include": map( . | select(.dont_run_on_ref != "${{ github.ref }}" ))}' ) - echo ::set-output name=matrix::$(echo "$matrix_post_filter") + echo "matrix=$matrix_post_filter" >> $GITHUB_OUTPUT echo "The subsequent job's matrix are:" echo $matrix_post_filter | jq '.' From 3275fb7637dfdcc24d9633726e34a5e505dd766e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 14 Nov 2022 14:19:33 +0100 Subject: [PATCH 021/232] ci: colorize output of jq --- .github/workflows/integration-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index c1d8e16..cee1049 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -55,7 +55,7 @@ jobs: echo "matrix=$matrix_post_filter" >> $GITHUB_OUTPUT echo "The subsequent job's matrix are:" - echo $matrix_post_filter | jq '.' + echo $matrix_post_filter | jq -C '.' env: matrix_include_pre_filter: | - name: "Int. tests: Ubuntu 18.04, Py 3.6" From 52f5f008541ea7fa004a70e8b9e2e1ca8e471798 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 28 Nov 2022 09:18:31 +0100 Subject: [PATCH 022/232] docs: relocate logo to _static for conformity --- bootstrap/bootstrap.py | 2 +- docs/{ => _static}/images/logo/favicon.ico | Bin docs/{ => _static}/images/logo/logo.png | Bin 3 files changed, 1 insertion(+), 1 deletion(-) rename docs/{ => _static}/images/logo/favicon.ico (100%) rename docs/{ => _static}/images/logo/logo.png (100%) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index f3759b7..943fcb9 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -59,7 +59,7 @@ progress_page_html = """ - +

Please wait while your TLJH is setting up...
Click the button below to see the logs
diff --git a/docs/images/logo/favicon.ico b/docs/_static/images/logo/favicon.ico similarity index 100% rename from docs/images/logo/favicon.ico rename to docs/_static/images/logo/favicon.ico diff --git a/docs/images/logo/logo.png b/docs/_static/images/logo/logo.png similarity index 100% rename from docs/images/logo/logo.png rename to docs/_static/images/logo/logo.png From 89603d1b5c3c0b0832983ca25dac1eee988041ba Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 28 Nov 2022 09:25:14 +0100 Subject: [PATCH 023/232] docs: remove reference to not-available image --- docs/topic/jupyterhub-configurator.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/topic/jupyterhub-configurator.rst b/docs/topic/jupyterhub-configurator.rst index 6fdce76..f319573 100644 --- a/docs/topic/jupyterhub-configurator.rst +++ b/docs/topic/jupyterhub-configurator.rst @@ -6,9 +6,6 @@ JupyterHub Configurator The `JupyterHub configurator `_ allows admins to change a subset of hub settings via a GUI. -.. image:: ../images/jupyterhub-configurator.png - :alt: Changing the default JupyterHub interface - Enabling the configurator ========================= From e30a97963921deec7e812801262e68446e8eede2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 28 Nov 2022 09:31:46 +0100 Subject: [PATCH 024/232] docs: refresh conf.py, add opengraph and rediraffe --- docs/conf.py | 128 ++++++++++++++++++++++++++++-------------- docs/requirements.txt | 8 ++- 2 files changed, 90 insertions(+), 46 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e8eec8d..321b5f2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,54 +1,96 @@ -import os +# Configuration file for Sphinx to build our documentation to HTML. +# +# Configuration reference: https://www.sphinx-doc.org/en/master/usage/configuration.html +# +import datetime + +# -- Project information ----------------------------------------------------- +# ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +# +project = "The Littlest JupyterHub" +copyright = f"{datetime.date.today().year}, Project Jupyter Contributors" +author = "Project Jupyter Contributors" + + +# -- General Sphinx configuration --------------------------------------------------- +# ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration +# +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +# +extensions = [ + "sphinx_copybutton", + "sphinxext.opengraph", + "sphinxext.rediraffe", +] +root_doc = "index" source_suffix = [".rst"] -project = "The Littlest JupyterHub" -copyright = "2018, JupyterHub Team" -author = "JupyterHub Team" -# The short X.Y version -version = "" -# The full version, including alpha/beta/rc tags -release = "v0.1" +# -- Options for HTML output ------------------------------------------------- +# ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +# +html_logo = "_static/images/logo/logo.png" +html_favicon = "_static/images/logo/favicon.ico" +html_static_path = ["_static"] -# Enable MathJax for Math -extensions = [ - "sphinx.ext.mathjax", - "sphinx.ext.intersphinx", - "sphinx_copybutton", -] - -# The root toctree document. -root_doc = master_doc = "index" - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path . -exclude_patterns = [ - "_build", - "Thumbs.db", - ".DS_Store", - "install/custom.rst", -] - -intersphinx_mapping = { - "sphinx": ("http://www.sphinx-doc.org/en/master/", None), +# pydata_sphinx_theme reference: https://pydata-sphinx-theme.readthedocs.io/en/stable/index.html +html_theme = "pydata_sphinx_theme" +html_theme_options = { + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/jupyterhub/the-littlest-jupyterhub", + "icon": "fab fa-github-square", + }, + { + "name": "Discourse", + "url": "https://discourse.jupyter.org/c/jupyterhub/tljh/13", + "icon": "fab fa-discourse", + }, + ], + "use_edit_page_button": True, +} +html_context = { + "github_user": "jupyterhub", + "github_repo": "the-littlest-jupyterhub", + "github_version": "main", + "doc_path": "docs", } -intersphinx_cache_limit = 90 # days -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" +# -- Options for linkcheck builder ------------------------------------------- +# ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder +# +linkcheck_ignore = [ + r"(.*)github\.com(.*)#", # javascript based anchors + r"(.*)/#%21(.*)/(.*)", # /#!forum/jupyter - encoded anchor edge case + r"https://github.com/[^/]*$", # too many github usernames / searches in changelog + "https://github.com/jupyterhub/the-littlest-jupyterhub/pull/", # too many PRs in changelog + "https://github.com/jupyterhub/the-littlest-jupyterhub/compare/", # too many comparisons in changelog +] +linkcheck_anchors_ignore = [ + "/#!", + "/#%21", +] -html_theme = "pydata_sphinx_theme" -html_logo = "images/logo/logo.png" -html_favicon = "images/logo/favicon.ico" +# -- Options for the opengraph extension ------------------------------------- +# ref: https://github.com/wpilibsuite/sphinxext-opengraph#options +# +# ogp_site_url is set automatically by RTD +ogp_image = "_static/logo.png" +ogp_use_first_image = True -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# Do this only if _static exists, otherwise this will error -here = os.path.dirname(os.path.abspath(__file__)) -if os.path.exists(os.path.join(here, "_static")): - html_static_path = ["_static"] + +# -- Options for the rediraffe extension ------------------------------------- +# ref: https://github.com/wpilibsuite/sphinxext-rediraffe#readme +# +# This extensions help us relocated content without breaking links. If a +# document is moved internally, we should configure a redirect like below. +# +rediraffe_branch = "main" +rediraffe_redirects = { + # "old-file": "new-folder/new-file-name", +} diff --git a/docs/requirements.txt b/docs/requirements.txt index b55f0e3..7d2b038 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,6 @@ -sphinx>=2 -sphinx-autobuild -sphinx_copybutton pydata-sphinx-theme +sphinx>=4 +sphinx_copybutton +sphinx-autobuild +sphinxext-opengraph +sphinxext-rediraffe From c49eae65bb5aa17308e8a6f7f9efb5c002807a28 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 28 Nov 2022 09:33:51 +0100 Subject: [PATCH 025/232] docs: update rtd config --- .readthedocs.yaml | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3cdde0f..7440511 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,29 +1,17 @@ # Configuration on how ReadTheDocs (RTD) builds our documentation # ref: https://readthedocs.org/projects/the-littlest-jupyterhub/ # ref: https://docs.readthedocs.io/en/stable/config-file/v2.html - -# Required (RTD configuration version) +# version: 2 -# Set the version of Python and other tools you might need -build: - os: ubuntu-20.04 - tools: - python: "3.9" - -# Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py -# Optionally build your docs in additional formats such as PDF and ePub -formats: [] +build: + os: ubuntu-22.04 + tools: + python: "3.11" python: install: - # WARNING: This requirements file will be installed without the pip - # --upgrade flag in an existing environment. This means that if a - # package is specified without a lower boundary, we may end up - # accepting the existing version. - # - # ref: https://github.com/readthedocs/readthedocs.org/blob/0e3df509e7810e46603be47d268273c596e68455/readthedocs/doc_builder/python_environments.py#L335-L344 - requirements: docs/requirements.txt From d4f12e3789ee20f2b84d2ce025a4fbfc7f99a4d4 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Sat, 14 Jan 2023 10:21:34 +0800 Subject: [PATCH 026/232] Update custom-server.rst Just noting that TLJH works on newer versions of ubuntu in docs --- docs/install/custom-server.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install/custom-server.rst b/docs/install/custom-server.rst index bcb616d..cac3060 100644 --- a/docs/install/custom-server.rst +++ b/docs/install/custom-server.rst @@ -31,7 +31,7 @@ Pre-requisites ============== #. Some familiarity with the command line. -#. A server running Ubuntu 18.04 where you have root access. +#. A server running Ubuntu 18.04+ where you have root access. #. At least **1GB** of RAM on your server. #. Ability to ``ssh`` into the server & run commands from the prompt. #. An **IP address** where the server can be reached from the browsers of your target audience. From 3b0ab71fe7dbdd822d79d79ebc210c583b444b2f Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Sat, 14 Jan 2023 10:33:39 +0800 Subject: [PATCH 027/232] General revamp of docs to point to newer ubuntu LTS --- docs/install/amazon.rst | 6 +++--- docs/install/azure.rst | 4 ++-- docs/install/custom-server.rst | 2 +- docs/install/digitalocean.rst | 4 ++-- docs/install/google.rst | 4 ++-- docs/install/index.rst | 2 +- docs/install/jetstream.rst | 6 +++--- docs/install/ovh.rst | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/install/amazon.rst b/docs/install/amazon.rst index e043301..4dbe054 100644 --- a/docs/install/amazon.rst +++ b/docs/install/amazon.rst @@ -58,15 +58,15 @@ Let's create the server on which we can run JupyterHub. #. On the page **Step 1: Choose an Amazon Machine Image (AMI)** you are going to pick the base image your remote server will have. The view will default to the 'Quick-start' tab selected and just a few down the page, select - **Ubuntu Server 18.04 LTS (HVM), SSD Volume Type - ami-XXXXXXXXXXXXXXXXX**, + **Ubuntu Server 22.04 LTS (HVM), SSD Volume Type - ami-XXXXXXXXXXXXXXXXX**, leaving `64-bit (x86)` toggled. .. image:: ../images/providers/amazon/select_ubuntu_18.png - :alt: Click Ubuntu server 18.04 + :alt: Click Ubuntu server 22.04 The `ami` alpha-numeric at the end references the specific Amazon machine image, ignore this as Amazon updates them routinely. The - **Ubuntu Server 18.04 LTS (HVM)** is the important part. + **Ubuntu Server 22.04 LTS (HVM)** is the important part. #. After selecting the AMI, you'll be at **Step 2: Choose an Instance Type**. diff --git a/docs/install/azure.rst b/docs/install/azure.rst index 62f7cf8..3ed81b2 100644 --- a/docs/install/azure.rst +++ b/docs/install/azure.rst @@ -52,7 +52,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. :alt: Create VM from the marketplace #. **Choose an Ubuntu server for your VM**: - * Click `Ubuntu Server 18.04 LTS.` + * Click `Ubuntu Server 22.04 LTS.` * Make sure `Resource Manager` is selected in the next screen and click **Create** .. image:: ../images/providers/azure/ubuntu-vm.png @@ -70,7 +70,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. * **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). + * **Image**. Make sure "Ubuntu Server 22.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. diff --git a/docs/install/custom-server.rst b/docs/install/custom-server.rst index cac3060..a28b45e 100644 --- a/docs/install/custom-server.rst +++ b/docs/install/custom-server.rst @@ -31,7 +31,7 @@ Pre-requisites ============== #. Some familiarity with the command line. -#. A server running Ubuntu 18.04+ where you have root access. +#. A server running Ubuntu 18.04+ where you have root access (Ubuntu 22.04 LTS recommended). #. At least **1GB** of RAM on your server. #. Ability to ``ssh`` into the server & run commands from the prompt. #. An **IP address** where the server can be reached from the browsers of your target audience. diff --git a/docs/install/digitalocean.rst b/docs/install/digitalocean.rst index 38d07a0..cbf40d5 100644 --- a/docs/install/digitalocean.rst +++ b/docs/install/digitalocean.rst @@ -34,10 +34,10 @@ Let's create the server on which we can run JupyterHub. This takes you to a page titled **Create Droplets** that lets you configure your server. -#. Under **Choose an image**, select **18.04 x64** under **Ubuntu**. +#. Under **Choose an image**, select **22.04 x64** under **Ubuntu**. .. image:: ../images/providers/digitalocean/select-image.png - :alt: Select 18.04 x64 image under Ubuntu + :alt: Select 22.04 x64 image under Ubuntu #. Under **Choose a size**, select the size of the server you want. The default (4GB RAM, 2CPUs, 20 USD / month) is not a bad start. You can resize your server diff --git a/docs/install/google.rst b/docs/install/google.rst index f6081d5..d6e38be 100644 --- a/docs/install/google.rst +++ b/docs/install/google.rst @@ -94,10 +94,10 @@ Let's create the server on which we can run JupyterHub. This should open a **Boot disk** popup. -#. Select **Ubuntu 18.04 LTS** from the list of operating system images. +#. Select **Ubuntu 22.04 LTS** from the list of operating system images. .. image:: ../images/providers/google/boot-disk-ubuntu.png - :alt: Selecting Ubuntu 18.04 for OS + :alt: Selecting Ubuntu 22.04 for OS #. You can also change the **type** and **size** of your disk at the bottom of this popup. diff --git a/docs/install/index.rst b/docs/install/index.rst index b783486..086083d 100644 --- a/docs/install/index.rst +++ b/docs/install/index.rst @@ -5,7 +5,7 @@ Installing ========== The Littlest JupyterHub (TLJH) can run on any server that is running at least -**Ubuntu 18.04**. Earlier versions of Ubuntu are not supported. +**Ubuntu 18.04 (22.04 LTS recommended)**. 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 diff --git a/docs/install/jetstream.rst b/docs/install/jetstream.rst index 7f95a3b..e9e55ad 100644 --- a/docs/install/jetstream.rst +++ b/docs/install/jetstream.rst @@ -33,11 +33,11 @@ Let's create the server on which we can run JupyterHub. This takes you to a page with a list of base images you can choose for your server. -#. Under **Image Search**, search for **Ubuntu 18.04**, and select the - **Ubuntu 18.04 Devel and Docker** image. +#. Under **Image Search**, search for **Ubuntu 22.04**, and select the + **Ubuntu 22.04 Devel and Docker** image. .. image:: ../images/providers/jetstream/select-image.png - :alt: Select Ubuntu 18.04 x64 image from image list + :alt: Select Ubuntu 22.04 x64 image from image list #. Once selected, you will see more information about this image. Click the **Launch** button on the top right. diff --git a/docs/install/ovh.rst b/docs/install/ovh.rst index 241368c..8639918 100644 --- a/docs/install/ovh.rst +++ b/docs/install/ovh.rst @@ -52,10 +52,10 @@ Let's create the server on which we can run JupyterHub. #. **Select a region**. -#. Select **Ubuntu 18.04** as the image: +#. Select **Ubuntu 22.04** as the image: .. image:: ../images/providers/ovh/distribution.png - :alt: Select Ubuntu 18.04 as the image + :alt: Select Ubuntu 22.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 From a7774e55ded384336f057c6cb797511dc2a9dd1a Mon Sep 17 00:00:00 2001 From: Pris Nasrat Date: Mon, 30 Jan 2023 11:17:37 -0500 Subject: [PATCH 028/232] Ensure SQLAlchemy 1.x used for hub Fixes #846 --- tljh/installer.py | 1 + tljh/requirements-base.txt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tljh/installer.py b/tljh/installer.py index 42ba070..7e9948d 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -121,6 +121,7 @@ def ensure_jupyterhub_package(prefix): conda.ensure_pip_packages( prefix, [ + "SQLAlchemy<2.0.0", "jupyterhub==1.*", "jupyterhub-systemdspawner==0.16.*", "jupyterhub-firstuseauthenticator==1.*", diff --git a/tljh/requirements-base.txt b/tljh/requirements-base.txt index 02df904..1985db7 100644 --- a/tljh/requirements-base.txt +++ b/tljh/requirements-base.txt @@ -5,6 +5,8 @@ # the requirements-txt-fixer pre-commit hook that sorted them and made # our integration tests fail. # +# For JupyterHub 1.x SQLAlchemy below 2.0.0 +SQLAlchemy<2.0.0 # JupyterHub + notebook package are base requirements for user environment jupyterhub==1.* notebook==6.* From b827de31dda2c08d08110fe0ef81d393dd5db024 Mon Sep 17 00:00:00 2001 From: "James A. Webb, IV" Date: Mon, 6 Feb 2023 09:23:37 -0600 Subject: [PATCH 029/232] Update user-environment.rst Minor typo --- docs/howto/env/user-environment.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/env/user-environment.rst b/docs/howto/env/user-environment.rst index 5ea9fcb..c1f90b9 100644 --- a/docs/howto/env/user-environment.rst +++ b/docs/howto/env/user-environment.rst @@ -170,7 +170,7 @@ To upgrade the Python version of the user environment, one can: * **Upgrade Python manually.** - Because upgrading Python for existing installs can break packages alaredy installed + Because upgrading Python for existing installs can break packages already installed under the old Python, upgrading your current TLJH installation, will NOT upgrade the Python version of the user environment, but you may do so manually. From 5c475b876c74422de81c89f08f41277cd4e62d5e Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 20:08:55 +0000 Subject: [PATCH 030/232] Add debugging info to test_install permissions checks --- integration-tests/test_install.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 4411e10..3f53b5f 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -45,6 +45,12 @@ def test_groups_exist(group): grp.getgrnam(group) +def debug_uid_gid(): + return ( + f"uid={os.getuid()} gid={os.getgid()} euid={os.geteuid()} egid={os.getegid()}" + ) + + def permissions_test(group, path, *, readable=None, writable=None, dirs_only=False): """Run a permissions test on all files in a path path""" # start a subprocess and become nobody:group in the process @@ -88,18 +94,22 @@ def permissions_test(group, path, *, readable=None, writable=None, dirs_only=Fal # check if the path should be writable if writable is not None: if access(path, os.W_OK) != writable: + stat = os.stat(path) + info = pool.submit(debug_uid_gid).result() failures.append( - "{} {} should {}be writable by {}".format( - stat_str, path, "" if writable else "not ", group + "{} {} should {}be writable by {} [{}]".format( + stat_str, path, "" if writable else "not ", group, info ) ) # check if the path should be readable if readable is not None: if access(path, os.R_OK) != readable: + stat = os.stat(path) + info = pool.submit(debug_uid_gid).result() failures.append( - "{} {} should {}be readable by {}".format( - stat_str, path, "" if readable else "not ", group + "{} {} should {}be readable by {} [{}]".format( + stat_str, path, "" if readable else "not ", group, info ) ) # verify that we actually tested some files From 05d46e1d9b6e6974e4e56e10fb002614a2f07720 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 20:28:16 +0000 Subject: [PATCH 031/232] integration-test.py: Add debugging info --- .github/integration-test.py | 64 +++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index 6d05ecb..73ea6d7 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -1,19 +1,56 @@ #!/usr/bin/env python3 import argparse +from shutil import which import subprocess +from time import time import os +def container_runtime(): + runtimes = ["podman", "docker"] + for runtime in runtimes: + if which(runtime): + return runtime + raise RuntimeError(f"No container runtime found, tried: {' '.join(runtimes)}") + + +def container_check_output(*args, **kwargs): + cmd = [container_runtime()] + list(*args) + print(f"Running {cmd} {kwargs}") + return subprocess.check_output(cmd, **kwargs) + + +def container_run(*args, **kwargs): + cmd = [container_runtime()] + list(*args) + print(f"Running {cmd} {kwargs}") + return subprocess.run(cmd, **kwargs) + + def build_systemd_image(image_name, source_path, build_args=None): """ Build docker image with systemd at source_path. Built image is tagged with image_name """ - cmd = ["docker", "build", f"-t={image_name}", source_path] + cmd = ["build", f"-t={image_name}", source_path] if build_args: cmd.extend([f"--build-arg={ba}" for ba in build_args]) - subprocess.check_call(cmd) + container_check_output(cmd) + + +def check_container_ready(container_name, timeout=60): + """ + Check if container is ready to run tests + """ + now = time() + while True: + try: + container_check_output(["exec", "-t", container_name, "id"]) + return + except subprocess.CalledProcessError: + if time() - now > timeout: + raise RuntimeError(f"Container {container_name} hasn't started") + time.sleep(5) def run_systemd_image(image_name, container_name, bootstrap_pip_spec): @@ -25,16 +62,16 @@ def run_systemd_image(image_name, container_name, bootstrap_pip_spec): Container named container_name will be started. """ cmd = [ - "docker", "run", "--privileged", - "--mount=type=bind,source=/sys/fs/cgroup,target=/sys/fs/cgroup", "--detach", f"--name={container_name}", # A bit less than 1GB to ensure TLJH runs on 1GB VMs. # If this is changed all docs references to the required memory must be changed too. "--memory=900m", ] + if container_runtime() != "podman": + cmd.append("--mount=type=bind,source=/sys/fs/cgroup,target=/sys/fs/cgroup") if bootstrap_pip_spec: cmd.append("-e") @@ -42,7 +79,7 @@ def run_systemd_image(image_name, container_name, bootstrap_pip_spec): cmd.append(image_name) - subprocess.check_call(cmd) + container_check_output(cmd) def stop_container(container_name): @@ -50,21 +87,20 @@ def stop_container(container_name): Stop & remove docker container if it exists. """ try: - subprocess.check_output( - ["docker", "inspect", container_name], stderr=subprocess.STDOUT - ) + container_check_output(["inspect", container_name], stderr=subprocess.STDOUT) except subprocess.CalledProcessError: # No such container exists, nothing to do return - subprocess.check_call(["docker", "rm", "-f", container_name]) + container_check_output(["rm", "-f", container_name]) def run_container_command(container_name, cmd): """ Run cmd in a running container with a bash shell """ - proc = subprocess.run( - ["docker", "exec", "-t", container_name, "/bin/bash", "-c", cmd], check=True + proc = container_run( + ["exec", "-t", container_name, "/bin/bash", "-c", cmd], + check=True, ) @@ -72,7 +108,7 @@ def copy_to_container(container_name, src_path, dest_path): """ Copy files from src_path to dest_path inside container_name """ - subprocess.check_call(["docker", "cp", src_path, f"{container_name}:{dest_path}"]) + container_check_output(["cp", src_path, f"{container_name}:{dest_path}"]) def run_test( @@ -84,6 +120,8 @@ def run_test( stop_container(test_name) run_systemd_image(image_name, test_name, bootstrap_pip_spec) + check_container_ready(test_name) + source_path = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) copy_to_container(test_name, os.path.join(source_path, "bootstrap/."), "/srv/src") @@ -93,7 +131,7 @@ def run_test( # These logs can be very relevant to debug a container startup failure print(f"--- Start of logs from the container: {test_name}") - print(subprocess.check_output(["docker", "logs", test_name]).decode()) + print(container_check_output(["logs", test_name]).decode()) print(f"--- End of logs from the container: {test_name}") # Install TLJH from the default branch first to test upgrades From ae5fd645762b498795b43595bbdd5163cef1c2b9 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 21:16:46 +0000 Subject: [PATCH 032/232] integration-test.py: try docker first --- .github/integration-test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index 73ea6d7..c6aec7f 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -7,7 +7,7 @@ import os def container_runtime(): - runtimes = ["podman", "docker"] + runtimes = ["docker", "podman"] for runtime in runtimes: if which(runtime): return runtime From 20c6a851de86984d36217fbaf6a9230bcc844a0b Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 21:28:43 +0000 Subject: [PATCH 033/232] integration-test.py: fix time delay check --- .github/integration-test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index c6aec7f..0dc93e4 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -2,7 +2,7 @@ import argparse from shutil import which import subprocess -from time import time +import time import os @@ -42,13 +42,13 @@ def check_container_ready(container_name, timeout=60): """ Check if container is ready to run tests """ - now = time() + now = time.time() while True: try: container_check_output(["exec", "-t", container_name, "id"]) return except subprocess.CalledProcessError: - if time() - now > timeout: + if time.time() - now > timeout: raise RuntimeError(f"Container {container_name} hasn't started") time.sleep(5) From 6413268211eb72cb1ad3b2cc034c3ea1d69e50ba Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 22:36:09 +0000 Subject: [PATCH 034/232] use id to debug test_install.py --- integration-tests/test_install.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 3f53b5f..866769e 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -46,9 +46,10 @@ def test_groups_exist(group): def debug_uid_gid(): - return ( - f"uid={os.getuid()} gid={os.getgid()} euid={os.geteuid()} egid={os.getegid()}" - ) + return subprocess.check_output("id").decode() + # return ( + # f"uid={os.getuid()} gid={os.getgid()} euid={os.geteuid()} egid={os.getegid()}" + # ) def permissions_test(group, path, *, readable=None, writable=None, dirs_only=False): From 967348069f6c002f088d280af18e60d6b2ce23be Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 23:15:20 +0000 Subject: [PATCH 035/232] Add `inspect` to container debugging --- .github/integration-test.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index 0dc93e4..c67e273 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -45,9 +45,21 @@ def check_container_ready(container_name, timeout=60): now = time.time() while True: try: - container_check_output(["exec", "-t", container_name, "id"]) + out = container_check_output(["exec", "-t", container_name, "id"]) + print(out.decode()) return - except subprocess.CalledProcessError: + except subprocess.CalledProcessError as e: + print(e) + try: + out = container_check_output(["inspect", container_name]) + print(out.decode()) + except subprocess.CalledProcessError as e: + print(e) + try: + out = container_check_output(["logs", container_name]) + print(out.decode()) + except subprocess.CalledProcessError as e: + print(e) if time.time() - now > timeout: raise RuntimeError(f"Container {container_name} hasn't started") time.sleep(5) From 57b0ed89b3641e883a6fd481a7eb98b8cff5f814 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 23:48:17 +0000 Subject: [PATCH 036/232] Try ignoring `/sys/fs/cgroup` mount --- .github/integration-test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index c67e273..a756711 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -82,8 +82,8 @@ def run_systemd_image(image_name, container_name, bootstrap_pip_spec): # If this is changed all docs references to the required memory must be changed too. "--memory=900m", ] - if container_runtime() != "podman": - cmd.append("--mount=type=bind,source=/sys/fs/cgroup,target=/sys/fs/cgroup") + # if container_runtime() != "podman": + # cmd.append("--mount=type=bind,source=/sys/fs/cgroup,target=/sys/fs/cgroup") if bootstrap_pip_spec: cmd.append("-e") From acd64c277e445e74e32a9cf135c25900369fd017 Mon Sep 17 00:00:00 2001 From: Pris Nasrat Date: Tue, 7 Feb 2023 10:35:19 -0500 Subject: [PATCH 037/232] Drop supplemental groups in install testing --- integration-tests/test_install.py | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 866769e..4dfa9eb 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -35,6 +35,7 @@ def setgroup(group): gid = grp.getgrnam(group).gr_gid uid = pwd.getpwnam("nobody").pw_uid os.setgid(gid) + os.setgroups([]) os.setuid(uid) os.environ["HOME"] = "/tmp/test-home-%i-%i" % (uid, gid) From 4eb2055e15365c87444daa57ad0a67f2b6146173 Mon Sep 17 00:00:00 2001 From: Pris Nasrat Date: Tue, 7 Feb 2023 11:41:09 -0500 Subject: [PATCH 038/232] Fix rtd build Pin Sphinx to below 6.0.0 see pydata/pydata-sphinx-theme#1094 --- docs/requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 7d2b038..2de526f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,7 @@ pydata-sphinx-theme -sphinx>=4 +# Sphix 6.0.0 breaks pydata-sphinx-theme +# See pydata/pydata-sphinx-theme#1094 +sphinx<6 sphinx_copybutton sphinx-autobuild sphinxext-opengraph From 4a701d2e1c8f27edec29db0041ba890eacafe956 Mon Sep 17 00:00:00 2001 From: Pris Nasrat Date: Thu, 9 Feb 2023 07:28:18 -0500 Subject: [PATCH 039/232] Remove commented out code in uid/gid debugging --- .github/integration-test.py | 2 -- integration-tests/test_install.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index a756711..25af580 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -82,8 +82,6 @@ def run_systemd_image(image_name, container_name, bootstrap_pip_spec): # If this is changed all docs references to the required memory must be changed too. "--memory=900m", ] - # if container_runtime() != "podman": - # cmd.append("--mount=type=bind,source=/sys/fs/cgroup,target=/sys/fs/cgroup") if bootstrap_pip_spec: cmd.append("-e") diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 4dfa9eb..1391ddd 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -48,9 +48,6 @@ def test_groups_exist(group): def debug_uid_gid(): return subprocess.check_output("id").decode() - # return ( - # f"uid={os.getuid()} gid={os.getgid()} euid={os.geteuid()} egid={os.getegid()}" - # ) def permissions_test(group, path, *, readable=None, writable=None, dirs_only=False): From 9803df1d446fb0d419e3e72e018026a5850eb130 Mon Sep 17 00:00:00 2001 From: Pris Nasrat Date: Thu, 9 Feb 2023 10:39:02 -0500 Subject: [PATCH 040/232] Trigger CI GitHub workflow in case flaky From 7699209bc35e8bf79c2db7770dae5ecc3b20a024 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 00:04:00 +0000 Subject: [PATCH 041/232] Switch default installed version to `latest` This will correspond to the most recent release tag (excludes pre-releases) --- bootstrap/bootstrap.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 943fcb9..ba2d4ab 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -335,7 +335,14 @@ def main(): parser = ArgumentParser() parser.add_argument("--show-progress-page", action="store_true") parser.add_argument( - "--version", default="main", help="TLJH version or Git reference" + "--version", + default="latest", + help=( + "TLJH version or Git reference. " + "Default 'latest' is the most recent release. " + "Partial versions can be specified, for example '1', '1.0' or '1.0.0'. " + "You can also pass a branch name such as 'main' or a commit hash." + ), ) args, tljh_installer_flags = parser.parse_known_args() From 8e7f534b4b1b91eeb92ae0eaad980e6a46d53833 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 00:09:29 +0000 Subject: [PATCH 042/232] changelog.md: `github-activity --since 4a74ad17a1a19f6378efe12a01ba634ed90f1e03` --- changelog.md | 328 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 changelog.md diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..e0a5e23 --- /dev/null +++ b/changelog.md @@ -0,0 +1,328 @@ +# 4a74ad17a1a19f6378efe12a01ba634ed90f1e03...master@{2022-11-27} + +([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...7fb84aad5642dd5d0ec5a3c4c238848081678919)) + +## Enhancements made + +- bootstrap script accepts a version [#819](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/819) ([@manics](https://github.com/manics)) +- ENH: add logging if user-requirements-txt-url found [#796](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/796) ([@raybellwaves](https://github.com/raybellwaves)) +- Add support for installing TLJH on Arm64 systems and bump traefik (1.7.18 -> 1.7.33) [#679](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/679) ([@cdibble](https://github.com/cdibble)) + +## Bugs fixed + +- Don't open file twice when downloading conda [#717](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/717) ([@yuvipanda](https://github.com/yuvipanda)) + +## Maintenance and upkeep improvements + +- Update precommit [#820](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/820) ([@manics](https://github.com/manics)) +- pre-commit: apply black formatting (and prettier on one yaml file) [#755](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/755) ([@consideRatio](https://github.com/consideRatio)) +- Update firstuseauthenticator to 1.0.0 [#749](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/749) ([@consideRatio](https://github.com/consideRatio)) +- Add .pre-commit-config [#748](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/748) ([@consideRatio](https://github.com/consideRatio)) +- docs: require sphinx>=2, otherwise error follows [#743](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/743) ([@consideRatio](https://github.com/consideRatio)) +- Refactor bootstrap.py script for readability [#715](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/715) ([@consideRatio](https://github.com/consideRatio)) +- Remove template in root folder - a mistakenly committed file [#713](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/713) ([@consideRatio](https://github.com/consideRatio)) +- Switch to Mamba [#697](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/697) ([@manics](https://github.com/manics)) + +## Documentation improvements + +- docs: reference nbgitpullers docs to fix outdated tljh docs [#826](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/826) ([@rdmolony](https://github.com/rdmolony)) +- DOC: update sudo tljh-config --help demo [#785](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/785) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: add tljh-db plugin to list [#782](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/782) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: move link to contributing/plugin higher [#781](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/781) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: update info on AWS get system log [#772](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/772) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: hyperlink there [#768](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/768) ([@raybellwaves](https://github.com/raybellwaves)) +- docs: fix how-to sections table of content section [#742](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/742) ([@consideRatio](https://github.com/consideRatio)) +- update awscognito docs to use GenericOAuthenticator [#729](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/729) ([@minrk](https://github.com/minrk)) +- docs: fix language regarding master [#718](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/718) ([@consideRatio](https://github.com/consideRatio)) +- Try setting min. req to 1GB of RAM [#716](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/716) ([@yuvipanda](https://github.com/yuvipanda)) +- Revision of our GitHub Workflows and README.rst to README.md [#710](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/710) ([@consideRatio](https://github.com/consideRatio)) +- Reflect the fact that AWS free tier is not enough [#696](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/696) ([@Guillaume-Garrigos](https://github.com/Guillaume-Garrigos)) +- Added instructions for restarting JupyterHub to docs (re: #455) [#666](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/666) ([@DataCascadia](https://github.com/DataCascadia)) +- DOC: moved nativeauthentic config instructions to code block [#294](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/294) ([@story645](https://github.com/story645)) +- Document tljh-config commands by referencing the --help sections [#213](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/213) ([@gillybops](https://github.com/gillybops)) + +## Other merged PRs + +- ci: fix deprecation of set-output in github workflows [#837](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/837) ([@consideRatio](https://github.com/consideRatio)) +- Fix typo with --show-progress-page argument in example [#835](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/835) ([@luong-komorebi](https://github.com/luong-komorebi)) +- ci: add dependabot for github actions and bump them now [#831](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/831) ([@consideRatio](https://github.com/consideRatio)) +- ci: run int. and unit tests on 22.04 LTS + py3.10 [#817](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/817) ([@MridulS](https://github.com/MridulS)) +- clarify direction of information in idle-culler [#816](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/816) ([@minrk](https://github.com/minrk)) +- Update progress_page_favicon_url link [#811](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/811) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Bump systemdspawner version [#810](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/810) ([@yuvipanda](https://github.com/yuvipanda)) +- github workflow: echo $BOOTSTRAP_PIP_SPEC [#801](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/801) ([@manics](https://github.com/manics)) +- extra logger.info [#789](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/789) ([@raybellwaves](https://github.com/raybellwaves)) +- updating 'plugin' documentation [#764](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/764) ([@oisinBates](https://github.com/oisinBates)) +- pre-commit: remove requirements-txt-fixer [#754](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/754) ([@consideRatio](https://github.com/consideRatio)) +- Small fixes for flake8 and other smaller pre-commit tools [#747](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/747) ([@consideRatio](https://github.com/consideRatio)) +- remove addressed FIXMEs in update_auth [#745](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/745) ([@minrk](https://github.com/minrk)) +- Remove MockConfigurer [#744](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/744) ([@minrk](https://github.com/minrk)) +- Modernize docs Makefile with sphinx-autobuild [#741](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/741) ([@consideRatio](https://github.com/consideRatio)) +- Apply TLJH auth config with less assumptions [#721](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/721) ([@consideRatio](https://github.com/consideRatio)) +- Bump to recent versions, and make bootstrap.py update to those when run [#719](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/719) ([@consideRatio](https://github.com/consideRatio)) +- ci: add .readthedocs.yaml [#712](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/712) ([@consideRatio](https://github.com/consideRatio)) +- Bump nbgitpuller version [#704](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/704) ([@yuvipanda](https://github.com/yuvipanda)) +- Bump notebook from 6.3.0 to 6.4.1 in /tljh [#703](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/703) ([@dependabot](https://github.com/dependabot)) +- Bump hub and notebook versions [#688](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/688) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- bump nativeauthenticator version to avoid critical bug [#683](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/683) ([@ibayer](https://github.com/ibayer)) +- Add "Users Lists" example [#682](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/682) ([@jeanmarcalkazzi](https://github.com/jeanmarcalkazzi)) +- Add missing configurator config [#680](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/680) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Revert "Revert "Switch integration and upgrade tests from CircleCI to GitHub actions"" [#678](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/678) ([@yuvipanda](https://github.com/yuvipanda)) +- Revert "Switch integration and upgrade tests from CircleCI to GitHub actions" [#677](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/677) ([@yuvipanda](https://github.com/yuvipanda)) +- Add the jupyterhub-configurator service [#676](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/676) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Switch integration and upgrade tests from CircleCI to GitHub actions [#673](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/673) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Switch unit tests from CircleCI to GitHub actions [#672](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/672) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Note smallest AWS instance TLJH can run on [#671](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/671) ([@yuvipanda](https://github.com/yuvipanda)) +- Pin chardet again and pin it for tests also. [#668](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/668) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Bump traefik-proxy version and remove pin. [#667](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/667) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Add docs to override systemd settings [#663](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/663) ([@jtpio](https://github.com/jtpio)) +- Docs: add missing gif for the TLJH is building page [#662](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/662) ([@jtpio](https://github.com/jtpio)) +- Upgrade to Jupyterlab 3.0 and Jupyter Resource Usage [#658](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/658) ([@jtpio](https://github.com/jtpio)) +- Fix code formatting in the docs [#657](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/657) ([@jtpio](https://github.com/jtpio)) +- setup.py: Update repo URL [#656](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/656) ([@jayvdb](https://github.com/jayvdb)) +- Own server install sets admin password in step 3 [#652](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/652) ([@leouieda](https://github.com/leouieda)) +- Fix link to resource estimation in server requirements docs [#651](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/651) ([@jtpio](https://github.com/jtpio)) +- Revert and pin notebook version [#648](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/648) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Upgrade to JupyterLab 3.0 [#647](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/647) ([@yuvipanda](https://github.com/yuvipanda)) +- Pin chardet [#643](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/643) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- bump systemdspawner to 0.15 [#639](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/639) ([@minrk](https://github.com/minrk)) +- Doc of how users can change password [#637](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/637) ([@mauro3](https://github.com/mauro3)) +- Add a necessary step to reset password [#636](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/636) ([@mauro3](https://github.com/mauro3)) +- Bump a few of the dependencies [#634](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/634) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- proposed changes for issue #619 [#633](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/633) ([@ewidl](https://github.com/ewidl)) +- how to call sudo with changed path [#632](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/632) ([@namin](https://github.com/namin)) +- Bump memory again for integration tests [#630](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/630) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix html_sidebars [#625](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/625) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix doc build [#624](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/624) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Add base_url capability to tljh-config [#623](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/623) ([@jeanmarcalkazzi](https://github.com/jeanmarcalkazzi)) +- Fix HTML of bootstrap [#621](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/621) ([@richardbrinkman](https://github.com/richardbrinkman)) +- Add link to jupyterhub-idle-culler [#607](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/607) ([@1kastner](https://github.com/1kastner)) +- Temporary page while tljh is building [#605](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/605) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Bump systemdspawner [#602](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/602) ([@yuvipanda](https://github.com/yuvipanda)) +- Remove CircleCi docs build [#600](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/600) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- ensure_server is now ensure_server_simulate [#599](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/599) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Use http port from config while checking hub [#598](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/598) ([@dongmok](https://github.com/dongmok)) +- add -L option to curl to follow redirect [#593](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/593) ([@LTangaF](https://github.com/LTangaF)) +- Upgrade JupyterLab version [#591](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/591) ([@yuvipanda](https://github.com/yuvipanda)) +- Use tljh.jupyter.org/bootstrap.py to get installer [#590](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/590) ([@yuvipanda](https://github.com/yuvipanda)) +- Use /hub/api endpoint to check for hub ready [#587](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/587) ([@jtpio](https://github.com/jtpio)) +- Allow extending traefik dynamic config [#586](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/586) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Allow extending traefik config [#582](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/582) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Provide more memory for integration tests [#580](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/580) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fixed git repo link from markdown to rst [#579](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/579) ([@danlester](https://github.com/danlester)) +- Use sha256 sums for verifying miniconda download [#570](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/570) ([@yuvipanda](https://github.com/yuvipanda)) +- Add a useful link to the git repo, fix a typo, in docs [#568](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/568) ([@danlester](https://github.com/danlester)) +- Add tljh-repo2docker to the list of plugins [#567](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/567) ([@jtpio](https://github.com/jtpio)) +- Rename to --bootstrap-pip-spec in the integration tests [#566](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/566) ([@jtpio](https://github.com/jtpio)) +- Make bootstrap_pip_spec test argument optional [#563](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/563) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Add documentation to install multiple plugins [#561](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/561) ([@jtpio](https://github.com/jtpio)) +- Remove unused plugins argument from run_plugin_actions [#560](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/560) ([@jtpio](https://github.com/jtpio)) +- Use idle culler from jupyterhub-idle-culler package [#559](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/559) ([@yuvipanda](https://github.com/yuvipanda)) +- Add bootstrap pip spec to the integration test docs [#558](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/558) ([@jtpio](https://github.com/jtpio)) +- Fix failing unit test [#553](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/553) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fixes 'availabe' > 'available' spelling in docs [#552](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/552) ([@sethwoodworth](https://github.com/sethwoodworth)) +- Add a section about known TLJH plugins to the documentation [#551](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/551) ([@jtpio](https://github.com/jtpio)) +- Provide instructions on how to revert each action of the installer [#545](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/545) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix code block formatting in the docs [#541](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/541) ([@jtpio](https://github.com/jtpio)) +- Update the docs theme to pydata-sphinx-theme [#538](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/538) ([@jtpio](https://github.com/jtpio)) +- Update hub packages to the latest stable versions [#537](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/537) ([@jtpio](https://github.com/jtpio)) +- Add a quick note about DNS records [#532](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/532) ([@jtpio](https://github.com/jtpio)) +- Use PR username when no CircleCI project [#531](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/531) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix typo in --user-requirements-txt-url help [#527](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/527) ([@jtpio](https://github.com/jtpio)) +- Fix installer [#519](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/519) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Use the same 1-100 numbers as in the docs and repo description [#516](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/516) ([@jtpio](https://github.com/jtpio)) +- Remove configurable-http-proxy references from docs #494 [#514](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/514) ([@shireenrao](https://github.com/shireenrao)) +- Update tests [#511](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/511) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix missing reference to requirements-base.txt [#504](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/504) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Upgrade jupyterlab to 1.2.6 [#499](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/499) ([@letianw91](https://github.com/letianw91)) +- Set tls 1.2 to be the min version [#498](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/498) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- [MRG] Fix integration test for new pip [#491](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/491) ([@betatim](https://github.com/betatim)) +- [MRG] Link contributing guide [#489](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/489) ([@betatim](https://github.com/betatim)) +- Fix broken link to resource estimation page [#485](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/485) ([@leouieda](https://github.com/leouieda)) +- Fix failing integration tests [#479](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/479) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Upgrade authenticators [#476](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/476) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Added AWS Cognito docs [#472](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/472) ([@budgester](https://github.com/budgester)) +- Switch to pandas theme [#468](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/468) ([@yuvipanda](https://github.com/yuvipanda)) +- installation failed due to no python3-dev packages [#460](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/460) ([@afonit](https://github.com/afonit)) +- [MRG] Azure docs - add details on the new Azure deploy button [#458](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/458) ([@trallard](https://github.com/trallard)) +- switch base environment to requirements file [#457](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/457) ([@minrk](https://github.com/minrk)) +- Add hook for new users [#453](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/453) ([@jkfm](https://github.com/jkfm)) +- Write out deb line only if it already doesn't exist [#449](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/449) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- [Ready to merge] - Update Azure docs [#448](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/448) ([@trallard](https://github.com/trallard)) +- Update Amazon AMI selection step [#443](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/443) ([@fomightez](https://github.com/fomightez)) +- Upgrade traefik version [#442](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/442) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Disable ProtectHome=tmpfs [#435](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/435) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Make Python3.7 the default [#433](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/433) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix failing conda tests [#423](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/423) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- fixed typo in key pair section [#421](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/421) ([@ptcane](https://github.com/ptcane)) +- HowTo Google authenticate [#404](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/404) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Docs update: reload proxy after modifying the ports [#403](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/403) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Allow adding multiple admins during install [#399](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/399) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Set admin password during install [#395](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/395) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- fixing typo (remove "can add rules") in amazon.rst [#393](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/393) ([@cornhundred](https://github.com/cornhundred)) +- Import containers from collections.abc rather than collections [#392](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/392) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fix link to the hooks in plugins docs [#390](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/390) ([@jtpio](https://github.com/jtpio)) +- Add tljh_post_install hook [#389](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/389) ([@jtpio](https://github.com/jtpio)) +- Run idle culler as a python module [#386](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/386) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Replace pre-alpha by beta state in documentation [#385](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/385) ([@lumbric](https://github.com/lumbric)) +- Allow adding users to specific groups [#382](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/382) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Tell apt-get to never ask questions [#380](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/380) ([@yuvipanda](https://github.com/yuvipanda)) +- Typo fix: `s` -> `is` [#376](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/376) ([@jtpio](https://github.com/jtpio)) +- Fix typo: missing "c" for instance [#374](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/374) ([@jtpio](https://github.com/jtpio)) +- Minor typo fix: praticular -> particular [#372](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/372) ([@jtpio](https://github.com/jtpio)) +- Add Tutorial for OVH [#371](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/371) ([@jtpio](https://github.com/jtpio)) +- Clarify the steps to build the docs locally [#370](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/370) ([@jtpio](https://github.com/jtpio)) +- Fix typo in README link [#367](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/367) ([@pbugnion](https://github.com/pbugnion)) +- Add idle culler [#366](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/366) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Add tmpauthenticator by default to TLJH [#365](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/365) ([@yuvipanda](https://github.com/yuvipanda)) +- Docs addition [#364](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/364) ([@kafonek](https://github.com/kafonek)) +- Fix typo: cohnfig -> config [#363](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/363) ([@staeiou](https://github.com/staeiou)) +- Add port configuration to docs [#362](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/362) ([@staeiou](https://github.com/staeiou)) +- Add custom hub package & config hooks [#360](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/360) ([@yuvipanda](https://github.com/yuvipanda)) +- Install & use pycurl for requests [#359](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/359) ([@yuvipanda](https://github.com/yuvipanda)) +- Minor azure doc cleanup [#358](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/358) ([@yuvipanda](https://github.com/yuvipanda)) +- Suppress insecure HTTPS warning when upgrading TLJH [#357](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/357) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Fixed out of date config directory listed in docs for tljh-config [#355](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/355) ([@JuanCab](https://github.com/JuanCab)) +- Add "tljh-config unset" option [#352](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/352) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Upgrade while https enabled [#347](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/347) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Remove stray .DS_Store files [#343](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/343) ([@yuvipanda](https://github.com/yuvipanda)) +- Add instructions to deploy on Azure [#342](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/342) ([@trallard](https://github.com/trallard)) +- Add more validation to bootstrap.py [#340](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/340) ([@yuvipanda](https://github.com/yuvipanda)) +- Retry downloading traefik if it fails [#339](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/339) ([@yuvipanda](https://github.com/yuvipanda)) +- Provide much better error messages [#337](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/337) ([@yuvipanda](https://github.com/yuvipanda)) +- Limit memory available in integration tests [#335](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/335) ([@yuvipanda](https://github.com/yuvipanda)) +- Remove stray = in authenticator configuration example [#331](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/331) ([@yuvipanda](https://github.com/yuvipanda)) +- Minor cleanup of custom server install documents [#329](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/329) ([@yuvipanda](https://github.com/yuvipanda)) +- Cleanup HTTPS documentation [#328](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/328) ([@yuvipanda](https://github.com/yuvipanda)) +- Add note about not running on your own laptop or in Docker [#327](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/327) ([@yuvipanda](https://github.com/yuvipanda)) +- Use c.Spawner to set mem_limit & cpu_limit [#326](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/326) ([@yuvipanda](https://github.com/yuvipanda)) +- Few updates from reading through the docs [#325](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/325) ([@znicholls](https://github.com/znicholls)) +- Remove repeated sentence from README.rst [#324](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/324) ([@MayeulC](https://github.com/MayeulC)) +- Remove ominous warning with outdated release date [#320](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/320) ([@yuvipanda](https://github.com/yuvipanda)) +- Move digital ocean 'resize' docs out of 'install' step [#319](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/319) ([@yuvipanda](https://github.com/yuvipanda)) +- Update Readme for the AWS docs link [#317](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/317) ([@shireenrao](https://github.com/shireenrao)) +- Upgrade to JupyterHub 1.0 [#313](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/313) ([@minrk](https://github.com/minrk)) +- Bump JupyterHub and systemdspawner versions [#311](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/311) ([@yuvipanda](https://github.com/yuvipanda)) +- adding sidebar links [#309](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/309) ([@choldgraf](https://github.com/choldgraf)) +- Change style to match Jhub main doc [#304](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/304) ([@leportella](https://github.com/leportella)) +- [MRG] Fix the version tag of the notebook package [#303](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/303) ([@betatim](https://github.com/betatim)) +- Bump jupyterhub version [#297](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/297) ([@yuvipanda](https://github.com/yuvipanda)) +- Update / clarify / shorten docs, add missing image from AWS install [#296](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/296) ([@laxdog](https://github.com/laxdog)) +- Pin tornado to <6 [#292](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/292) ([@willirath](https://github.com/willirath)) +- typo fix in installer actions [#287](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/287) ([@junctionapps](https://github.com/junctionapps)) +- Add NativeAuth as an optional authenticator [#284](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/284) ([@leportella](https://github.com/leportella)) +- update dev-setup commands [#276](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/276) ([@minrk](https://github.com/minrk)) +- single yaml implementation [#275](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/275) ([@minrk](https://github.com/minrk)) +- updating the image size text [#271](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/271) ([@choldgraf](https://github.com/choldgraf)) +- Run fix-permissions on each install command [#268](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/268) ([@minrk](https://github.com/minrk)) +- Replace chp with traefik-proxy [#266](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/266) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Use --sys-prefix for installing nbextensions [#265](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/265) ([@yuvipanda](https://github.com/yuvipanda)) +- Mark flaky test as flaky [#262](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/262) ([@yuvipanda](https://github.com/yuvipanda)) +- fix GitHub login config missing callback URL [#261](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/261) ([@huhuhang](https://github.com/huhuhang)) +- Use newer firstuseauthenticator [#260](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/260) ([@willirath](https://github.com/willirath)) +- Install git explicitly during bootstrap [#254](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/254) ([@yuvipanda](https://github.com/yuvipanda)) +- Move custom server troubleshooting code to its own page [#253](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/253) ([@yuvipanda](https://github.com/yuvipanda)) +- Add ipywidgets to base installation [#249](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/249) ([@yuvipanda](https://github.com/yuvipanda)) +- Use tljh logger in installer [#248](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/248) ([@fm75](https://github.com/fm75)) +- Fixing RTD badge [#244](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/244) ([@choldgraf](https://github.com/choldgraf)) +- Adds the universe repository to the used sources [#242](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/242) ([@owah](https://github.com/owah)) +- Update nodejs to 10.x LTS [#238](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/238) ([@yuvipanda](https://github.com/yuvipanda)) +- Exit when tljh-config is called as non-root [#232](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/232) ([@yuvipanda](https://github.com/yuvipanda)) +- Documentation behind proxy [#230](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/230) ([@fm75](https://github.com/fm75)) +- Removed duplicate 'the' in docs [#227](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/227) ([@altmas5](https://github.com/altmas5)) +- consolidate yaml configuration [#224](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/224) ([@minrk](https://github.com/minrk)) +- Provide better error message when running on unsupported distro [#221](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/221) ([@yuvipanda](https://github.com/yuvipanda)) +- Upgrade package versions [#215](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/215) ([@yuvipanda](https://github.com/yuvipanda)) +- add warning if tljh-config is called as non-root user [#209](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/209) ([@anyushevai](https://github.com/anyushevai)) +- updating theme and storing docs artifacts [#205](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/205) ([@choldgraf](https://github.com/choldgraf)) +- No memory limit (continued) [#202](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/202) ([@betatim](https://github.com/betatim)) +- enabling jupyter contributed extensions [#201](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/201) ([@wrightaprilm](https://github.com/wrightaprilm)) +- Update docs.rst [#196](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/196) ([@jzf2101](https://github.com/jzf2101)) +- Fix minor typo: pypy -> pypi [#194](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/194) ([@jtpio](https://github.com/jtpio)) +- Issue#182: add amazon installation tutorial [#189](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/189) ([@fomightez](https://github.com/fomightez)) +- small typo in docs [#184](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/184) ([@choldgraf](https://github.com/choldgraf)) +- adding update on resizing droplet [#181](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/181) ([@wrightaprilm](https://github.com/wrightaprilm)) +- Normalize systemuser [#179](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/179) ([@yuvipanda](https://github.com/yuvipanda)) +- Remove extra space after opening paren [#178](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/178) ([@yuvipanda](https://github.com/yuvipanda)) +- Bump firstuseauthenticator version [#175](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/175) ([@yuvipanda](https://github.com/yuvipanda)) +- typo questoins -> questions. [#174](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/174) ([@Carreau](https://github.com/Carreau)) +- Remind to use https on custom-servers. [#170](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/170) ([@Carreau](https://github.com/Carreau)) +- Don't create home publicly readable [#169](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/169) ([@Carreau](https://github.com/Carreau)) +- installer.py: remove unused f"..." [#167](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/167) ([@gyg-github](https://github.com/gyg-github)) +- put config in `$tljh/config` directory [#163](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/163) ([@minrk](https://github.com/minrk)) +- missing arguments in integration test commands [#162](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/162) ([@minrk](https://github.com/minrk)) +- test manual https setup [#161](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/161) ([@minrk](https://github.com/minrk)) +- jupyterhub 0.9.2 [#160](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/160) ([@minrk](https://github.com/minrk)) +- Fix some typos [#159](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/159) ([@Carreau](https://github.com/Carreau)) +- Upgrade to latest version of JupyterLab [#152](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/152) ([@yuvipanda](https://github.com/yuvipanda)) +- polish local server install [#151](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/151) ([@Carreau](https://github.com/Carreau)) +- Don't capture stderr when calling conda [#149](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/149) ([@yuvipanda](https://github.com/yuvipanda)) +- Fix link to custom server install [#143](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/143) ([@jprorama](https://github.com/jprorama)) +- Copybutton fix [#140](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/140) ([@choldgraf](https://github.com/choldgraf)) +- Install jupyterhub extension for jupyterlab [#139](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/139) ([@yuvipanda](https://github.com/yuvipanda)) +- Use node 8, not 10 [#138](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/138) ([@yuvipanda](https://github.com/yuvipanda)) +- Added existing property-path for tljh-config set method [#137](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/137) ([@ynnelson](https://github.com/ynnelson)) +- Move tljh-config symlink to /usr/bin [#135](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/135) ([@yuvipanda](https://github.com/yuvipanda)) +- Remove readthedocs.yml file [#131](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/131) ([@yuvipanda](https://github.com/yuvipanda)) +- Switch back to a venv for docs + fix .circle config [#130](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/130) ([@yuvipanda](https://github.com/yuvipanda)) +- Make it easier to run multiple independent integration tests [#129](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/129) ([@yuvipanda](https://github.com/yuvipanda)) +- Add plugin support to the installer [#127](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/127) ([@yuvipanda](https://github.com/yuvipanda)) +- removing extra copybutton files [#126](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/126) ([@choldgraf](https://github.com/choldgraf)) +- adding copy button to code blocks and fixing the integration bug [#124](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/124) ([@choldgraf](https://github.com/choldgraf)) +- [MRG] updating content from zexuan's user test [#123](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/123) ([@choldgraf](https://github.com/choldgraf)) +- Remove extreneous = [#119](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/119) ([@yuvipanda](https://github.com/yuvipanda)) +- adding when to use tljh page [#118](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/118) ([@choldgraf](https://github.com/choldgraf)) +- adding documentation for GitHub OAuth [#117](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/117) ([@choldgraf](https://github.com/choldgraf)) +- Fix quick links in README [#113](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/113) ([@willirath](https://github.com/willirath)) +- Install nbresuse by default [#111](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/111) ([@yuvipanda](https://github.com/yuvipanda)) +- Re-organize installation documentation [#110](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/110) ([@yuvipanda](https://github.com/yuvipanda)) +- [MRG] Adding CI for documentation and fixing docs warnings [#107](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/107) ([@betatim](https://github.com/betatim)) +- shared data and username emphasis [#103](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/103) ([@choldgraf](https://github.com/choldgraf)) +- unittests for traefik [#96](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/96) ([@minrk](https://github.com/minrk)) +- fix coverage uploads [#95](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/95) ([@minrk](https://github.com/minrk)) +- Symlink tljh-config to /usr/local/bin [#94](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/94) ([@yuvipanda](https://github.com/yuvipanda)) +- Document code-review practices [#93](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/93) ([@yuvipanda](https://github.com/yuvipanda)) +- small updates to the docs [#91](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/91) ([@choldgraf](https://github.com/choldgraf)) +- tests and fixes in tljh-config [#89](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/89) ([@minrk](https://github.com/minrk)) +- Fix traefik config reload [#88](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/88) ([@yuvipanda](https://github.com/yuvipanda)) +- Load arbitrary .py config files from a conf.d dir [#87](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/87) ([@yuvipanda](https://github.com/yuvipanda)) +- Fix notebook user interface switching docs [#86](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/86) ([@yuvipanda](https://github.com/yuvipanda)) +- Remove README note about HTTPS not being supported [#85](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/85) ([@yuvipanda](https://github.com/yuvipanda)) +- Log bootstrap / installer messages to file as well [#82](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/82) ([@yuvipanda](https://github.com/yuvipanda)) +- Add docs on using arbitrary authenticators [#80](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/80) ([@yuvipanda](https://github.com/yuvipanda)) +- Customize theme to have better links in sidebar [#79](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/79) ([@yuvipanda](https://github.com/yuvipanda)) +- Add tljh-config command [#77](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/77) ([@yuvipanda](https://github.com/yuvipanda)) +- Clarify development status warnings [#76](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/76) ([@yuvipanda](https://github.com/yuvipanda)) +- Use a venv to run unit tests [#74](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/74) ([@yuvipanda](https://github.com/yuvipanda)) +- Add tutorial on how to use nbgitpuller [#73](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/73) ([@yuvipanda](https://github.com/yuvipanda)) +- Use a venv to run unit tests [#72](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/72) ([@yuvipanda](https://github.com/yuvipanda)) +- Update server requirements documentation [#69](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/69) ([@yuvipanda](https://github.com/yuvipanda)) +- Add a how-to guide on selecting VM Memory / CPU / Disk size [#68](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/68) ([@yuvipanda](https://github.com/yuvipanda)) +- Add HTTPS support with traefik [#67](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/67) ([@minrk](https://github.com/minrk)) +- Replace pointers to yuvipanda/ on github with jupyterhub/ [#66](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/66) ([@yuvipanda](https://github.com/yuvipanda)) +- Add doc on customizing installer [#65](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/65) ([@yuvipanda](https://github.com/yuvipanda)) +- Use venv for base hub environment [#64](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/64) ([@yuvipanda](https://github.com/yuvipanda)) +- fix typo in installer [#63](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/63) ([@gedankenstuecke](https://github.com/gedankenstuecke)) +- jupyterhub 0.9.1, notebook 5.6.0 [#60](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/60) ([@minrk](https://github.com/minrk)) +- move state outside envs [#59](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/59) ([@minrk](https://github.com/minrk)) +- bootstrap: allow conda to be upgraded [#58](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/58) ([@minrk](https://github.com/minrk)) +- Install nbgitpuller by default [#55](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/55) ([@yuvipanda](https://github.com/yuvipanda)) +- Add option to install requirements.txt file on install [#53](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/53) ([@yuvipanda](https://github.com/yuvipanda)) +- Fix link to custom tutorial [#52](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/52) ([@parente](https://github.com/parente)) +- run integration tests with pytest [#43](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/43) ([@minrk](https://github.com/minrk)) +- Minor typo [#40](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/40) ([@rprimet](https://github.com/rprimet)) +- Install all python packages in hub environment with pip [#39](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/39) ([@yuvipanda](https://github.com/yuvipanda)) +- Support using arbitrary set of installed authenticators [#37](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/37) ([@yuvipanda](https://github.com/yuvipanda)) +- remove —no-cache-dir arg [#34](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/34) ([@minrk](https://github.com/minrk)) +- Handle transient errors [#32](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/32) ([@rprimet](https://github.com/rprimet)) +- Small text improvements + adding copy buttons to text blocks [#24](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/24) ([@choldgraf](https://github.com/choldgraf)) +- [MRG] update jetstream tutorial with links, minor fixes [#19](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/19) ([@ctb](https://github.com/ctb)) +- Pour some tea 🍵 [#7](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/7) ([@rgbkrk](https://github.com/rgbkrk)) +- minor fixes to dev-instructions [#6](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/6) ([@gedankenstuecke](https://github.com/gedankenstuecke)) +- allow upgrade of miniconda during install [#3](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/3) ([@gedankenstuecke](https://github.com/gedankenstuecke)) + +## Contributors to this release + +([GitHub contributors page for this release](https://github.com/jupyterhub/the-littlest-jupyterhub/graphs/contributors?from=2018-06-15&to=2022-11-27&type=c)) + +[@1kastner](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3A1kastner+updated%3A2018-06-15..2022-11-27&type=Issues) | [@6palace](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3A6palace+updated%3A2018-06-15..2022-11-27&type=Issues) | [@AashitaK](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAashitaK+updated%3A2018-06-15..2022-11-27&type=Issues) | [@aboutaaron](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaboutaaron+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Adrianhein](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAdrianhein+updated%3A2018-06-15..2022-11-27&type=Issues) | [@afonit](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aafonit+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ajhenley](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aajhenley+updated%3A2018-06-15..2022-11-27&type=Issues) | [@altmas5](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaltmas5+updated%3A2018-06-15..2022-11-27&type=Issues) | [@alvinhuff](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aalvinhuff+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Amran2k16](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAmran2k16+updated%3A2018-06-15..2022-11-27&type=Issues) | [@anyushevai](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aanyushevai+updated%3A2018-06-15..2022-11-27&type=Issues) | [@aolney](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaolney+updated%3A2018-06-15..2022-11-27&type=Issues) | [@astrojuanlu](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aastrojuanlu+updated%3A2018-06-15..2022-11-27&type=Issues) | [@benbovy](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abenbovy+updated%3A2018-06-15..2022-11-27&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abetatim+updated%3A2018-06-15..2022-11-27&type=Issues) | [@bjornarfjelldal](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abjornarfjelldal+updated%3A2018-06-15..2022-11-27&type=Issues) | [@budgester](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abudgester+updated%3A2018-06-15..2022-11-27&type=Issues) | [@CagtayFabry](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACagtayFabry+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACarreau+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cdibble](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acdibble+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cgawron](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acgawron+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cgodkin](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acgodkin+updated%3A2018-06-15..2022-11-27&type=Issues) | [@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acholdgraf+updated%3A2018-06-15..2022-11-27&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acodecov+updated%3A2018-06-15..2022-11-27&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AconsideRatio+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cornhundred](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acornhundred+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ctb](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Actb+updated%3A2018-06-15..2022-11-27&type=Issues) | [@CyborgDroid](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACyborgDroid+updated%3A2018-06-15..2022-11-27&type=Issues) | [@danlester](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adanlester+updated%3A2018-06-15..2022-11-27&type=Issues) | [@DataCascadia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ADataCascadia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@davide84](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adavide84+updated%3A2018-06-15..2022-11-27&type=Issues) | [@davidedelvento](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adavidedelvento+updated%3A2018-06-15..2022-11-27&type=Issues) | [@deeplook](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adeeplook+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adependabot+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dongmok](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adongmok+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dschofield](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adschofield+updated%3A2018-06-15..2022-11-27&type=Issues) | [@efedorov-dart](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aefedorov-dart+updated%3A2018-06-15..2022-11-27&type=Issues) | [@EvilMav](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AEvilMav+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ewidl](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aewidl+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fermasia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afermasia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@filippo82](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afilippo82+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fm75](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afm75+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fomightez](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afomightez+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fperez](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afperez+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Fregf](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AFregf+updated%3A2018-06-15..2022-11-27&type=Issues) | [@frier-sam](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afrier-sam+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gabefair](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agabefair+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gantheaume](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agantheaume+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gedankenstuecke](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agedankenstuecke+updated%3A2018-06-15..2022-11-27&type=Issues) | [@geoffbacon](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ageoffbacon+updated%3A2018-06-15..2022-11-27&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGeorgianaElena+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gillybops](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agillybops+updated%3A2018-06-15..2022-11-27&type=Issues) | [@greg-dusek](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agreg-dusek+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gsemet](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agsemet+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Guillaume-Garrigos](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGuillaume-Garrigos+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gutow](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agutow+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gvdr](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agvdr+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gyg-github](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agyg-github+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Hannnsen](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AHannnsen+updated%3A2018-06-15..2022-11-27&type=Issues) | [@henfee](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahenfee+updated%3A2018-06-15..2022-11-27&type=Issues) | [@hoenie-ams](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahoenie-ams+updated%3A2018-06-15..2022-11-27&type=Issues) | [@huhuhang](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahuhuhang+updated%3A2018-06-15..2022-11-27&type=Issues) | [@iampatterson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aiampatterson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ian-r-rose](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aian-r-rose+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ibayer](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aibayer+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ikhoury](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aikhoury+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JavierHernandezMontes](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJavierHernandezMontes+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jayvdb](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajayvdb+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jdelamare](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajdelamare+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jdkruzr](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajdkruzr+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jeanmarcalkazzi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajeanmarcalkazzi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jerpson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajerpson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jhadjar](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajhadjar+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jihobak](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajihobak+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jkfm](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajkfm+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JobinJohan](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJobinJohan+updated%3A2018-06-15..2022-11-27&type=Issues) | [@josiahls](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajosiahls+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jprorama](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajprorama+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajtpio+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JuanCab](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJuanCab+updated%3A2018-06-15..2022-11-27&type=Issues) | [@junctionapps](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajunctionapps+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jzf2101](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajzf2101+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kafonek](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akafonek+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kannes](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akannes+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kevmk04](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akevmk04+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lachlancampbell](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alachlancampbell+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AlambdaTotoro+updated%3A2018-06-15..2022-11-27&type=Issues) | [@laxdog](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alaxdog+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lee-hodg](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alee-hodg+updated%3A2018-06-15..2022-11-27&type=Issues) | [@leouieda](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aleouieda+updated%3A2018-06-15..2022-11-27&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aleportella+updated%3A2018-06-15..2022-11-27&type=Issues) | [@letianw91](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aletianw91+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Louren](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ALouren+updated%3A2018-06-15..2022-11-27&type=Issues) | [@LTangaF](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ALTangaF+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lumbric](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alumbric+updated%3A2018-06-15..2022-11-27&type=Issues) | [@luong-komorebi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aluong-komorebi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mangecoeur](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amangecoeur+updated%3A2018-06-15..2022-11-27&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amanics+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MartijnZ](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMartijnZ+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mauro3](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amauro3+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MayeulC](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMayeulC+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mbenguig](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ambenguig+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mdpiper](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amdpiper+updated%3A2018-06-15..2022-11-27&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ameeseeksmachine+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mgd722](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amgd722+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mhwasil](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amhwasil+updated%3A2018-06-15..2022-11-27&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aminrk+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mpkirby](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ampkirby+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mpound](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ampound+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MridulS](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMridulS+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mskblackbelt](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amskblackbelt+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mtav](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amtav+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mukhendra](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amukhendra+updated%3A2018-06-15..2022-11-27&type=Issues) | [@namin](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anamin+updated%3A2018-06-15..2022-11-27&type=Issues) | [@nguyenvulong](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anguyenvulong+updated%3A2018-06-15..2022-11-27&type=Issues) | [@norcalbiostat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anorcalbiostat+updated%3A2018-06-15..2022-11-27&type=Issues) | [@oisinBates](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AoisinBates+updated%3A2018-06-15..2022-11-27&type=Issues) | [@olivierverdier](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aolivierverdier+updated%3A2018-06-15..2022-11-27&type=Issues) | [@owah](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aowah+updated%3A2018-06-15..2022-11-27&type=Issues) | [@parente](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aparente+updated%3A2018-06-15..2022-11-27&type=Issues) | [@parmentelat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aparmentelat+updated%3A2018-06-15..2022-11-27&type=Issues) | [@paulnakroshis](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apaulnakroshis+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pbugnion](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apbugnion+updated%3A2018-06-15..2022-11-27&type=Issues) | [@psychemedia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apsychemedia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ptcane](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aptcane+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pulponair](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apulponair+updated%3A2018-06-15..2022-11-27&type=Issues) | [@raybellwaves](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Araybellwaves+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rdmolony](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ardmolony+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rgbkrk](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Argbkrk+updated%3A2018-06-15..2022-11-27&type=Issues) | [@richardbrinkman](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arichardbrinkman+updated%3A2018-06-15..2022-11-27&type=Issues) | [@RobinTTY](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ARobinTTY+updated%3A2018-06-15..2022-11-27&type=Issues) | [@robnagler](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arobnagler+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rprimet](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arprimet+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rraghav13](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arraghav13+updated%3A2018-06-15..2022-11-27&type=Issues) | [@scottkleinman](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ascottkleinman+updated%3A2018-06-15..2022-11-27&type=Issues) | [@sethwoodworth](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asethwoodworth+updated%3A2018-06-15..2022-11-27&type=Issues) | [@shireenrao](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ashireenrao+updated%3A2018-06-15..2022-11-27&type=Issues) | [@silhouetted](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asilhouetted+updated%3A2018-06-15..2022-11-27&type=Issues) | [@staeiou](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astaeiou+updated%3A2018-06-15..2022-11-27&type=Issues) | [@stephen-a2z](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astephen-a2z+updated%3A2018-06-15..2022-11-27&type=Issues) | [@story645](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astory645+updated%3A2018-06-15..2022-11-27&type=Issues) | [@subgero](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asubgero+updated%3A2018-06-15..2022-11-27&type=Issues) | [@sukhjitsehra](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asukhjitsehra+updated%3A2018-06-15..2022-11-27&type=Issues) | [@support](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asupport+updated%3A2018-06-15..2022-11-27&type=Issues) | [@t3chbg](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3At3chbg+updated%3A2018-06-15..2022-11-27&type=Issues) | [@tkang007](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atkang007+updated%3A2018-06-15..2022-11-27&type=Issues) | [@TobiGiese](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ATobiGiese+updated%3A2018-06-15..2022-11-27&type=Issues) | [@toccalenuvole73](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atoccalenuvole73+updated%3A2018-06-15..2022-11-27&type=Issues) | [@tomliptrot](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atomliptrot+updated%3A2018-06-15..2022-11-27&type=Issues) | [@trallard](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atrallard+updated%3A2018-06-15..2022-11-27&type=Issues) | [@twrobinson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atwrobinson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@VincePlantItAi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AVincePlantItAi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@vsisl](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Avsisl+updated%3A2018-06-15..2022-11-27&type=Issues) | [@waltermateriais](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awaltermateriais+updated%3A2018-06-15..2022-11-27&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awelcome+updated%3A2018-06-15..2022-11-27&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awillingc+updated%3A2018-06-15..2022-11-27&type=Issues) | [@willirath](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awillirath+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wjcapehart](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awjcapehart+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wqh17101](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awqh17101+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wrightaprilm](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awrightaprilm+updated%3A2018-06-15..2022-11-27&type=Issues) | [@xavierliang](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Axavierliang+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ynnelson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aynnelson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ayuvipanda+updated%3A2018-06-15..2022-11-27&type=Issues) | [@znicholls](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aznicholls+updated%3A2018-06-15..2022-11-27&type=Issues) From 15b5f8c537601be5db6499e4ef5fe64f9c60b9ec Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 00:21:30 +0000 Subject: [PATCH 043/232] Single section for changelog Very few PRs are labelled, so just have one `Merged PRs` section --- changelog.md | 86 ++++++++++++++++++++++------------------------------ 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/changelog.md b/changelog.md index e0a5e23..861eab7 100644 --- a/changelog.md +++ b/changelog.md @@ -2,71 +2,56 @@ ([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...7fb84aad5642dd5d0ec5a3c4c238848081678919)) -## Enhancements made - -- bootstrap script accepts a version [#819](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/819) ([@manics](https://github.com/manics)) -- ENH: add logging if user-requirements-txt-url found [#796](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/796) ([@raybellwaves](https://github.com/raybellwaves)) -- Add support for installing TLJH on Arm64 systems and bump traefik (1.7.18 -> 1.7.33) [#679](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/679) ([@cdibble](https://github.com/cdibble)) - -## Bugs fixed - -- Don't open file twice when downloading conda [#717](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/717) ([@yuvipanda](https://github.com/yuvipanda)) - -## Maintenance and upkeep improvements - -- Update precommit [#820](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/820) ([@manics](https://github.com/manics)) -- pre-commit: apply black formatting (and prettier on one yaml file) [#755](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/755) ([@consideRatio](https://github.com/consideRatio)) -- Update firstuseauthenticator to 1.0.0 [#749](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/749) ([@consideRatio](https://github.com/consideRatio)) -- Add .pre-commit-config [#748](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/748) ([@consideRatio](https://github.com/consideRatio)) -- docs: require sphinx>=2, otherwise error follows [#743](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/743) ([@consideRatio](https://github.com/consideRatio)) -- Refactor bootstrap.py script for readability [#715](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/715) ([@consideRatio](https://github.com/consideRatio)) -- Remove template in root folder - a mistakenly committed file [#713](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/713) ([@consideRatio](https://github.com/consideRatio)) -- Switch to Mamba [#697](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/697) ([@manics](https://github.com/manics)) - -## Documentation improvements - -- docs: reference nbgitpullers docs to fix outdated tljh docs [#826](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/826) ([@rdmolony](https://github.com/rdmolony)) -- DOC: update sudo tljh-config --help demo [#785](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/785) ([@raybellwaves](https://github.com/raybellwaves)) -- DOC: add tljh-db plugin to list [#782](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/782) ([@raybellwaves](https://github.com/raybellwaves)) -- DOC: move link to contributing/plugin higher [#781](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/781) ([@raybellwaves](https://github.com/raybellwaves)) -- DOC: update info on AWS get system log [#772](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/772) ([@raybellwaves](https://github.com/raybellwaves)) -- DOC: hyperlink there [#768](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/768) ([@raybellwaves](https://github.com/raybellwaves)) -- docs: fix how-to sections table of content section [#742](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/742) ([@consideRatio](https://github.com/consideRatio)) -- update awscognito docs to use GenericOAuthenticator [#729](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/729) ([@minrk](https://github.com/minrk)) -- docs: fix language regarding master [#718](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/718) ([@consideRatio](https://github.com/consideRatio)) -- Try setting min. req to 1GB of RAM [#716](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/716) ([@yuvipanda](https://github.com/yuvipanda)) -- Revision of our GitHub Workflows and README.rst to README.md [#710](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/710) ([@consideRatio](https://github.com/consideRatio)) -- Reflect the fact that AWS free tier is not enough [#696](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/696) ([@Guillaume-Garrigos](https://github.com/Guillaume-Garrigos)) -- Added instructions for restarting JupyterHub to docs (re: #455) [#666](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/666) ([@DataCascadia](https://github.com/DataCascadia)) -- DOC: moved nativeauthentic config instructions to code block [#294](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/294) ([@story645](https://github.com/story645)) -- Document tljh-config commands by referencing the --help sections [#213](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/213) ([@gillybops](https://github.com/gillybops)) - -## Other merged PRs +## Merged PRs - ci: fix deprecation of set-output in github workflows [#837](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/837) ([@consideRatio](https://github.com/consideRatio)) - Fix typo with --show-progress-page argument in example [#835](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/835) ([@luong-komorebi](https://github.com/luong-komorebi)) - ci: add dependabot for github actions and bump them now [#831](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/831) ([@consideRatio](https://github.com/consideRatio)) +- docs: reference nbgitpullers docs to fix outdated tljh docs [#826](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/826) ([@rdmolony](https://github.com/rdmolony)) +- Update precommit [#820](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/820) ([@manics](https://github.com/manics)) +- bootstrap script accepts a version [#819](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/819) ([@manics](https://github.com/manics)) - ci: run int. and unit tests on 22.04 LTS + py3.10 [#817](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/817) ([@MridulS](https://github.com/MridulS)) - clarify direction of information in idle-culler [#816](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/816) ([@minrk](https://github.com/minrk)) - Update progress_page_favicon_url link [#811](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/811) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Bump systemdspawner version [#810](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/810) ([@yuvipanda](https://github.com/yuvipanda)) - github workflow: echo $BOOTSTRAP_PIP_SPEC [#801](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/801) ([@manics](https://github.com/manics)) +- ENH: add logging if user-requirements-txt-url found [#796](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/796) ([@raybellwaves](https://github.com/raybellwaves)) - extra logger.info [#789](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/789) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: update sudo tljh-config --help demo [#785](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/785) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: add tljh-db plugin to list [#782](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/782) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: move link to contributing/plugin higher [#781](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/781) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: update info on AWS get system log [#772](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/772) ([@raybellwaves](https://github.com/raybellwaves)) +- DOC: hyperlink there [#768](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/768) ([@raybellwaves](https://github.com/raybellwaves)) - updating 'plugin' documentation [#764](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/764) ([@oisinBates](https://github.com/oisinBates)) +- pre-commit: apply black formatting (and prettier on one yaml file) [#755](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/755) ([@consideRatio](https://github.com/consideRatio)) - pre-commit: remove requirements-txt-fixer [#754](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/754) ([@consideRatio](https://github.com/consideRatio)) +- Update firstuseauthenticator to 1.0.0 [#749](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/749) ([@consideRatio](https://github.com/consideRatio)) +- Add .pre-commit-config [#748](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/748) ([@consideRatio](https://github.com/consideRatio)) - Small fixes for flake8 and other smaller pre-commit tools [#747](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/747) ([@consideRatio](https://github.com/consideRatio)) - remove addressed FIXMEs in update_auth [#745](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/745) ([@minrk](https://github.com/minrk)) - Remove MockConfigurer [#744](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/744) ([@minrk](https://github.com/minrk)) +- docs: require sphinx>=2, otherwise error follows [#743](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/743) ([@consideRatio](https://github.com/consideRatio)) +- docs: fix how-to sections table of content section [#742](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/742) ([@consideRatio](https://github.com/consideRatio)) - Modernize docs Makefile with sphinx-autobuild [#741](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/741) ([@consideRatio](https://github.com/consideRatio)) +- update awscognito docs to use GenericOAuthenticator [#729](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/729) ([@minrk](https://github.com/minrk)) - Apply TLJH auth config with less assumptions [#721](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/721) ([@consideRatio](https://github.com/consideRatio)) - Bump to recent versions, and make bootstrap.py update to those when run [#719](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/719) ([@consideRatio](https://github.com/consideRatio)) +- docs: fix language regarding master [#718](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/718) ([@consideRatio](https://github.com/consideRatio)) +- Don't open file twice when downloading conda [#717](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/717) ([@yuvipanda](https://github.com/yuvipanda)) +- Try setting min. req to 1GB of RAM [#716](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/716) ([@yuvipanda](https://github.com/yuvipanda)) +- Refactor bootstrap.py script for readability [#715](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/715) ([@consideRatio](https://github.com/consideRatio)) +- Remove template in root folder - a mistakenly committed file [#713](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/713) ([@consideRatio](https://github.com/consideRatio)) - ci: add .readthedocs.yaml [#712](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/712) ([@consideRatio](https://github.com/consideRatio)) +- Revision of our GitHub Workflows and README.rst to README.md [#710](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/710) ([@consideRatio](https://github.com/consideRatio)) - Bump nbgitpuller version [#704](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/704) ([@yuvipanda](https://github.com/yuvipanda)) - Bump notebook from 6.3.0 to 6.4.1 in /tljh [#703](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/703) ([@dependabot](https://github.com/dependabot)) +- Switch to Mamba [#697](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/697) ([@manics](https://github.com/manics)) +- Reflect the fact that AWS free tier is not enough [#696](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/696) ([@Guillaume-Garrigos](https://github.com/Guillaume-Garrigos)) - Bump hub and notebook versions [#688](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/688) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - bump nativeauthenticator version to avoid critical bug [#683](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/683) ([@ibayer](https://github.com/ibayer)) - Add "Users Lists" example [#682](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/682) ([@jeanmarcalkazzi](https://github.com/jeanmarcalkazzi)) - Add missing configurator config [#680](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/680) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Add support for installing TLJH on Arm64 systems and bump traefik (1.7.18 -> 1.7.33) [#679](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/679) ([@cdibble](https://github.com/cdibble)) - Revert "Revert "Switch integration and upgrade tests from CircleCI to GitHub actions"" [#678](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/678) ([@yuvipanda](https://github.com/yuvipanda)) - Revert "Switch integration and upgrade tests from CircleCI to GitHub actions" [#677](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/677) ([@yuvipanda](https://github.com/yuvipanda)) - Add the jupyterhub-configurator service [#676](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/676) ([@GeorgianaElena](https://github.com/GeorgianaElena)) @@ -75,6 +60,7 @@ - Note smallest AWS instance TLJH can run on [#671](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/671) ([@yuvipanda](https://github.com/yuvipanda)) - Pin chardet again and pin it for tests also. [#668](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/668) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Bump traefik-proxy version and remove pin. [#667](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/667) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Added instructions for restarting JupyterHub to docs (re: #455) [#666](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/666) ([@DataCascadia](https://github.com/DataCascadia)) - Add docs to override systemd settings [#663](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/663) ([@jtpio](https://github.com/jtpio)) - Docs: add missing gif for the TLJH is building page [#662](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/662) ([@jtpio](https://github.com/jtpio)) - Upgrade to Jupyterlab 3.0 and Jupyter Resource Usage [#658](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/658) ([@jtpio](https://github.com/jtpio)) @@ -136,19 +122,19 @@ - Fix missing reference to requirements-base.txt [#504](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/504) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Upgrade jupyterlab to 1.2.6 [#499](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/499) ([@letianw91](https://github.com/letianw91)) - Set tls 1.2 to be the min version [#498](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/498) ([@GeorgianaElena](https://github.com/GeorgianaElena)) -- [MRG] Fix integration test for new pip [#491](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/491) ([@betatim](https://github.com/betatim)) -- [MRG] Link contributing guide [#489](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/489) ([@betatim](https://github.com/betatim)) +- Fix integration test for new pip [#491](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/491) ([@betatim](https://github.com/betatim)) +- Link contributing guide [#489](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/489) ([@betatim](https://github.com/betatim)) - Fix broken link to resource estimation page [#485](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/485) ([@leouieda](https://github.com/leouieda)) - Fix failing integration tests [#479](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/479) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Upgrade authenticators [#476](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/476) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Added AWS Cognito docs [#472](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/472) ([@budgester](https://github.com/budgester)) - Switch to pandas theme [#468](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/468) ([@yuvipanda](https://github.com/yuvipanda)) - installation failed due to no python3-dev packages [#460](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/460) ([@afonit](https://github.com/afonit)) -- [MRG] Azure docs - add details on the new Azure deploy button [#458](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/458) ([@trallard](https://github.com/trallard)) +- Azure docs - add details on the new Azure deploy button [#458](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/458) ([@trallard](https://github.com/trallard)) - switch base environment to requirements file [#457](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/457) ([@minrk](https://github.com/minrk)) - Add hook for new users [#453](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/453) ([@jkfm](https://github.com/jkfm)) - Write out deb line only if it already doesn't exist [#449](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/449) ([@GeorgianaElena](https://github.com/GeorgianaElena)) -- [Ready to merge] - Update Azure docs [#448](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/448) ([@trallard](https://github.com/trallard)) +- Update Azure docs [#448](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/448) ([@trallard](https://github.com/trallard)) - Update Amazon AMI selection step [#443](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/443) ([@fomightez](https://github.com/fomightez)) - Upgrade traefik version [#442](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/442) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Disable ProtectHome=tmpfs [#435](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/435) ([@GeorgianaElena](https://github.com/GeorgianaElena)) @@ -157,7 +143,7 @@ - fixed typo in key pair section [#421](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/421) ([@ptcane](https://github.com/ptcane)) - HowTo Google authenticate [#404](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/404) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Docs update: reload proxy after modifying the ports [#403](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/403) ([@GeorgianaElena](https://github.com/GeorgianaElena)) -- Allow adding multiple admins during install [#399](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/399) ([@GeorgianaElena](https://github.com/GeorgianaElena)) +- Allow adding multiple admins during install [#399](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/399) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - Set admin password during install [#395](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/395) ([@GeorgianaElena](https://github.com/GeorgianaElena)) - fixing typo (remove "can add rules") in amazon.rst [#393](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/393) ([@cornhundred](https://github.com/cornhundred)) - Import containers from collections.abc rather than collections [#392](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/392) ([@GeorgianaElena](https://github.com/GeorgianaElena)) @@ -205,9 +191,10 @@ - Bump JupyterHub and systemdspawner versions [#311](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/311) ([@yuvipanda](https://github.com/yuvipanda)) - adding sidebar links [#309](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/309) ([@choldgraf](https://github.com/choldgraf)) - Change style to match Jhub main doc [#304](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/304) ([@leportella](https://github.com/leportella)) -- [MRG] Fix the version tag of the notebook package [#303](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/303) ([@betatim](https://github.com/betatim)) +- Fix the version tag of the notebook package [#303](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/303) ([@betatim](https://github.com/betatim)) - Bump jupyterhub version [#297](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/297) ([@yuvipanda](https://github.com/yuvipanda)) - Update / clarify / shorten docs, add missing image from AWS install [#296](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/296) ([@laxdog](https://github.com/laxdog)) +- DOC: moved nativeauthentic config instructions to code block [#294](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/294) ([@story645](https://github.com/story645)) - Pin tornado to <6 [#292](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/292) ([@willirath](https://github.com/willirath)) - typo fix in installer actions [#287](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/287) ([@junctionapps](https://github.com/junctionapps)) - Add NativeAuth as an optional authenticator [#284](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/284) ([@leportella](https://github.com/leportella)) @@ -233,6 +220,7 @@ - consolidate yaml configuration [#224](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/224) ([@minrk](https://github.com/minrk)) - Provide better error message when running on unsupported distro [#221](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/221) ([@yuvipanda](https://github.com/yuvipanda)) - Upgrade package versions [#215](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/215) ([@yuvipanda](https://github.com/yuvipanda)) +- Document tljh-config commands by referencing the --help sections [#213](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/213) ([@gillybops](https://github.com/gillybops)) - add warning if tljh-config is called as non-root user [#209](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/209) ([@anyushevai](https://github.com/anyushevai)) - updating theme and storing docs artifacts [#205](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/205) ([@choldgraf](https://github.com/choldgraf)) - No memory limit (continued) [#202](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/202) ([@betatim](https://github.com/betatim)) @@ -269,14 +257,14 @@ - Add plugin support to the installer [#127](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/127) ([@yuvipanda](https://github.com/yuvipanda)) - removing extra copybutton files [#126](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/126) ([@choldgraf](https://github.com/choldgraf)) - adding copy button to code blocks and fixing the integration bug [#124](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/124) ([@choldgraf](https://github.com/choldgraf)) -- [MRG] updating content from zexuan's user test [#123](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/123) ([@choldgraf](https://github.com/choldgraf)) +- updating content from zexuan's user test [#123](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/123) ([@choldgraf](https://github.com/choldgraf)) - Remove extreneous = [#119](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/119) ([@yuvipanda](https://github.com/yuvipanda)) - adding when to use tljh page [#118](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/118) ([@choldgraf](https://github.com/choldgraf)) - adding documentation for GitHub OAuth [#117](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/117) ([@choldgraf](https://github.com/choldgraf)) - Fix quick links in README [#113](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/113) ([@willirath](https://github.com/willirath)) - Install nbresuse by default [#111](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/111) ([@yuvipanda](https://github.com/yuvipanda)) - Re-organize installation documentation [#110](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/110) ([@yuvipanda](https://github.com/yuvipanda)) -- [MRG] Adding CI for documentation and fixing docs warnings [#107](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/107) ([@betatim](https://github.com/betatim)) +- Adding CI for documentation and fixing docs warnings [#107](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/107) ([@betatim](https://github.com/betatim)) - shared data and username emphasis [#103](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/103) ([@choldgraf](https://github.com/choldgraf)) - unittests for traefik [#96](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/96) ([@minrk](https://github.com/minrk)) - fix coverage uploads [#95](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/95) ([@minrk](https://github.com/minrk)) @@ -316,7 +304,7 @@ - remove —no-cache-dir arg [#34](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/34) ([@minrk](https://github.com/minrk)) - Handle transient errors [#32](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/32) ([@rprimet](https://github.com/rprimet)) - Small text improvements + adding copy buttons to text blocks [#24](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/24) ([@choldgraf](https://github.com/choldgraf)) -- [MRG] update jetstream tutorial with links, minor fixes [#19](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/19) ([@ctb](https://github.com/ctb)) +- update jetstream tutorial with links, minor fixes [#19](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/19) ([@ctb](https://github.com/ctb)) - Pour some tea 🍵 [#7](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/7) ([@rgbkrk](https://github.com/rgbkrk)) - minor fixes to dev-instructions [#6](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/6) ([@gedankenstuecke](https://github.com/gedankenstuecke)) - allow upgrade of miniconda during install [#3](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/3) ([@gedankenstuecke](https://github.com/gedankenstuecke)) From 6d1deaa19c326a775d54727aa97b2a85bac0b776 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 00:29:46 +0000 Subject: [PATCH 044/232] Set version to 0.2.0 --- changelog.md | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog.md b/changelog.md index 861eab7..7f7206f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,6 @@ -# 4a74ad17a1a19f6378efe12a01ba634ed90f1e03...master@{2022-11-27} +# 0.2.0 - YYYY-MM-DD -([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...7fb84aad5642dd5d0ec5a3c4c238848081678919)) +([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...0.2.0)) ## Merged PRs diff --git a/setup.py b/setup.py index ca1987f..194c3af 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="the-littlest-jupyterhub", - version="0.1", + version="0.2.0", description="A small JupyterHub distribution", url="https://github.com/jupyterhub/the-littlest-jupyterhub", author="Jupyter Development Team", From 7a50cc9436c86688b91d21c9f7e4b49be71c9d94 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Nov 2022 19:03:48 +0000 Subject: [PATCH 045/232] bootstrap.py: Only lookup git ref if `TLJH_BOOTSTRAP_PIP_SPEC` not set test_bootstrap.py: use `--version main` since there are no tags/releases --- bootstrap/bootstrap.py | 14 +++++++------- integration-tests/test_bootstrap.py | 9 ++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index ba2d4ab..87846d1 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -455,16 +455,16 @@ def main(): if os.environ.get("TLJH_BOOTSTRAP_DEV", "no") == "yes": logger.info("Selected TLJH_BOOTSTRAP_DEV=yes...") tljh_install_cmd.append("--editable") - version = _resolve_git_version(args.version) - tljh_install_cmd.append( - os.environ.get( - "TLJH_BOOTSTRAP_PIP_SPEC", + bootstrap_pip_spec = os.environ.get("TLJH_BOOTSTRAP_PIP_SPEC") + if not bootstrap_pip_spec: + bootstrap_pip_spec = ( "git+https://github.com/jupyterhub/the-littlest-jupyterhub.git@{}".format( - version - ), + _resolve_git_version(args.version) + ) ) - ) + + tljh_install_cmd.append(bootstrap_pip_spec) if initial_setup: logger.info("Installing TLJH installer...") else: diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index b0cdab6..bc97bee 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -100,7 +100,14 @@ def run_bootstrap_after_preparing_container( bootstrap_script = get_bootstrap_script_location(container_name, show_progress_page) - exec_flags = ["-i", container_name, "python3", bootstrap_script] + exec_flags = [ + "-i", + container_name, + "python3", + bootstrap_script, + "--version", + "main", + ] if show_progress_page: exec_flags = ( ["-e", "TLJH_BOOTSTRAP_DEV=yes", "-e", "TLJH_BOOTSTRAP_PIP_SPEC=/srv/src"] From 971c716f0ed75829c649d95398b697f2ee0c9bd5 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 10 Feb 2023 11:51:33 +0000 Subject: [PATCH 046/232] Update changelog with PRs since 7fb84aad5642dd5d0ec5a3c4c238848081678919 --- changelog.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 7f7206f..437d752 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,9 @@ ## Merged PRs +- Fix broken CI [#851](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/851) ([@pnasrat](https://github.com/pnasrat)) +- Ensure SQLAlchemy 1.x used for hub [#848](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/848) ([@pnasrat](https://github.com/pnasrat)) +- docs: update sphinx configuration, add opengraph and rediraffe, fix a warning [#840](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/840) ([@consideRatio](https://github.com/consideRatio)) - ci: fix deprecation of set-output in github workflows [#837](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/837) ([@consideRatio](https://github.com/consideRatio)) - Fix typo with --show-progress-page argument in example [#835](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/835) ([@luong-komorebi](https://github.com/luong-komorebi)) - ci: add dependabot for github actions and bump them now [#831](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/831) ([@consideRatio](https://github.com/consideRatio)) @@ -313,4 +316,4 @@ ([GitHub contributors page for this release](https://github.com/jupyterhub/the-littlest-jupyterhub/graphs/contributors?from=2018-06-15&to=2022-11-27&type=c)) -[@1kastner](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3A1kastner+updated%3A2018-06-15..2022-11-27&type=Issues) | [@6palace](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3A6palace+updated%3A2018-06-15..2022-11-27&type=Issues) | [@AashitaK](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAashitaK+updated%3A2018-06-15..2022-11-27&type=Issues) | [@aboutaaron](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaboutaaron+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Adrianhein](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAdrianhein+updated%3A2018-06-15..2022-11-27&type=Issues) | [@afonit](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aafonit+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ajhenley](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aajhenley+updated%3A2018-06-15..2022-11-27&type=Issues) | [@altmas5](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaltmas5+updated%3A2018-06-15..2022-11-27&type=Issues) | [@alvinhuff](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aalvinhuff+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Amran2k16](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAmran2k16+updated%3A2018-06-15..2022-11-27&type=Issues) | [@anyushevai](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aanyushevai+updated%3A2018-06-15..2022-11-27&type=Issues) | [@aolney](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaolney+updated%3A2018-06-15..2022-11-27&type=Issues) | [@astrojuanlu](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aastrojuanlu+updated%3A2018-06-15..2022-11-27&type=Issues) | [@benbovy](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abenbovy+updated%3A2018-06-15..2022-11-27&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abetatim+updated%3A2018-06-15..2022-11-27&type=Issues) | [@bjornarfjelldal](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abjornarfjelldal+updated%3A2018-06-15..2022-11-27&type=Issues) | [@budgester](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abudgester+updated%3A2018-06-15..2022-11-27&type=Issues) | [@CagtayFabry](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACagtayFabry+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACarreau+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cdibble](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acdibble+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cgawron](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acgawron+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cgodkin](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acgodkin+updated%3A2018-06-15..2022-11-27&type=Issues) | [@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acholdgraf+updated%3A2018-06-15..2022-11-27&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acodecov+updated%3A2018-06-15..2022-11-27&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AconsideRatio+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cornhundred](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acornhundred+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ctb](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Actb+updated%3A2018-06-15..2022-11-27&type=Issues) | [@CyborgDroid](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACyborgDroid+updated%3A2018-06-15..2022-11-27&type=Issues) | [@danlester](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adanlester+updated%3A2018-06-15..2022-11-27&type=Issues) | [@DataCascadia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ADataCascadia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@davide84](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adavide84+updated%3A2018-06-15..2022-11-27&type=Issues) | [@davidedelvento](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adavidedelvento+updated%3A2018-06-15..2022-11-27&type=Issues) | [@deeplook](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adeeplook+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adependabot+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dongmok](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adongmok+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dschofield](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adschofield+updated%3A2018-06-15..2022-11-27&type=Issues) | [@efedorov-dart](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aefedorov-dart+updated%3A2018-06-15..2022-11-27&type=Issues) | [@EvilMav](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AEvilMav+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ewidl](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aewidl+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fermasia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afermasia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@filippo82](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afilippo82+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fm75](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afm75+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fomightez](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afomightez+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fperez](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afperez+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Fregf](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AFregf+updated%3A2018-06-15..2022-11-27&type=Issues) | [@frier-sam](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afrier-sam+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gabefair](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agabefair+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gantheaume](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agantheaume+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gedankenstuecke](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agedankenstuecke+updated%3A2018-06-15..2022-11-27&type=Issues) | [@geoffbacon](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ageoffbacon+updated%3A2018-06-15..2022-11-27&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGeorgianaElena+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gillybops](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agillybops+updated%3A2018-06-15..2022-11-27&type=Issues) | [@greg-dusek](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agreg-dusek+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gsemet](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agsemet+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Guillaume-Garrigos](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGuillaume-Garrigos+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gutow](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agutow+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gvdr](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agvdr+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gyg-github](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agyg-github+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Hannnsen](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AHannnsen+updated%3A2018-06-15..2022-11-27&type=Issues) | [@henfee](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahenfee+updated%3A2018-06-15..2022-11-27&type=Issues) | [@hoenie-ams](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahoenie-ams+updated%3A2018-06-15..2022-11-27&type=Issues) | [@huhuhang](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahuhuhang+updated%3A2018-06-15..2022-11-27&type=Issues) | [@iampatterson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aiampatterson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ian-r-rose](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aian-r-rose+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ibayer](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aibayer+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ikhoury](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aikhoury+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JavierHernandezMontes](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJavierHernandezMontes+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jayvdb](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajayvdb+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jdelamare](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajdelamare+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jdkruzr](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajdkruzr+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jeanmarcalkazzi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajeanmarcalkazzi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jerpson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajerpson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jhadjar](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajhadjar+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jihobak](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajihobak+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jkfm](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajkfm+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JobinJohan](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJobinJohan+updated%3A2018-06-15..2022-11-27&type=Issues) | [@josiahls](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajosiahls+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jprorama](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajprorama+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajtpio+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JuanCab](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJuanCab+updated%3A2018-06-15..2022-11-27&type=Issues) | [@junctionapps](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajunctionapps+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jzf2101](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajzf2101+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kafonek](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akafonek+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kannes](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akannes+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kevmk04](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akevmk04+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lachlancampbell](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alachlancampbell+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AlambdaTotoro+updated%3A2018-06-15..2022-11-27&type=Issues) | [@laxdog](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alaxdog+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lee-hodg](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alee-hodg+updated%3A2018-06-15..2022-11-27&type=Issues) | [@leouieda](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aleouieda+updated%3A2018-06-15..2022-11-27&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aleportella+updated%3A2018-06-15..2022-11-27&type=Issues) | [@letianw91](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aletianw91+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Louren](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ALouren+updated%3A2018-06-15..2022-11-27&type=Issues) | [@LTangaF](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ALTangaF+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lumbric](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alumbric+updated%3A2018-06-15..2022-11-27&type=Issues) | [@luong-komorebi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aluong-komorebi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mangecoeur](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amangecoeur+updated%3A2018-06-15..2022-11-27&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amanics+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MartijnZ](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMartijnZ+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mauro3](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amauro3+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MayeulC](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMayeulC+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mbenguig](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ambenguig+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mdpiper](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amdpiper+updated%3A2018-06-15..2022-11-27&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ameeseeksmachine+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mgd722](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amgd722+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mhwasil](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amhwasil+updated%3A2018-06-15..2022-11-27&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aminrk+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mpkirby](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ampkirby+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mpound](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ampound+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MridulS](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMridulS+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mskblackbelt](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amskblackbelt+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mtav](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amtav+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mukhendra](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amukhendra+updated%3A2018-06-15..2022-11-27&type=Issues) | [@namin](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anamin+updated%3A2018-06-15..2022-11-27&type=Issues) | [@nguyenvulong](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anguyenvulong+updated%3A2018-06-15..2022-11-27&type=Issues) | [@norcalbiostat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anorcalbiostat+updated%3A2018-06-15..2022-11-27&type=Issues) | [@oisinBates](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AoisinBates+updated%3A2018-06-15..2022-11-27&type=Issues) | [@olivierverdier](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aolivierverdier+updated%3A2018-06-15..2022-11-27&type=Issues) | [@owah](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aowah+updated%3A2018-06-15..2022-11-27&type=Issues) | [@parente](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aparente+updated%3A2018-06-15..2022-11-27&type=Issues) | [@parmentelat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aparmentelat+updated%3A2018-06-15..2022-11-27&type=Issues) | [@paulnakroshis](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apaulnakroshis+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pbugnion](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apbugnion+updated%3A2018-06-15..2022-11-27&type=Issues) | [@psychemedia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apsychemedia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ptcane](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aptcane+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pulponair](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apulponair+updated%3A2018-06-15..2022-11-27&type=Issues) | [@raybellwaves](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Araybellwaves+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rdmolony](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ardmolony+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rgbkrk](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Argbkrk+updated%3A2018-06-15..2022-11-27&type=Issues) | [@richardbrinkman](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arichardbrinkman+updated%3A2018-06-15..2022-11-27&type=Issues) | [@RobinTTY](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ARobinTTY+updated%3A2018-06-15..2022-11-27&type=Issues) | [@robnagler](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arobnagler+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rprimet](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arprimet+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rraghav13](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arraghav13+updated%3A2018-06-15..2022-11-27&type=Issues) | [@scottkleinman](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ascottkleinman+updated%3A2018-06-15..2022-11-27&type=Issues) | [@sethwoodworth](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asethwoodworth+updated%3A2018-06-15..2022-11-27&type=Issues) | [@shireenrao](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ashireenrao+updated%3A2018-06-15..2022-11-27&type=Issues) | [@silhouetted](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asilhouetted+updated%3A2018-06-15..2022-11-27&type=Issues) | [@staeiou](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astaeiou+updated%3A2018-06-15..2022-11-27&type=Issues) | [@stephen-a2z](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astephen-a2z+updated%3A2018-06-15..2022-11-27&type=Issues) | [@story645](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astory645+updated%3A2018-06-15..2022-11-27&type=Issues) | [@subgero](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asubgero+updated%3A2018-06-15..2022-11-27&type=Issues) | [@sukhjitsehra](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asukhjitsehra+updated%3A2018-06-15..2022-11-27&type=Issues) | [@support](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asupport+updated%3A2018-06-15..2022-11-27&type=Issues) | [@t3chbg](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3At3chbg+updated%3A2018-06-15..2022-11-27&type=Issues) | [@tkang007](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atkang007+updated%3A2018-06-15..2022-11-27&type=Issues) | [@TobiGiese](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ATobiGiese+updated%3A2018-06-15..2022-11-27&type=Issues) | [@toccalenuvole73](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atoccalenuvole73+updated%3A2018-06-15..2022-11-27&type=Issues) | [@tomliptrot](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atomliptrot+updated%3A2018-06-15..2022-11-27&type=Issues) | [@trallard](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atrallard+updated%3A2018-06-15..2022-11-27&type=Issues) | [@twrobinson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atwrobinson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@VincePlantItAi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AVincePlantItAi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@vsisl](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Avsisl+updated%3A2018-06-15..2022-11-27&type=Issues) | [@waltermateriais](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awaltermateriais+updated%3A2018-06-15..2022-11-27&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awelcome+updated%3A2018-06-15..2022-11-27&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awillingc+updated%3A2018-06-15..2022-11-27&type=Issues) | [@willirath](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awillirath+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wjcapehart](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awjcapehart+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wqh17101](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awqh17101+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wrightaprilm](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awrightaprilm+updated%3A2018-06-15..2022-11-27&type=Issues) | [@xavierliang](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Axavierliang+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ynnelson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aynnelson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ayuvipanda+updated%3A2018-06-15..2022-11-27&type=Issues) | [@znicholls](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aznicholls+updated%3A2018-06-15..2022-11-27&type=Issues) +[@1kastner](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3A1kastner+updated%3A2018-06-15..2022-11-27&type=Issues) | [@6palace](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3A6palace+updated%3A2018-06-15..2022-11-27&type=Issues) | [@AashitaK](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAashitaK+updated%3A2018-06-15..2022-11-27&type=Issues) | [@aboutaaron](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaboutaaron+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Adrianhein](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAdrianhein+updated%3A2018-06-15..2022-11-27&type=Issues) | [@afonit](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aafonit+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ajhenley](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aajhenley+updated%3A2018-06-15..2022-11-27&type=Issues) | [@altmas5](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaltmas5+updated%3A2018-06-15..2022-11-27&type=Issues) | [@alvinhuff](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aalvinhuff+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Amran2k16](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AAmran2k16+updated%3A2018-06-15..2022-11-27&type=Issues) | [@anyushevai](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aanyushevai+updated%3A2018-06-15..2022-11-27&type=Issues) | [@aolney](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaolney+updated%3A2018-06-15..2022-11-27&type=Issues) | [@astrojuanlu](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aastrojuanlu+updated%3A2018-06-15..2022-11-27&type=Issues) | [@benbovy](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abenbovy+updated%3A2018-06-15..2022-11-27&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abetatim+updated%3A2018-06-15..2022-11-27&type=Issues) | [@bjornarfjelldal](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abjornarfjelldal+updated%3A2018-06-15..2022-11-27&type=Issues) | [@budgester](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Abudgester+updated%3A2018-06-15..2022-11-27&type=Issues) | [@CagtayFabry](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACagtayFabry+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACarreau+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cdibble](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acdibble+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cgawron](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acgawron+updated%3A2018-06-15..2022-11-27&type=Issues) | [@cgodkin](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acgodkin+updated%3A2018-06-15..2022-11-27&type=Issues) | [@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acholdgraf+updated%3A2018-06-15..2022-11-27&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acodecov+updated%3A2018-06-15..2022-11-27&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AconsideRatio+updated%3A2018-06-15..2023-02-10&type=Issues) | [@cornhundred](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acornhundred+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ctb](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Actb+updated%3A2018-06-15..2022-11-27&type=Issues) | [@CyborgDroid](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ACyborgDroid+updated%3A2018-06-15..2022-11-27&type=Issues) | [@danlester](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adanlester+updated%3A2018-06-15..2022-11-27&type=Issues) | [@DataCascadia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ADataCascadia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@davide84](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adavide84+updated%3A2018-06-15..2022-11-27&type=Issues) | [@davidedelvento](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adavidedelvento+updated%3A2018-06-15..2022-11-27&type=Issues) | [@deeplook](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adeeplook+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adependabot+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dongmok](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adongmok+updated%3A2018-06-15..2022-11-27&type=Issues) | [@dschofield](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Adschofield+updated%3A2018-06-15..2022-11-27&type=Issues) | [@efedorov-dart](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aefedorov-dart+updated%3A2018-06-15..2022-11-27&type=Issues) | [@EvilMav](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AEvilMav+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ewidl](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aewidl+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fermasia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afermasia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@filippo82](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afilippo82+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fm75](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afm75+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fomightez](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afomightez+updated%3A2018-06-15..2022-11-27&type=Issues) | [@fperez](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afperez+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Fregf](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AFregf+updated%3A2018-06-15..2022-11-27&type=Issues) | [@frier-sam](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Afrier-sam+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gabefair](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agabefair+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gantheaume](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agantheaume+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gedankenstuecke](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agedankenstuecke+updated%3A2018-06-15..2022-11-27&type=Issues) | [@geoffbacon](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ageoffbacon+updated%3A2018-06-15..2022-11-27&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGeorgianaElena+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gillybops](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agillybops+updated%3A2018-06-15..2022-11-27&type=Issues) | [@greg-dusek](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agreg-dusek+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gsemet](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agsemet+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Guillaume-Garrigos](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGuillaume-Garrigos+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gutow](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agutow+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gvdr](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agvdr+updated%3A2018-06-15..2022-11-27&type=Issues) | [@gyg-github](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Agyg-github+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Hannnsen](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AHannnsen+updated%3A2018-06-15..2022-11-27&type=Issues) | [@henfee](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahenfee+updated%3A2018-06-15..2022-11-27&type=Issues) | [@hoenie-ams](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahoenie-ams+updated%3A2018-06-15..2022-11-27&type=Issues) | [@huhuhang](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ahuhuhang+updated%3A2018-06-15..2022-11-27&type=Issues) | [@iampatterson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aiampatterson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ian-r-rose](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aian-r-rose+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ibayer](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aibayer+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ikhoury](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aikhoury+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JavierHernandezMontes](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJavierHernandezMontes+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jayvdb](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajayvdb+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jdelamare](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajdelamare+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jdkruzr](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajdkruzr+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jeanmarcalkazzi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajeanmarcalkazzi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jerpson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajerpson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jhadjar](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajhadjar+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jihobak](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajihobak+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jkfm](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajkfm+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JobinJohan](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJobinJohan+updated%3A2018-06-15..2022-11-27&type=Issues) | [@josiahls](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajosiahls+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jprorama](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajprorama+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajtpio+updated%3A2018-06-15..2022-11-27&type=Issues) | [@JuanCab](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AJuanCab+updated%3A2018-06-15..2022-11-27&type=Issues) | [@junctionapps](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajunctionapps+updated%3A2018-06-15..2022-11-27&type=Issues) | [@jzf2101](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajzf2101+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kafonek](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akafonek+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kannes](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akannes+updated%3A2018-06-15..2022-11-27&type=Issues) | [@kevmk04](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akevmk04+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lachlancampbell](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alachlancampbell+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lambdaTotoro](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AlambdaTotoro+updated%3A2018-06-15..2022-11-27&type=Issues) | [@laxdog](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alaxdog+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lee-hodg](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alee-hodg+updated%3A2018-06-15..2022-11-27&type=Issues) | [@leouieda](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aleouieda+updated%3A2018-06-15..2022-11-27&type=Issues) | [@leportella](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aleportella+updated%3A2018-06-15..2022-11-27&type=Issues) | [@letianw91](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aletianw91+updated%3A2018-06-15..2022-11-27&type=Issues) | [@Louren](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ALouren+updated%3A2018-06-15..2022-11-27&type=Issues) | [@LTangaF](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ALTangaF+updated%3A2018-06-15..2022-11-27&type=Issues) | [@lumbric](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Alumbric+updated%3A2018-06-15..2022-11-27&type=Issues) | [@luong-komorebi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aluong-komorebi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mangecoeur](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amangecoeur+updated%3A2018-06-15..2022-11-27&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amanics+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MartijnZ](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMartijnZ+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mauro3](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amauro3+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MayeulC](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMayeulC+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mbenguig](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ambenguig+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mdpiper](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amdpiper+updated%3A2018-06-15..2022-11-27&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ameeseeksmachine+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mgd722](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amgd722+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mhwasil](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amhwasil+updated%3A2018-06-15..2022-11-27&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aminrk+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mpkirby](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ampkirby+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mpound](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ampound+updated%3A2018-06-15..2022-11-27&type=Issues) | [@MridulS](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMridulS+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mskblackbelt](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amskblackbelt+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mtav](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amtav+updated%3A2018-06-15..2022-11-27&type=Issues) | [@mukhendra](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amukhendra+updated%3A2018-06-15..2022-11-27&type=Issues) | [@namin](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anamin+updated%3A2018-06-15..2022-11-27&type=Issues) | [@nguyenvulong](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anguyenvulong+updated%3A2018-06-15..2022-11-27&type=Issues) | [@norcalbiostat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Anorcalbiostat+updated%3A2018-06-15..2022-11-27&type=Issues) | [@oisinBates](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AoisinBates+updated%3A2018-06-15..2022-11-27&type=Issues) | [@olivierverdier](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aolivierverdier+updated%3A2018-06-15..2022-11-27&type=Issues) | [@owah](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aowah+updated%3A2018-06-15..2022-11-27&type=Issues) | [@parente](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aparente+updated%3A2018-06-15..2022-11-27&type=Issues) | [@parmentelat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aparmentelat+updated%3A2018-06-15..2022-11-27&type=Issues) | [@paulnakroshis](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apaulnakroshis+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pbugnion](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apbugnion+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pnasrat](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apbugnion+updated%3A2018-06-15..2023-10-02&type=Issues) | [@psychemedia](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apsychemedia+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ptcane](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aptcane+updated%3A2018-06-15..2022-11-27&type=Issues) | [@pulponair](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Apulponair+updated%3A2018-06-15..2022-11-27&type=Issues) | [@raybellwaves](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Araybellwaves+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rdmolony](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ardmolony+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rgbkrk](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Argbkrk+updated%3A2018-06-15..2022-11-27&type=Issues) | [@richardbrinkman](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arichardbrinkman+updated%3A2018-06-15..2022-11-27&type=Issues) | [@RobinTTY](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ARobinTTY+updated%3A2018-06-15..2022-11-27&type=Issues) | [@robnagler](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arobnagler+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rprimet](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arprimet+updated%3A2018-06-15..2022-11-27&type=Issues) | [@rraghav13](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Arraghav13+updated%3A2018-06-15..2022-11-27&type=Issues) | [@scottkleinman](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ascottkleinman+updated%3A2018-06-15..2022-11-27&type=Issues) | [@sethwoodworth](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asethwoodworth+updated%3A2018-06-15..2022-11-27&type=Issues) | [@shireenrao](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ashireenrao+updated%3A2018-06-15..2022-11-27&type=Issues) | [@silhouetted](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asilhouetted+updated%3A2018-06-15..2022-11-27&type=Issues) | [@staeiou](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astaeiou+updated%3A2018-06-15..2022-11-27&type=Issues) | [@stephen-a2z](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astephen-a2z+updated%3A2018-06-15..2022-11-27&type=Issues) | [@story645](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Astory645+updated%3A2018-06-15..2022-11-27&type=Issues) | [@subgero](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asubgero+updated%3A2018-06-15..2022-11-27&type=Issues) | [@sukhjitsehra](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asukhjitsehra+updated%3A2018-06-15..2022-11-27&type=Issues) | [@support](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Asupport+updated%3A2018-06-15..2022-11-27&type=Issues) | [@t3chbg](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3At3chbg+updated%3A2018-06-15..2022-11-27&type=Issues) | [@tkang007](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atkang007+updated%3A2018-06-15..2022-11-27&type=Issues) | [@TobiGiese](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ATobiGiese+updated%3A2018-06-15..2022-11-27&type=Issues) | [@toccalenuvole73](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atoccalenuvole73+updated%3A2018-06-15..2022-11-27&type=Issues) | [@tomliptrot](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atomliptrot+updated%3A2018-06-15..2022-11-27&type=Issues) | [@trallard](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atrallard+updated%3A2018-06-15..2022-11-27&type=Issues) | [@twrobinson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Atwrobinson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@VincePlantItAi](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AVincePlantItAi+updated%3A2018-06-15..2022-11-27&type=Issues) | [@vsisl](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Avsisl+updated%3A2018-06-15..2022-11-27&type=Issues) | [@waltermateriais](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awaltermateriais+updated%3A2018-06-15..2022-11-27&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awelcome+updated%3A2018-06-15..2022-11-27&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awillingc+updated%3A2018-06-15..2022-11-27&type=Issues) | [@willirath](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awillirath+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wjcapehart](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awjcapehart+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wqh17101](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awqh17101+updated%3A2018-06-15..2022-11-27&type=Issues) | [@wrightaprilm](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awrightaprilm+updated%3A2018-06-15..2022-11-27&type=Issues) | [@xavierliang](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Axavierliang+updated%3A2018-06-15..2022-11-27&type=Issues) | [@ynnelson](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aynnelson+updated%3A2018-06-15..2022-11-27&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ayuvipanda+updated%3A2018-06-15..2022-11-27&type=Issues) | [@znicholls](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aznicholls+updated%3A2018-06-15..2022-11-27&type=Issues) From 4245ac01cda4b9c4a2453ac6e4fc3c5e474f005b Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 10 Feb 2023 13:59:01 +0000 Subject: [PATCH 047/232] 0.2.0 tentative release date 2023-02-10 --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 437d752..601ba9f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,4 @@ -# 0.2.0 - YYYY-MM-DD +# 0.2.0 - 2023-02-10 ([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...0.2.0)) From 1da06a0606e13462d522b5f0e065343aef6f1edd Mon Sep 17 00:00:00 2001 From: Simon Li Date: Mon, 27 Feb 2023 09:49:12 +0000 Subject: [PATCH 048/232] 0.2.0 release date --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 601ba9f..94b38fd 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,4 @@ -# 0.2.0 - 2023-02-10 +# 0.2.0 - 2023-02-27 ([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...0.2.0)) From 12316c32567969287338aaa268a78fcbfd887cc3 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Tue, 10 Jan 2023 16:40:59 -0800 Subject: [PATCH 049/232] Upgrade some packages Should upgrade base python version separately --- tljh/installer.py | 9 ++++----- tljh/requirements-base.txt | 15 ++++++--------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index 7e9948d..39af98c 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -121,14 +121,13 @@ def ensure_jupyterhub_package(prefix): conda.ensure_pip_packages( prefix, [ - "SQLAlchemy<2.0.0", - "jupyterhub==1.*", - "jupyterhub-systemdspawner==0.16.*", + "jupyterhub==3.1.*", + "jupyterhub-systemdspawner==0.17.*", "jupyterhub-firstuseauthenticator==1.*", "jupyterhub-nativeauthenticator==1.*", "jupyterhub-ldapauthenticator==1.*", - "jupyterhub-tmpauthenticator==0.6.*", - "oauthenticator==14.*", + "jupyterhub-tmpauthenticator==0.6", + "oauthenticator==15.*", "jupyterhub-idle-culler==1.*", "git+https://github.com/yuvipanda/jupyterhub-configurator@317759e17c8e48de1b1352b836dac2a230536dba", ], diff --git a/tljh/requirements-base.txt b/tljh/requirements-base.txt index 1985db7..7ac5d12 100644 --- a/tljh/requirements-base.txt +++ b/tljh/requirements-base.txt @@ -8,17 +8,14 @@ # For JupyterHub 1.x SQLAlchemy below 2.0.0 SQLAlchemy<2.0.0 # JupyterHub + notebook package are base requirements for user environment -jupyterhub==1.* -notebook==6.* +jupyterhub==3.1.* +notebook==6.5.* # Install additional notebook frontends! jupyterlab==3.* -nteract-on-jupyter==2.* -# Install jupyterlab extensions from PyPI +nteract-on-jupyter==2.1.* # nbgitpuller for easily pulling in Git repositories -nbgitpuller==1.* +nbgitpuller==1.1.* # jupyter-resource-usage to show people how much RAM they are using -jupyter-resource-usage==0.6.* +jupyter-resource-usage==0.7.* # Most people consider ipywidgets to be part of the core notebook experience -ipywidgets==7.* -# Pin tornado -tornado>=6.1 +ipywidgets==8.* From aa72179afa0b47547488621c92959b51fda1ab73 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 10:15:22 +0100 Subject: [PATCH 050/232] require ubuntu 20.04, test on debian 10 --- .github/workflows/integration-test.yaml | 50 +++++++++++-------------- .github/workflows/unit-test.yaml | 3 -- README.md | 2 +- bootstrap/bootstrap.py | 31 ++++++++------- docs/index.rst | 2 +- docs/install/amazon.rst | 6 +-- docs/install/azure.rst | 32 ++++++++-------- docs/install/custom-server.rst | 2 +- docs/install/digitalocean.rst | 4 +- docs/install/google.rst | 4 +- docs/install/index.rst | 3 +- docs/install/jetstream.rst | 6 +-- docs/install/ovh.rst | 10 ++--- docs/topic/requirements.rst | 2 +- integration-tests/Dockerfile | 4 +- integration-tests/test_bootstrap.py | 12 +++--- 16 files changed, 85 insertions(+), 88 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index cee1049..3993684 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -58,36 +58,28 @@ jobs: echo $matrix_post_filter | jq -C '.' env: matrix_include_pre_filter: | - - name: "Int. tests: Ubuntu 18.04, Py 3.6" - ubuntu_version: "18.04" - python_version: "3.6" + - name: "Int. tests: Debian 10, Py 3.7" + distro_image: "debian:10" + runs_on: "ubuntu-22.04" extra_flags: "" - - name: "Int. tests: Ubuntu 20.04, Py 3.9" - ubuntu_version: "20.04" - python_version: "3.9" + - name: "Int. tests: Ubuntu 20.04, Py 3.8" + distro_image: "ubuntu:20.04" extra_flags: "" - - name: "Int. tests: Ubuntu 22.04, Py 3.10" - ubuntu_version: "22.04" - python_version: "3.10" + - name: "Int. tests: Ubuntu 22.04 (Py 3.10)" + distro_image: "ubuntu:22.04" extra_flags: "" - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade" - ubuntu_version: "22.04" - python_version: "3.10" + distro_image: "ubuntu:22.04" extra_flags: --upgrade dont_run_on_ref: refs/heads/master integration-tests: needs: decide-on-test-jobs-to-run - # runs-on can only be configured to the LTS releases of ubuntu (20.04, - # 22.04, ...), so if we want to test against the latest non-LTS release, we - # must compromise when configuring runs-on and configure runs-on to be the - # latest LTS release instead. - # - # This can have consequences because actions like actions/setup-python will - # mount cached installations associated with the chosen runs-on environment. - # - runs-on: ${{ format('ubuntu-{0}', matrix.runs_on || matrix.ubuntu_version) }} + # integration tests run in a container, + # not in the worker, so this version is not relevant to the tests + # and can be the same for all tested versions + runs-on: ubuntu-22.04 name: ${{ matrix.name }} strategy: @@ -98,7 +90,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: - python-version: "${{ matrix.python_version }}" + python-version: "3.10" - name: Install pytest run: python3 -m pip install pytest @@ -106,7 +98,7 @@ jobs: # We abort pytest after two failures as a compromise between wanting to # avoid a flood of logs while still understanding if multiple tests would # fail. - - name: Run bootstrap tests (Runs in/Builds ubuntu:${{ matrix.ubuntu_version }} derived image) + - name: Run bootstrap tests (Runs in/Builds ${{ matrix.distro_image }} derived image) run: | pytest --verbose --maxfail=2 --color=yes --durations=10 --capture=no \ integration-tests/test_bootstrap.py @@ -115,14 +107,14 @@ jobs: # integration-tests/test_bootstrap.py will build and start containers # based on this environment variable. This is similar to how # .github/integration-test.py build-image can take a --build-arg - # setting the ubuntu_version. - UBUNTU_VERSION: ${{ matrix.ubuntu_version }} + # setting the base image. + BASE_IMAGE: ${{ matrix.distro_image }} # We build a docker image from wherein we will work - - name: Build systemd image (Builds ubuntu:${{ matrix.ubuntu_version }} derived image) + - name: Build systemd image (Builds ${{ matrix.distro_image }} derived image) run: | .github/integration-test.py build-image \ - --build-arg "ubuntu_version=${{ matrix.ubuntu_version }}" + --build-arg "BASE_IMAGE=${{ matrix.distro_image }}" # FIXME: Make the logic below easier to follow. # - In short, setting BOOTSTRAP_PIP_SPEC here, specifies from what @@ -151,7 +143,7 @@ jobs: echo "BOOTSTRAP_PIP_SPEC=$BOOTSTRAP_PIP_SPEC" >> $GITHUB_ENV echo $BOOTSTRAP_PIP_SPEC - - name: Run basic tests (Runs in ubuntu:${{ matrix.ubuntu_version }} derived image) + - name: Run basic tests (Runs in ${{ matrix.distro_image }} derived image) run: | .github/integration-test.py run-test basic-tests \ --bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \ @@ -162,7 +154,7 @@ jobs: test_extensions.py timeout-minutes: 15 - - name: Run admin tests (Runs in ubuntu:${{ matrix.ubuntu_version }} derived image) + - name: Run admin tests (Runs in ${{ matrix.distro_image }} derived image) run: | .github/integration-test.py run-test admin-tests \ --installer-args "--admin admin:admin" \ @@ -171,7 +163,7 @@ jobs: test_admin_installer.py timeout-minutes: 15 - - name: Run plugin tests (Runs in ubuntu:${{ matrix.ubuntu_version }} derived image) + - name: Run plugin tests (Runs in ${{ matrix.distro_image }} derived image) run: | .github/integration-test.py run-test plugin-tests \ --bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \ diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 71804f3..a0acde8 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -42,9 +42,6 @@ jobs: fail-fast: false matrix: include: - - name: "Unit tests: Ubuntu 18.04, Py 3.6" - ubuntu_version: "18.04" - python_version: "3.6" - name: "Unit tests: Ubuntu 20.04, Py 3.9" ubuntu_version: "20.04" python_version: "3.9" diff --git a/README.md b/README.md index 361e8be..46f2d19 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ 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**. Earlier versions of Ubuntu are not supported. +**Ubuntu 20.04**. Earlier versions of Ubuntu are not supported. We have several tutorials to get you started. - Tutorials to create a new server from scratch on a cloud provider & run TLJH diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 0e9ba81..2895e0b 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -9,11 +9,10 @@ This script is run as: Constraints: - - The entire script should be compatible with Python 3.6, which is the on - Ubuntu 18.04+. - - The script should parse in Python 3.5 as we print error messages for using - Ubuntu 16.04+ which comes with Python 3.5 by default. This means no - f-strings can be used. + - The entire script should be compatible with Python 3.7, which is the on + Debian 10. + - The script should parse in Python 3.6 as we print error messages for using + Ubuntu 18.04+ which comes with Python 3.6 by default. - The script must depend only on stdlib modules, as no previous installation of dependencies can be assumed. @@ -132,6 +131,12 @@ progress_page_html = """ logger = logging.getLogger(__name__) + +def _parse_version(vs): + """Parse a simple version into a tuple of ints""" + return (int(part) for part in vs.split(".")) + + # This function is needed both by the process starting this script, and by the # TLJH installer that this script execs in the end. Make sure its replica at # tljh/utils.py stays in sync with this version! @@ -199,22 +204,22 @@ def ensure_host_system_can_install_tljh(): .strip() ) - # Require Ubuntu 18.04+ or Debian 10+ + # Require Ubuntu 20.04+ or Debian 10+ distro = get_os_release_variable("ID") version = float(get_os_release_variable("VERSION_ID")) if distro not in ["ubuntu", "debian"]: print("The Littlest JupyterHub currently supports Ubuntu or Debian Linux only") sys.exit(1) - elif distro == "ubuntu" and float(version) < 18.04: - print("The Littlest JupyterHub requires Ubuntu 18.04 or higher") + elif distro == "ubuntu" and _parse_version(version) < (20, 4): + print("The Littlest JupyterHub requires Ubuntu 20.04 or higher") sys.exit(1) - elif distro == "debian" and float(version) < 10: + elif distro == "debian" and _parse_version(version) < (10,): print("The Littlest JupyterHub requires Debian 10 or higher") sys.exit(1) # Require Python 3.6+ - if sys.version_info < (3, 6): - print("bootstrap.py must be run with at least Python 3.6") + if sys.version_info < (3, 7): + print(f"bootstrap.py must be run with at least Python 3.7, found {sys.version}") sys.exit(1) # Require systemd (systemctl is a part of systemd) @@ -230,7 +235,7 @@ def ensure_host_system_can_install_tljh(): "For local development, see http://tljh.jupyter.org/en/latest/contributing/dev-setup.html" ) sys.exit(1) - return distro, version + return distro, version class ProgressPageRequestHandler(SimpleHTTPRequestHandler): @@ -441,7 +446,7 @@ def main(): "python3-venv", "python3-pip", "git", - "sudo", # sudo is missing in default debian install + "sudo", # sudo is missing in default debian install ], env=apt_get_adjusted_env, ) diff --git a/docs/index.rst b/docs/index.rst index 074a4d2..2d40312 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,7 @@ might still make breaking changes that have no clear upgrade pathway. Installation ============ -The Littlest JupyterHub (TLJH) can run on any server that is running **Ubuntu 18.04** or **Ubuntu 20.04** on a amd64 or arm64 CPU architecture. Earlier versions of Ubuntu are not supported. +The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 10** or **Ubuntu 20.04** on a amd64 or arm64 CPU architecture. Earlier versions of Ubuntu and Debian are not supported, as are other Linux distributions. We have a bunch of tutorials to get you started. - Tutorials to create a new server from scratch on a cloud provider & run TLJH diff --git a/docs/install/amazon.rst b/docs/install/amazon.rst index e043301..4dbe054 100644 --- a/docs/install/amazon.rst +++ b/docs/install/amazon.rst @@ -58,15 +58,15 @@ Let's create the server on which we can run JupyterHub. #. On the page **Step 1: Choose an Amazon Machine Image (AMI)** you are going to pick the base image your remote server will have. The view will default to the 'Quick-start' tab selected and just a few down the page, select - **Ubuntu Server 18.04 LTS (HVM), SSD Volume Type - ami-XXXXXXXXXXXXXXXXX**, + **Ubuntu Server 22.04 LTS (HVM), SSD Volume Type - ami-XXXXXXXXXXXXXXXXX**, leaving `64-bit (x86)` toggled. .. image:: ../images/providers/amazon/select_ubuntu_18.png - :alt: Click Ubuntu server 18.04 + :alt: Click Ubuntu server 22.04 The `ami` alpha-numeric at the end references the specific Amazon machine image, ignore this as Amazon updates them routinely. The - **Ubuntu Server 18.04 LTS (HVM)** is the important part. + **Ubuntu Server 22.04 LTS (HVM)** is the important part. #. After selecting the AMI, you'll be at **Step 2: Choose an Instance Type**. diff --git a/docs/install/azure.rst b/docs/install/azure.rst index 62f7cf8..68f29c5 100644 --- a/docs/install/azure.rst +++ b/docs/install/azure.rst @@ -13,16 +13,16 @@ users and a user environment with packages you want to be installed running on This tutorial leads you step-by-step for you to manually deploy your own JupyterHub on Azure cloud. -.. note:: ✨ The ``Deploy to Azure button`` project allows you to deploy your own JupyterHub with minimal manual configuration steps. The deploy to Azure button allows you to have a vanilla configuration in just one-click and by assigning some variables. - +.. note:: ✨ The ``Deploy to Azure button`` project allows you to deploy your own JupyterHub with minimal manual configuration steps. The deploy to Azure button allows you to have a vanilla configuration in just one-click and by assigning some variables. + Check it out at `https://github.com/trallard/TLJH-azure-button `_. Prerequisites ============== -* A Microsoft Azure account. +* 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 `_) +* To get started you can get a free account which includes 150 dollars worth of Azure credits (`get a free account here `_) These instructions cover how to set up a Virtual Machine on Microsoft Azure. For subsequent information about creating @@ -51,8 +51,8 @@ A new screen with all the options for Virtual Machines in Azure will displayed. .. 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.` +#. **Choose an Ubuntu server for your VM**: + * Click `Ubuntu Server 22.04 LTS.` * Make sure `Resource Manager` is selected in the next screen and click **Create** .. image:: ../images/providers/azure/ubuntu-vm.png @@ -70,7 +70,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. * **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). + * **Image**. Make sure "Ubuntu Server 22.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. @@ -83,17 +83,17 @@ A new screen with all the options for Virtual Machines in Azure will displayed. #. 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) + * Click on **Change size** (see image below) .. image:: ../images/providers/azure/size-vm.png - :alt: Choose vm size + :alt: Choose vm size - .. note:: For more information about estimating memory, CPU and disk needs check `The memory section in the TLJH documentation `_ + .. note:: For more information about estimating memory, CPU and disk needs check `The memory section in the TLJH documentation `_ + + * Select a suitable image (to check available images and prices in your region `click on this link `_). - * Select a suitable image (to check available images and prices in your region `click on this link `_). - #. Disks (Storage): - * **Disk options**: select the OS disk type there are options for SDD and HDD. **SSD persistent disk** gives you a faster but more expensive disk than HDD. + * **Disk options**: select 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". @@ -111,7 +111,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. * **Public inbound ports**. Check **HTTP**, **HTTPS**, and **SSH**. .. image:: ../images/providers/azure/networking-vm.png - :alt: Choose networking ports + :alt: Choose networking ports #. Management * Monitoring @@ -129,7 +129,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. #. 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. + * **Cloud init**. We are going to use this section to install TLJH directly into our Virtual Machine. Copy the code snippet below: .. code:: bash @@ -165,7 +165,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. .. image:: ../images/providers/azure/goto-vm.png :alt: Go to VM -#. Check if the installation is completed by **copying** the **Public IP address** of your virtual machine, and trying to access it with a browser. +#. Check if the installation is completed 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 diff --git a/docs/install/custom-server.rst b/docs/install/custom-server.rst index bcb616d..9c94843 100644 --- a/docs/install/custom-server.rst +++ b/docs/install/custom-server.rst @@ -31,7 +31,7 @@ Pre-requisites ============== #. Some familiarity with the command line. -#. A server running Ubuntu 18.04 where you have root access. +#. A server running Ubuntu 20.04 where you have root access. #. At least **1GB** of RAM on your server. #. Ability to ``ssh`` into the server & run commands from the prompt. #. An **IP address** where the server can be reached from the browsers of your target audience. diff --git a/docs/install/digitalocean.rst b/docs/install/digitalocean.rst index 38d07a0..cbf40d5 100644 --- a/docs/install/digitalocean.rst +++ b/docs/install/digitalocean.rst @@ -34,10 +34,10 @@ Let's create the server on which we can run JupyterHub. This takes you to a page titled **Create Droplets** that lets you configure your server. -#. Under **Choose an image**, select **18.04 x64** under **Ubuntu**. +#. Under **Choose an image**, select **22.04 x64** under **Ubuntu**. .. image:: ../images/providers/digitalocean/select-image.png - :alt: Select 18.04 x64 image under Ubuntu + :alt: Select 22.04 x64 image under Ubuntu #. Under **Choose a size**, select the size of the server you want. The default (4GB RAM, 2CPUs, 20 USD / month) is not a bad start. You can resize your server diff --git a/docs/install/google.rst b/docs/install/google.rst index f6081d5..d6e38be 100644 --- a/docs/install/google.rst +++ b/docs/install/google.rst @@ -94,10 +94,10 @@ Let's create the server on which we can run JupyterHub. This should open a **Boot disk** popup. -#. Select **Ubuntu 18.04 LTS** from the list of operating system images. +#. Select **Ubuntu 22.04 LTS** from the list of operating system images. .. image:: ../images/providers/google/boot-disk-ubuntu.png - :alt: Selecting Ubuntu 18.04 for OS + :alt: Selecting Ubuntu 22.04 for OS #. You can also change the **type** and **size** of your disk at the bottom of this popup. diff --git a/docs/install/index.rst b/docs/install/index.rst index b783486..7f79e62 100644 --- a/docs/install/index.rst +++ b/docs/install/index.rst @@ -5,7 +5,8 @@ Installing ========== The Littlest JupyterHub (TLJH) can run on any server that is running at least -**Ubuntu 18.04**. Earlier versions of Ubuntu are not supported. +**Ubuntu 20.04** or **Debian 10**. Earlier versions of Ubuntu and Debian are not supported, +nor are other Linux distributions. We have a bunch of tutorials to get you started. Tutorials to create a new server from scratch on a cloud provider & run TLJH diff --git a/docs/install/jetstream.rst b/docs/install/jetstream.rst index 7f95a3b..e9e55ad 100644 --- a/docs/install/jetstream.rst +++ b/docs/install/jetstream.rst @@ -33,11 +33,11 @@ Let's create the server on which we can run JupyterHub. This takes you to a page with a list of base images you can choose for your server. -#. Under **Image Search**, search for **Ubuntu 18.04**, and select the - **Ubuntu 18.04 Devel and Docker** image. +#. Under **Image Search**, search for **Ubuntu 22.04**, and select the + **Ubuntu 22.04 Devel and Docker** image. .. image:: ../images/providers/jetstream/select-image.png - :alt: Select Ubuntu 18.04 x64 image from image list + :alt: Select Ubuntu 22.04 x64 image from image list #. Once selected, you will see more information about this image. Click the **Launch** button on the top right. diff --git a/docs/install/ovh.rst b/docs/install/ovh.rst index 241368c..33010b4 100644 --- a/docs/install/ovh.rst +++ b/docs/install/ovh.rst @@ -32,7 +32,7 @@ Let's create the server on which we can run JupyterHub. .. 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 @@ -47,15 +47,15 @@ Let's create the server on which we can run JupyterHub. .. 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: +#. Select **Ubuntu 22.04** as the image: .. image:: ../images/providers/ovh/distribution.png - :alt: Select Ubuntu 18.04 as the image + :alt: Select Ubuntu 22.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 diff --git a/docs/topic/requirements.rst b/docs/topic/requirements.rst index 7b5f1a4..995733f 100644 --- a/docs/topic/requirements.rst +++ b/docs/topic/requirements.rst @@ -7,7 +7,7 @@ Server Requirements Operating System ================ -We require using Ubuntu 18.04 as the base operating system for your server. +We require using Ubuntu >=20.04 as the base operating system for your server. Root access =========== diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile index 64d5d0b..69a7ba4 100644 --- a/integration-tests/Dockerfile +++ b/integration-tests/Dockerfile @@ -1,6 +1,6 @@ # Systemd inside a Docker container, for CI only -ARG ubuntu_version=20.04 -FROM ubuntu:${ubuntu_version} +ARG BASE_IMAGE=ubuntu:20.04 +FROM $BASE_IMAGE # DEBIAN_FRONTEND is set to avoid being asked for input and hang during build: # https://anonoz.github.io/tech/2020/04/24/docker-build-stuck-tzdata.html diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index bc97bee..8a97762 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -6,6 +6,8 @@ import os import subprocess import time +BASE_IMAGE = os.getenv("BASE_IMAGE", "ubuntu:20.04") + def install_pkgs(container_name, show_progress_page): # Install python3 inside the ubuntu container @@ -13,7 +15,7 @@ def install_pkgs(container_name, show_progress_page): pkgs = ["python3"] if show_progress_page: pkgs += ["systemd", "git", "curl"] - # Create the sudoers dir, so that the installer succesfully gets to the + # Create the sudoers dir, so that the installer successfully 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"] @@ -128,15 +130,15 @@ def test_ubuntu_too_old(): """ Error with a useful message when running in older Ubuntu """ - output = run_bootstrap_after_preparing_container("old-distro-test", "ubuntu:16.04") - assert output.stdout == "The Littlest JupyterHub requires Ubuntu 18.04 or higher\n" + output = run_bootstrap_after_preparing_container("old-distro-test", "ubuntu:18.04") + assert output.stdout == "The Littlest JupyterHub requires Ubuntu 20.04 or higher\n" assert output.returncode == 1 def test_inside_no_systemd_docker(): output = run_bootstrap_after_preparing_container( "plain-docker-test", - f"ubuntu:{os.getenv('UBUNTU_VERSION', '20.04')}", + BASE_IMAGE, ) assert "Systemd is required to run TLJH" in output.stdout assert output.returncode == 1 @@ -172,7 +174,7 @@ def test_progress_page(): installer = executor.submit( run_bootstrap_after_preparing_container, "progress-page", - f"ubuntu:{os.getenv('UBUNTU_VERSION', '20.04')}", + BASE_IMAGE, True, ) From de1fc86b5ea57f3e214f35ec8c02acd4f0e60bbf Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 10:29:56 +0100 Subject: [PATCH 051/232] only pin major versions in requirements-base.txt, installer.py --- tljh/installer.py | 4 ++-- tljh/requirements-base.txt | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index 39af98c..ac49086 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -121,12 +121,12 @@ def ensure_jupyterhub_package(prefix): conda.ensure_pip_packages( prefix, [ - "jupyterhub==3.1.*", + "jupyterhub==3.*", "jupyterhub-systemdspawner==0.17.*", "jupyterhub-firstuseauthenticator==1.*", "jupyterhub-nativeauthenticator==1.*", "jupyterhub-ldapauthenticator==1.*", - "jupyterhub-tmpauthenticator==0.6", + "jupyterhub-tmpauthenticator==0.6.*", "oauthenticator==15.*", "jupyterhub-idle-culler==1.*", "git+https://github.com/yuvipanda/jupyterhub-configurator@317759e17c8e48de1b1352b836dac2a230536dba", diff --git a/tljh/requirements-base.txt b/tljh/requirements-base.txt index 7ac5d12..ba9ff85 100644 --- a/tljh/requirements-base.txt +++ b/tljh/requirements-base.txt @@ -5,16 +5,14 @@ # the requirements-txt-fixer pre-commit hook that sorted them and made # our integration tests fail. # -# For JupyterHub 1.x SQLAlchemy below 2.0.0 -SQLAlchemy<2.0.0 # JupyterHub + notebook package are base requirements for user environment -jupyterhub==3.1.* -notebook==6.5.* +jupyterhub==3.* +notebook==6.* # Install additional notebook frontends! jupyterlab==3.* -nteract-on-jupyter==2.1.* +nteract-on-jupyter==2.* # nbgitpuller for easily pulling in Git repositories -nbgitpuller==1.1.* +nbgitpuller==1.* # jupyter-resource-usage to show people how much RAM they are using jupyter-resource-usage==0.7.* # Most people consider ipywidgets to be part of the core notebook experience From 150a3f62d67e2d20eb95f5995145c32fd2331c45 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 10:39:40 +0100 Subject: [PATCH 052/232] parse distro version tuples instead of using floats --- .github/workflows/integration-test.yaml | 2 +- bootstrap/bootstrap.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 3993684..613f089 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -65,7 +65,7 @@ jobs: - name: "Int. tests: Ubuntu 20.04, Py 3.8" distro_image: "ubuntu:20.04" extra_flags: "" - - name: "Int. tests: Ubuntu 22.04 (Py 3.10)" + - name: "Int. tests: Ubuntu 22.04 Py 3.10" distro_image: "ubuntu:22.04" extra_flags: "" - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade" diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 2895e0b..912c10f 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -206,7 +206,7 @@ def ensure_host_system_can_install_tljh(): # Require Ubuntu 20.04+ or Debian 10+ distro = get_os_release_variable("ID") - version = float(get_os_release_variable("VERSION_ID")) + version = get_os_release_variable("VERSION_ID") if distro not in ["ubuntu", "debian"]: print("The Littlest JupyterHub currently supports Ubuntu or Debian Linux only") sys.exit(1) From e03f5ec2cd5f99b3b63fe09d910fbdde14457981 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 10:45:54 +0100 Subject: [PATCH 053/232] Require Debian 11, not 10 10 has too-old Python by default --- .github/workflows/integration-test.yaml | 4 ++-- bootstrap/bootstrap.py | 18 +++++++++--------- docs/index.rst | 3 ++- docs/install/index.rst | 5 ++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 613f089..940340d 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -58,8 +58,8 @@ jobs: echo $matrix_post_filter | jq -C '.' env: matrix_include_pre_filter: | - - name: "Int. tests: Debian 10, Py 3.7" - distro_image: "debian:10" + - name: "Int. tests: Debian 11, Py 3.9" + distro_image: "debian:11" runs_on: "ubuntu-22.04" extra_flags: "" - name: "Int. tests: Ubuntu 20.04, Py 3.8" diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 912c10f..d4c9135 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -9,10 +9,10 @@ This script is run as: Constraints: - - The entire script should be compatible with Python 3.7, which is the on - Debian 10. + - The entire script should be compatible with Python 3.8, which is the on + Ubuntu 20.04. - The script should parse in Python 3.6 as we print error messages for using - Ubuntu 18.04+ which comes with Python 3.6 by default. + Ubuntu 18.04 which comes with Python 3.6 by default. - The script must depend only on stdlib modules, as no previous installation of dependencies can be assumed. @@ -134,7 +134,7 @@ logger = logging.getLogger(__name__) def _parse_version(vs): """Parse a simple version into a tuple of ints""" - return (int(part) for part in vs.split(".")) + return tuple(int(part) for part in vs.split(".")) # This function is needed both by the process starting this script, and by the @@ -204,7 +204,7 @@ def ensure_host_system_can_install_tljh(): .strip() ) - # Require Ubuntu 20.04+ or Debian 10+ + # Require Ubuntu 20.04+ or Debian 11+ distro = get_os_release_variable("ID") version = get_os_release_variable("VERSION_ID") if distro not in ["ubuntu", "debian"]: @@ -213,13 +213,13 @@ def ensure_host_system_can_install_tljh(): elif distro == "ubuntu" and _parse_version(version) < (20, 4): print("The Littlest JupyterHub requires Ubuntu 20.04 or higher") sys.exit(1) - elif distro == "debian" and _parse_version(version) < (10,): + elif distro == "debian" and _parse_version(version) < (11,): print("The Littlest JupyterHub requires Debian 10 or higher") sys.exit(1) - # Require Python 3.6+ - if sys.version_info < (3, 7): - print(f"bootstrap.py must be run with at least Python 3.7, found {sys.version}") + # Require Python 3.8+ + if sys.version_info < (3, 8): + print(f"bootstrap.py must be run with at least Python 3.8, found {sys.version}") sys.exit(1) # Require systemd (systemctl is a part of systemd) diff --git a/docs/index.rst b/docs/index.rst index 2d40312..d79adc7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,8 @@ might still make breaking changes that have no clear upgrade pathway. Installation ============ -The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 10** or **Ubuntu 20.04** on a amd64 or arm64 CPU architecture. Earlier versions of Ubuntu and Debian are not supported, as are other Linux distributions. +The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on a amd64 or arm64 CPU architecture. +Earlier versions of Ubuntu and Debian are not supported, nor are other Linux distributions. We have a bunch of tutorials to get you started. - Tutorials to create a new server from scratch on a cloud provider & run TLJH diff --git a/docs/install/index.rst b/docs/install/index.rst index 7f79e62..f53b0da 100644 --- a/docs/install/index.rst +++ b/docs/install/index.rst @@ -4,9 +4,8 @@ Installing ========== -The Littlest JupyterHub (TLJH) can run on any server that is running at least -**Ubuntu 20.04** or **Debian 10**. Earlier versions of Ubuntu and Debian are not supported, -nor are other Linux distributions. +The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on a amd64 or arm64 CPU architecture. +Earlier versions of Ubuntu and Debian are not supported, nor are other Linux distributions. We have a bunch of tutorials to get you started. Tutorials to create a new server from scratch on a cloud provider & run TLJH From c80e5f68540154aa65b8ba8529250e2cd3b1ed45 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 11:05:25 +0100 Subject: [PATCH 054/232] add python3 to integration test debian base image doesn't include Python3 --- integration-tests/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile index 69a7ba4..447bcb7 100644 --- a/integration-tests/Dockerfile +++ b/integration-tests/Dockerfile @@ -11,6 +11,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \ curl \ git \ sudo \ + python3 \ && rm -rf /var/lib/apt/lists/* # Kill all the things we don't need From 6f852d173d09f3149464d24f008adee443190ecf Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 11:15:13 +0100 Subject: [PATCH 055/232] add note that newer OS will _probably_ work, but LTS are supported --- docs/index.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index d79adc7..fb8e02e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,9 @@ might still make breaking changes that have no clear upgrade pathway. Installation ============ -The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on a amd64 or arm64 CPU architecture. +The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on an amd64 or arm64 CPU architecture. +We aim to support 'stable' and Long-Term Support (LTS) versions. +Newer versions are likely to work with little or no adjustment, but these are the officially supported and tested versions. Earlier versions of Ubuntu and Debian are not supported, nor are other Linux distributions. We have a bunch of tutorials to get you started. From 1a3c48a50091878ed9dca1b5db7aa6d051d30fa8 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 14:21:00 +0100 Subject: [PATCH 056/232] Update base user environment to mambaforge 22.11.1-4 shift some duplicated code into utility functions and constants --- tests/test_conda.py | 26 +++++------- tljh/conda.py | 41 +++++++++++++++---- tljh/installer.py | 98 +++++++++++++++++++++++++++++++-------------- tljh/utils.py | 9 +++++ 4 files changed, 120 insertions(+), 54 deletions(-) diff --git a/tests/test_conda.py b/tests/test_conda.py index a13ab39..d38a85a 100644 --- a/tests/test_conda.py +++ b/tests/test_conda.py @@ -2,6 +2,7 @@ Test conda commandline wrappers """ from tljh import conda +from tljh import installer import os import pytest import subprocess @@ -13,25 +14,20 @@ def prefix(): """ Provide a temporary directory with a mambaforge conda environment """ - # see https://github.com/conda-forge/miniforge/releases - mambaforge_version = "4.10.3-7" - if os.uname().machine == "aarch64": - installer_sha256 = ( - "ac95f137b287b3408e4f67f07a284357b1119ee157373b788b34e770ef2392b2" - ) - elif os.uname().machine == "x86_64": - installer_sha256 = ( - "fc872522ec427fcab10167a93e802efaf251024b58cc27b084b915a9a73c4474" - ) - installer_url = "https://github.com/conda-forge/miniforge/releases/download/{v}/Mambaforge-{v}-Linux-{arch}.sh".format( - v=mambaforge_version, arch=os.uname().machine - ) + machine = os.uname().machine + installer_url, checksum = installer._mambaforge_url() with tempfile.TemporaryDirectory() as tmpdir: with conda.download_miniconda_installer( - installer_url, installer_sha256 + installer_url, checksum ) as installer_path: conda.install_miniconda(installer_path, tmpdir) - conda.ensure_conda_packages(tmpdir, ["conda==4.10.3"]) + conda.ensure_conda_packages( + tmpdir, + [ + f"conda=={installer.MAMBAFORGE_CONDA_VERSION}", + f"mamba=={installer.MAMBAFORGE_MAMBA_VERSION}", + ], + ) yield tmpdir diff --git a/tljh/conda.py b/tljh/conda.py index 88923f6..7aa864a 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -8,8 +8,8 @@ import hashlib import contextlib import tempfile import requests -from distutils.version import LooseVersion as V from tljh import utils +from tljh.utils import parse_version as V def sha256_file(fname): @@ -29,19 +29,44 @@ def check_miniconda_version(prefix, version): """ Return true if a miniconda install with version exists at prefix """ + versions = get_mamba_versions(prefix) + if "conda" not in versions: + return False + + return V(versions["conda"]) >= V(version) + + +def get_mamba_versions(prefix): + """Parse `mamba --version` output into a dict + + which looks like: + + mamba 1.1.0 + conda 22.11.1 + + into: + + { + "mamba": "1.1.0", + "conda": "22.11.1", + } + """ + versions = {} try: - installed_version = ( + out = ( subprocess.check_output( - [os.path.join(prefix, "bin", "conda"), "-V"], stderr=subprocess.STDOUT + [os.path.join(prefix, "bin", "mamba"), "--version"], + stderr=subprocess.STDOUT, ) .decode() .strip() - .split()[1] ) - return V(installed_version) >= V(version) except (subprocess.CalledProcessError, FileNotFoundError): - # Conda doesn't exist - return False + return versions + for line in out.strip().splitlines(): + pkg, version = line.split() + versions[pkg] = version + return versions @contextlib.contextmanager @@ -53,7 +78,7 @@ def download_miniconda_installer(installer_url, sha256sum): of given version, verifies the sha256sum & provides path to it to the `with` block to run. """ - with tempfile.NamedTemporaryFile("wb") as f: + with tempfile.NamedTemporaryFile("wb", suffix=".sh") as f: f.write(requests.get(installer_url).content) # Remain in the NamedTemporaryFile context, but flush changes, see: # https://docs.python.org/3/library/os.html#os.fsync diff --git a/tljh/installer.py b/tljh/installer.py index 7e9948d..c11eb3e 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -26,6 +26,7 @@ from tljh import ( traefik, user, ) + from .config import ( CONFIG_DIR, CONFIG_FILE, @@ -34,6 +35,7 @@ from .config import ( STATE_DIR, USER_ENV_PREFIX, ) +from .utils import parse_version as V from .yaml import yaml HERE = os.path.abspath(os.path.dirname(__file__)) @@ -154,58 +156,92 @@ def ensure_usergroups(): f.write("Defaults exempt_group = jupyterhub-admins\n") +# Install mambaforge using an installer from +# https://github.com/conda-forge/miniforge/releases +MAMBAFORGE_VERSION = "22.11.1-4" +# sha256 checksums +MAMBAFORGE_CHECKSUMS = { + "aarch64": "96191001f27e0cc76612d4498d34f9f656d8a7dddee44617159e42558651479c", + "x86_64": "16c7d256de783ceeb39970e675efa4a8eb830dcbb83187f1197abfea0bf07d30", +} +# run `mamba --version` to get the conda and mamba versions +# conda/mamba will be _upgraded_ to these versions, if they differ from what's in +# the mambaforge distribution +MAMBAFORGE_MAMBA_VERSION = "1.1.0" +MAMBAFORGE_CONDA_VERSION = "22.11.1" + + +def _mambaforge_url(version=MAMBAFORGE_VERSION, arch=None): + """Return (URL, checksum) for mambaforge download for a given version and arch + + Default values provided for both version and arch + """ + if arch is None: + arch = os.uname().machine + installer_url = "https://github.com/conda-forge/miniforge/releases/download/{v}/Mambaforge-{v}-Linux-{arch}.sh".format( + v=version, + arch=arch, + ) + # Check system architecture, set appropriate installer checksum + checksum = MAMBAFORGE_CHECKSUMS.get(arch) + if not checksum: + raise ValueError( + f"Unsupported architecture: {arch}. TLJH only supports {','.join(MAMBAFORGE_CHECKSUMS.keys())}" + ) + return installer_url, checksum + + def ensure_user_environment(user_requirements_txt_file): """ Set up user conda environment with required packages """ logger.info("Setting up user environment...") + # note: these must be in descending order + conda_upgrade_versions = { + # format: "conda version": (conda_version, mamba_version), + # mambaforge 4.10.3-7 (2023-03-21) + "22.11.1": (MAMBAFORGE_CONDA_VERSION, MAMBAFORGE_MAMBA_VERSION), + # tljh up to 0.2.0 (2021-10-18) + "4.10.3": ("4.10.3", "0.16.0"), + # very old versions, do these still work? + "4.7.10": ("4.8.1", "0.16.0"), + "4.5.4": ("4.5.8", "0.16.0"), + } - miniconda_old_version = "4.5.4" - miniconda_new_version = "4.7.10" - # Install mambaforge using an installer from - # https://github.com/conda-forge/miniforge/releases - mambaforge_new_version = "4.10.3-7" - # Check system architecture, set appropriate installer checksum - if os.uname().machine == "aarch64": - installer_sha256 = ( - "ac95f137b287b3408e4f67f07a284357b1119ee157373b788b34e770ef2392b2" - ) - elif os.uname().machine == "x86_64": - installer_sha256 = ( - "fc872522ec427fcab10167a93e802efaf251024b58cc27b084b915a9a73c4474" - ) # Check OS, set appropriate string for conda installer path if os.uname().sysname != "Linux": raise OSError("TLJH is only supported on Linux platforms.") - # Then run `mamba --version` to get the conda and mamba versions - # Keep these in sync with tests/test_conda.py::prefix - mambaforge_conda_new_version = "4.10.3" - mambaforge_mamba_version = "0.16.0" + found_conda = False + have_versions = conda.get_mamba_versions(USER_ENV_PREFIX) + have_conda_version = have_versions.get("conda") + if have_conda_version: + for check_version, conda_mamba_version in conda_upgrade_versions.items(): + if V(have_conda_version) >= V(check_version): + found_conda = True + conda_version, mamba_version = conda_mamba_version + break - if conda.check_miniconda_version(USER_ENV_PREFIX, mambaforge_conda_new_version): - conda_version = "4.10.3" - elif conda.check_miniconda_version(USER_ENV_PREFIX, miniconda_new_version): - conda_version = "4.8.1" - elif conda.check_miniconda_version(USER_ENV_PREFIX, miniconda_old_version): - conda_version = "4.5.8" - # If no prior miniconda installation is found, we can install a newer version - else: + if not found_conda: + if os.path.exists(USER_ENV_PREFIX): + logger.warning( + f"Found prefix at {USER_ENV_PREFIX}, but too old or missing conda/mamba ({have_versions}). Rebuilding env from scratch!!" + ) + # FIXME: should this fail? I'm not sure how destructive it is logger.info("Downloading & setting up user environment...") - installer_url = "https://github.com/conda-forge/miniforge/releases/download/{v}/Mambaforge-{v}-Linux-{arch}.sh".format( - v=mambaforge_new_version, arch=os.uname().machine - ) + installer_url, installer_sha256 = _mambaforge_url() with conda.download_miniconda_installer( installer_url, installer_sha256 ) as installer_path: conda.install_miniconda(installer_path, USER_ENV_PREFIX) - conda_version = "4.10.3" + conda_version = MAMBAFORGE_CONDA_VERSION + mamba_version = MAMBAFORGE_MAMBA_VERSION conda.ensure_conda_packages( USER_ENV_PREFIX, [ # Conda's latest version is on conda much more so than on PyPI. "conda==" + conda_version, - "mamba==" + mambaforge_mamba_version, + "mamba==" + MAMBAFORGE_MAMBA_VERSION, ], ) diff --git a/tljh/utils.py b/tljh/utils.py index 0b61da6..a78fb80 100644 --- a/tljh/utils.py +++ b/tljh/utils.py @@ -59,3 +59,12 @@ def get_plugin_manager(): pm.load_setuptools_entrypoints("tljh") return pm + + +def parse_version(version_string): + """Parse version string to tuple + + Finds all numbers and returns a tuple of ints + _very_ loose version parsing, like the old distutils.version.LooseVersion + """ + return tuple(int(part) for part in version_string.split(".")) From 6d0c1cbf63606444c077281b6b482ff5c3372cbd Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 15:59:37 +0100 Subject: [PATCH 057/232] Remove very old conda versions from installer.py - 4.7.10 was Python 3.7 (no longer supported) - 4.5.4 was Python 3.6 --- tljh/installer.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index c11eb3e..52f956c 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -201,11 +201,8 @@ def ensure_user_environment(user_requirements_txt_file): # format: "conda version": (conda_version, mamba_version), # mambaforge 4.10.3-7 (2023-03-21) "22.11.1": (MAMBAFORGE_CONDA_VERSION, MAMBAFORGE_MAMBA_VERSION), - # tljh up to 0.2.0 (2021-10-18) + # tljh up to 0.2.0 (since 2021-10-18) "4.10.3": ("4.10.3", "0.16.0"), - # very old versions, do these still work? - "4.7.10": ("4.8.1", "0.16.0"), - "4.5.4": ("4.5.8", "0.16.0"), } # Check OS, set appropriate string for conda installer path From 5e6f68eb32e69a771f8091d16c5e07420772f328 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 16:27:39 +0100 Subject: [PATCH 058/232] bump version to 1.0.0.dev0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 194c3af..7a179c6 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="the-littlest-jupyterhub", - version="0.2.0", + version="1.0.0.dev0", description="A small JupyterHub distribution", url="https://github.com/jupyterhub/the-littlest-jupyterhub", author="Jupyter Development Team", From 27c9761f1c87fa451778731ec63e8072a11c9652 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 22 Mar 2023 14:01:33 +0100 Subject: [PATCH 059/232] Apply suggestions from code review Co-authored-by: Simon Li --- bootstrap/bootstrap.py | 4 ++-- docs/index.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index d4c9135..67cf46c 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -9,7 +9,7 @@ This script is run as: Constraints: - - The entire script should be compatible with Python 3.8, which is the on + - The entire script should be compatible with Python 3.8, which is the default on Ubuntu 20.04. - The script should parse in Python 3.6 as we print error messages for using Ubuntu 18.04 which comes with Python 3.6 by default. @@ -214,7 +214,7 @@ def ensure_host_system_can_install_tljh(): print("The Littlest JupyterHub requires Ubuntu 20.04 or higher") sys.exit(1) elif distro == "debian" and _parse_version(version) < (11,): - print("The Littlest JupyterHub requires Debian 10 or higher") + print("The Littlest JupyterHub requires Debian 11 or higher") sys.exit(1) # Require Python 3.8+ diff --git a/docs/index.rst b/docs/index.rst index fb8e02e..f66f4b9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,7 +19,7 @@ Installation The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on an amd64 or arm64 CPU architecture. We aim to support 'stable' and Long-Term Support (LTS) versions. -Newer versions are likely to work with little or no adjustment, but these are the officially supported and tested versions. +Newer versions are likely to work with little or no adjustment, but these are not officially supported or tested. Earlier versions of Ubuntu and Debian are not supported, nor are other Linux distributions. We have a bunch of tutorials to get you started. From 594b61003f731df80ca1f754d58ef3bb68787996 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 23 Mar 2023 12:30:35 +0100 Subject: [PATCH 060/232] add some logging to conda setup --- tljh/conda.py | 13 ++++++++++++- tljh/installer.py | 5 ++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tljh/conda.py b/tljh/conda.py index 7aa864a..6f1f9ce 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -6,8 +6,12 @@ import subprocess import json import hashlib import contextlib +import logging import tempfile +import time + import requests + from tljh import utils from tljh.utils import parse_version as V @@ -78,12 +82,19 @@ def download_miniconda_installer(installer_url, sha256sum): of given version, verifies the sha256sum & provides path to it to the `with` block to run. """ + logger = logging.getLogger("tljh") + logger.info(f"Downloading conda installer {installer_url}") with tempfile.NamedTemporaryFile("wb", suffix=".sh") as f: - f.write(requests.get(installer_url).content) + tic = time.perf_counter() + r = requests.get(installer_url) + r.raise_for_status() + f.write(r.content) # Remain in the NamedTemporaryFile context, but flush changes, see: # https://docs.python.org/3/library/os.html#os.fsync f.flush() os.fsync(f.fileno()) + t = time.perf_counter() - tic + logger.info(f"Downloaded conda installer {installer_url} in {t:.1f}s") if sha256_file(f.name) != sha256sum: raise Exception("sha256sum hash mismatch! Downloaded file corrupted") diff --git a/tljh/installer.py b/tljh/installer.py index 52f956c..df94592 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -212,6 +212,9 @@ def ensure_user_environment(user_requirements_txt_file): have_versions = conda.get_mamba_versions(USER_ENV_PREFIX) have_conda_version = have_versions.get("conda") if have_conda_version: + logger.info( + f"Found prefix at {USER_ENV_PREFIX}, with conda/mamba({have_versions})" + ) for check_version, conda_mamba_version in conda_upgrade_versions.items(): if V(have_conda_version) >= V(check_version): found_conda = True @@ -238,7 +241,7 @@ def ensure_user_environment(user_requirements_txt_file): [ # Conda's latest version is on conda much more so than on PyPI. "conda==" + conda_version, - "mamba==" + MAMBAFORGE_MAMBA_VERSION, + "mamba==" + mamba_version, ], ) From 4d42f24e480b7fa875f0545430b426ae252d9915 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 23 Mar 2023 12:34:44 +0100 Subject: [PATCH 061/232] test ensure_user_environment verify behavior for: - current version (no change) - old, supported version (upgrade, but not too far) - too old, re-run installer - directory exists, no conda --- tests/test_installer.py | 114 ++++++++++++++++++++++++++++++++++++++++ tljh/conda.py | 2 +- tljh/installer.py | 2 +- tljh/utils.py | 5 +- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 9b42d70..bf06aae 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -1,10 +1,16 @@ """ Unit test functions in installer.py """ +import json import os +from unittest import mock +from subprocess import run, PIPE + import pytest +from tljh import conda from tljh import installer +from tljh.utils import parse_version as V from tljh.yaml import yaml @@ -36,3 +42,111 @@ def test_ensure_admins(tljh_dir, admins, expected_config): # verify the list was flattened assert config["users"]["admin"] == expected_config + + +def setup_conda(distro, version, prefix): + """Install mambaforge or miniconda in a prefix""" + if distro == "mambaforge": + installer_url, _ = installer._mambaforge_url(version) + elif distro == "miniconda": + arch = os.uname().machine + installer_url = ( + f"https://repo.anaconda.com/miniconda/Miniconda3-{version}-Linux-{arch}.sh" + ) + else: + raise ValueError(f"{distro=} must be 'miniconda' or 'mambaforge'") + with conda.download_miniconda_installer(installer_url, None) as installer_path: + conda.install_miniconda(installer_path, str(prefix)) + + +@pytest.fixture +def user_env_prefix(tmp_path): + user_env_prefix = tmp_path / "user_env" + with mock.patch.object(installer, "USER_ENV_PREFIX", str(user_env_prefix)): + yield user_env_prefix + + +@pytest.mark.parametrize( + "distro, version, conda_version, mamba_version", + [ + ( + None, + None, + installer.MAMBAFORGE_CONDA_VERSION, + installer.MAMBAFORGE_MAMBA_VERSION, + ), + ( + "exists", + None, + installer.MAMBAFORGE_CONDA_VERSION, + installer.MAMBAFORGE_MAMBA_VERSION, + ), + ( + "mambaforge", + "22.11.1-4", + installer.MAMBAFORGE_CONDA_VERSION, + installer.MAMBAFORGE_MAMBA_VERSION, + ), + ("mambaforge", "4.10.3-7", "4.10.3", "0.16.0"), + ( + "miniconda", + "4.7.10", + installer.MAMBAFORGE_CONDA_VERSION, + installer.MAMBAFORGE_MAMBA_VERSION, + ), + ( + "miniconda", + "4.5.1", + installer.MAMBAFORGE_CONDA_VERSION, + installer.MAMBAFORGE_MAMBA_VERSION, + ), + ], +) +def test_ensure_user_environment( + user_env_prefix, + distro, + version, + conda_version, + mamba_version, +): + if version and V(version) < V("4.10.1") and os.uname().machine == "aarch64": + pytest.skip(f"Miniconda {version} not available for aarch64") + canary_file = user_env_prefix / "test-file.txt" + canary_package = "types-backports_abc" + if distro: + if distro == "exists": + user_env_prefix.mkdir() + else: + setup_conda(distro, version, user_env_prefix) + # install a noarch: python package that won't be used otherwise + # should depend on Python, so it will interact with possible upgrades + run( + [str(user_env_prefix / "bin/conda"), "install", "-y", canary_package], + input="", + check=True, + ) + + # make a file not managed by conda, to check for wipeouts + with canary_file.open("w") as f: + f.write("I'm here\n") + + installer.ensure_user_environment("") + p = run( + [str(user_env_prefix / "bin/conda"), "list", "--json"], + stdout=PIPE, + text=True, + check=True, + ) + package_list = json.loads(p.stdout) + packages = {package["name"]: package for package in package_list} + if distro: + # make sure we didn't wipe out files + assert canary_file.exists() + if distro != "exists": + # make sure we didn't delete the installed package + assert canary_package in packages + + assert "conda" in packages + assert packages["conda"]["version"] == conda_version + assert "mamba" in packages + assert packages["mamba"]["version"] == mamba_version diff --git a/tljh/conda.py b/tljh/conda.py index 6f1f9ce..19dc937 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -96,7 +96,7 @@ def download_miniconda_installer(installer_url, sha256sum): t = time.perf_counter() - tic logger.info(f"Downloaded conda installer {installer_url} in {t:.1f}s") - if sha256_file(f.name) != sha256sum: + if sha256sum and sha256_file(f.name) != sha256sum: raise Exception("sha256sum hash mismatch! Downloaded file corrupted") yield f.name diff --git a/tljh/installer.py b/tljh/installer.py index df94592..b463ff1 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -224,7 +224,7 @@ def ensure_user_environment(user_requirements_txt_file): if not found_conda: if os.path.exists(USER_ENV_PREFIX): logger.warning( - f"Found prefix at {USER_ENV_PREFIX}, but too old or missing conda/mamba ({have_versions}). Rebuilding env from scratch!!" + f"Found prefix at {USER_ENV_PREFIX}, but too old or missing conda/mamba ({have_versions}). Upgrading from mambaforge." ) # FIXME: should this fail? I'm not sure how destructive it is logger.info("Downloading & setting up user environment...") diff --git a/tljh/utils.py b/tljh/utils.py index a78fb80..d4bf127 100644 --- a/tljh/utils.py +++ b/tljh/utils.py @@ -2,6 +2,7 @@ Miscellaneous functions useful in at least two places unrelated to each other """ import logging +import re import subprocess # Copied into bootstrap/bootstrap.py. Make sure these two copies are exactly the same! @@ -67,4 +68,6 @@ def parse_version(version_string): Finds all numbers and returns a tuple of ints _very_ loose version parsing, like the old distutils.version.LooseVersion """ - return tuple(int(part) for part in version_string.split(".")) + # return a tuple of all the numbers in the version string + # always succeeds, even if passed nonsense + return tuple(int(part) for part in re.findall(r"\d+", version_string)) From a24e038136b819783ae2ee16586720f511db3938 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 23 Mar 2023 15:39:46 +0100 Subject: [PATCH 062/232] get canary package from conda-forge --- tests/test_installer.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index bf06aae..97524d6 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -121,7 +121,13 @@ def test_ensure_user_environment( # install a noarch: python package that won't be used otherwise # should depend on Python, so it will interact with possible upgrades run( - [str(user_env_prefix / "bin/conda"), "install", "-y", canary_package], + [ + str(user_env_prefix / "bin/conda"), + "install", + "-y", + "-c" "conda-forge", + canary_package, + ], input="", check=True, ) From 755d0d02fbe4cbbc0fff2ca7cdc17ed7220c3fa7 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 23 Mar 2023 16:26:34 +0100 Subject: [PATCH 063/232] avoid auto-updating conda in test env because then we don't get the version we expect to test with! --- tests/test_installer.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_installer.py b/tests/test_installer.py index 97524d6..861c947 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -57,6 +57,19 @@ def setup_conda(distro, version, prefix): raise ValueError(f"{distro=} must be 'miniconda' or 'mambaforge'") with conda.download_miniconda_installer(installer_url, None) as installer_path: conda.install_miniconda(installer_path, str(prefix)) + # avoid auto-updating conda when we install other packages + run( + [ + str(prefix / "bin/conda"), + "config", + "--system", + "--set", + "auto_update_conda", + "false", + ], + input="", + check=True, + ) @pytest.fixture From 5da2859408126a7570eb7dc3974e1a73275d1728 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 24 Mar 2023 11:30:43 +0100 Subject: [PATCH 064/232] avoid registering duplicate log handlers init_logging is called many times in test_config, which has been registering numerous duplicate log handlers, attached to stderr that's closed between tests --- tljh/log.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tljh/log.py b/tljh/log.py index f626c96..e615f7b 100644 --- a/tljh/log.py +++ b/tljh/log.py @@ -9,6 +9,13 @@ def init_logging(): """Setup default tljh logger""" logger = logging.getLogger("tljh") os.makedirs(INSTALL_PREFIX, exist_ok=True) + + # check if any log handlers are already registered + # don't reconfigure logs if handlers are already configured + # e.g. happens in pytest, which hooks up log handlers for reporting + # or if this function is called twice + if logger.hasHandlers(): + return file_logger = logging.FileHandler(os.path.join(INSTALL_PREFIX, "installer.log")) file_logger.setFormatter(logging.Formatter("%(asctime)s %(message)s")) logger.addHandler(file_logger) From 6b0b5f2998eaafa27d34c57f1a037ea861291365 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 24 Mar 2023 11:36:36 +0100 Subject: [PATCH 065/232] add bzip2 to test env required for testing against miniconda 4.5 --- .github/workflows/unit-test.yaml | 1 + integration-tests/Dockerfile | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index a0acde8..3c51a98 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -61,6 +61,7 @@ jobs: apt-get update apt-get install --yes \ python3-venv \ + bzip2 \ git python3 -m venv /srv/venv diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile index 447bcb7..22cd2d4 100644 --- a/integration-tests/Dockerfile +++ b/integration-tests/Dockerfile @@ -8,6 +8,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \ && apt-get update \ && apt-get install --yes \ systemd \ + bzip2 \ curl \ git \ sudo \ From 5980cb3ef22221b1f20c20a2e8928ef297f01668 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 24 Mar 2023 11:58:32 +0100 Subject: [PATCH 066/232] log commands before they start (at debug level) so you know what you're waiting for --- tljh/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tljh/utils.py b/tljh/utils.py index d4bf127..8ab1ca8 100644 --- a/tljh/utils.py +++ b/tljh/utils.py @@ -25,10 +25,11 @@ def run_subprocess(cmd, *args, **kwargs): and failed output directly to the user's screen """ logger = logging.getLogger("tljh") + printable_command = " ".join(cmd) + logger.debug("Running %s", printable_command) 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( From de36cfc116ee59414d6968abb9a0162a4cd241a5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 27 Mar 2023 09:31:02 +0200 Subject: [PATCH 067/232] adopt myst run rst2myst, with minimal manual formatting fixes --- CONTRIBUTING.rst | 8 - docs/PULL_REQUEST_TEMPLATE.md | 4 - docs/conf.py | 15 +- .../{code-review.rst => code-review.md} | 29 +- docs/contributing/dev-setup.md | 72 +++++ docs/contributing/dev-setup.rst | 75 ----- docs/contributing/{docs.rst => docs.md} | 184 +++++------- docs/contributing/{index.rst => index.md} | 23 +- .../{packages.rst => packages.md} | 30 +- docs/contributing/plugins.md | 132 +++++++++ docs/contributing/plugins.rst | 147 --------- docs/contributing/tests.md | 62 ++++ docs/contributing/tests.rst | 66 ----- docs/howto/admin/admin-users.md | 100 +++++++ docs/howto/admin/admin-users.rst | 102 ------- docs/howto/admin/enable-extensions.md | 56 ++++ docs/howto/admin/enable-extensions.rst | 58 ---- docs/howto/admin/https.md | 118 ++++++++ docs/howto/admin/https.rst | 112 ------- .../howto/admin/{nbresuse.rst => nbresuse.md} | 13 +- docs/howto/admin/{resize.rst => resize.md} | 52 ++-- ...-estimation.rst => resource-estimation.md} | 55 ++-- docs/howto/admin/systemd.md | 82 +++++ docs/howto/admin/systemd.rst | 88 ------ docs/howto/auth/awscognito.md | 128 ++++++++ docs/howto/auth/awscognito.rst | 122 -------- docs/howto/auth/dummy.md | 47 +++ docs/howto/auth/dummy.rst | 51 ---- docs/howto/auth/firstuse.md | 79 +++++ docs/howto/auth/firstuse.rst | 81 ----- docs/howto/auth/github.md | 108 +++++++ docs/howto/auth/github.rst | 93 ------ docs/howto/auth/google.md | 133 +++++++++ docs/howto/auth/google.rst | 119 -------- docs/howto/auth/nativeauth.md | 33 +++ docs/howto/auth/nativeauth.rst | 40 --- docs/howto/content/add-data.md | 100 +++++++ docs/howto/content/add-data.rst | 97 ------ .../{nbgitpuller.rst => nbgitpuller.md} | 41 ++- docs/howto/content/share-data.md | 137 +++++++++ docs/howto/content/share-data.rst | 139 --------- docs/howto/env/notebook-interfaces.rst | 56 ---- docs/howto/env/server-resources.rst | 10 - docs/howto/env/user-environment.rst | 209 ------------- docs/howto/index.md | 67 +++++ docs/howto/index.rst | 68 ----- docs/howto/providers/azure.md | 38 +++ docs/howto/providers/azure.rst | 36 --- docs/howto/providers/digitalocean.md | 42 +++ docs/howto/providers/digitalocean.rst | 43 --- docs/{index.rst => index.md} | 73 +++-- docs/install/amazon.md | 279 ++++++++++++++++++ docs/install/amazon.rst | 269 ----------------- docs/install/azure.md | 218 ++++++++++++++ docs/install/azure.rst | 192 ------------ docs/install/custom-server.md | 95 ++++++ docs/install/custom-server.rst | 99 ------- docs/install/digitalocean.md | 123 ++++++++ docs/install/digitalocean.rst | 119 -------- docs/install/google.md | 219 ++++++++++++++ docs/install/google.rst | 205 ------------- docs/install/{index.rst => index.md} | 27 +- docs/install/jetstream.md | 152 ++++++++++ docs/install/jetstream.rst | 145 --------- docs/install/ovh.md | 133 +++++++++ docs/install/ovh.rst | 127 -------- docs/requirements.txt | 1 + docs/topic/authenticator-configuration.md | 89 ++++++ docs/topic/authenticator-configuration.rst | 95 ------ docs/topic/customizing-installer.md | 135 +++++++++ docs/topic/customizing-installer.rst | 140 --------- docs/topic/escape-hatch.md | 91 ++++++ docs/topic/escape-hatch.rst | 94 ------ .../topic/{idle-culler.rst => idle-culler.md} | 106 +++---- docs/topic/index.md | 19 ++ docs/topic/index.rst | 20 -- docs/topic/installer-actions.md | 218 ++++++++++++++ docs/topic/installer-actions.rst | 214 -------------- docs/topic/jupyterhub-configurator.md | 21 ++ docs/topic/jupyterhub-configurator.rst | 25 -- docs/topic/requirements.md | 23 ++ docs/topic/requirements.rst | 29 -- docs/topic/{security.rst => security.md} | 61 ++-- docs/topic/tljh-config.md | 246 +++++++++++++++ docs/topic/tljh-config.rst | 271 ----------------- docs/topic/{whentouse.rst => whentouse.md} | 26 +- docs/troubleshooting/{index.rst => index.md} | 26 +- docs/troubleshooting/logs.md | 105 +++++++ docs/troubleshooting/logs.rst | 112 ------- docs/troubleshooting/providers/amazon.md | 26 ++ docs/troubleshooting/providers/amazon.rst | 32 -- .../providers/{custom.rst => custom.md} | 27 +- docs/troubleshooting/providers/google.md | 17 ++ docs/troubleshooting/providers/google.rst | 22 -- docs/troubleshooting/restart.md | 27 ++ docs/troubleshooting/restart.rst | 29 -- 96 files changed, 4131 insertions(+), 4491 deletions(-) delete mode 100644 CONTRIBUTING.rst delete mode 100644 docs/PULL_REQUEST_TEMPLATE.md rename docs/contributing/{code-review.rst => code-review.md} (69%) create mode 100644 docs/contributing/dev-setup.md delete mode 100644 docs/contributing/dev-setup.rst rename docs/contributing/{docs.rst => docs.md} (58%) rename docs/contributing/{index.rst => index.md} (55%) rename docs/contributing/{packages.rst => packages.md} (61%) create mode 100644 docs/contributing/plugins.md delete mode 100644 docs/contributing/plugins.rst create mode 100644 docs/contributing/tests.md delete mode 100644 docs/contributing/tests.rst create mode 100644 docs/howto/admin/admin-users.md delete mode 100644 docs/howto/admin/admin-users.rst create mode 100644 docs/howto/admin/enable-extensions.md delete mode 100644 docs/howto/admin/enable-extensions.rst create mode 100644 docs/howto/admin/https.md delete mode 100644 docs/howto/admin/https.rst rename docs/howto/admin/{nbresuse.rst => nbresuse.md} (53%) rename docs/howto/admin/{resize.rst => resize.md} (52%) rename docs/howto/admin/{resource-estimation.rst => resource-estimation.md} (70%) create mode 100644 docs/howto/admin/systemd.md delete mode 100644 docs/howto/admin/systemd.rst create mode 100644 docs/howto/auth/awscognito.md delete mode 100644 docs/howto/auth/awscognito.rst create mode 100644 docs/howto/auth/dummy.md delete mode 100644 docs/howto/auth/dummy.rst create mode 100644 docs/howto/auth/firstuse.md delete mode 100644 docs/howto/auth/firstuse.rst create mode 100644 docs/howto/auth/github.md delete mode 100644 docs/howto/auth/github.rst create mode 100644 docs/howto/auth/google.md delete mode 100644 docs/howto/auth/google.rst create mode 100644 docs/howto/auth/nativeauth.md delete mode 100644 docs/howto/auth/nativeauth.rst create mode 100644 docs/howto/content/add-data.md delete mode 100644 docs/howto/content/add-data.rst rename docs/howto/content/{nbgitpuller.rst => nbgitpuller.md} (53%) create mode 100644 docs/howto/content/share-data.md delete mode 100644 docs/howto/content/share-data.rst delete mode 100644 docs/howto/env/notebook-interfaces.rst delete mode 100644 docs/howto/env/server-resources.rst delete mode 100644 docs/howto/env/user-environment.rst create mode 100644 docs/howto/index.md delete mode 100644 docs/howto/index.rst create mode 100644 docs/howto/providers/azure.md delete mode 100644 docs/howto/providers/azure.rst create mode 100644 docs/howto/providers/digitalocean.md delete mode 100644 docs/howto/providers/digitalocean.rst rename docs/{index.rst => index.md} (65%) create mode 100644 docs/install/amazon.md delete mode 100644 docs/install/amazon.rst create mode 100644 docs/install/azure.md delete mode 100644 docs/install/azure.rst create mode 100644 docs/install/custom-server.md delete mode 100644 docs/install/custom-server.rst create mode 100644 docs/install/digitalocean.md delete mode 100644 docs/install/digitalocean.rst create mode 100644 docs/install/google.md delete mode 100644 docs/install/google.rst rename docs/install/{index.rst => index.md} (71%) create mode 100644 docs/install/jetstream.md delete mode 100644 docs/install/jetstream.rst create mode 100644 docs/install/ovh.md delete mode 100644 docs/install/ovh.rst create mode 100644 docs/topic/authenticator-configuration.md delete mode 100644 docs/topic/authenticator-configuration.rst create mode 100644 docs/topic/customizing-installer.md delete mode 100644 docs/topic/customizing-installer.rst create mode 100644 docs/topic/escape-hatch.md delete mode 100644 docs/topic/escape-hatch.rst rename docs/topic/{idle-culler.rst => idle-culler.md} (58%) create mode 100644 docs/topic/index.md delete mode 100644 docs/topic/index.rst create mode 100644 docs/topic/installer-actions.md delete mode 100644 docs/topic/installer-actions.rst create mode 100644 docs/topic/jupyterhub-configurator.md delete mode 100644 docs/topic/jupyterhub-configurator.rst create mode 100644 docs/topic/requirements.md delete mode 100644 docs/topic/requirements.rst rename docs/topic/{security.rst => security.md} (61%) create mode 100644 docs/topic/tljh-config.md delete mode 100644 docs/topic/tljh-config.rst rename docs/topic/{whentouse.rst => whentouse.md} (51%) rename docs/troubleshooting/{index.rst => index.md} (66%) create mode 100644 docs/troubleshooting/logs.md delete mode 100644 docs/troubleshooting/logs.rst create mode 100644 docs/troubleshooting/providers/amazon.md delete mode 100644 docs/troubleshooting/providers/amazon.rst rename docs/troubleshooting/providers/{custom.rst => custom.md} (55%) create mode 100644 docs/troubleshooting/providers/google.md delete mode 100644 docs/troubleshooting/providers/google.rst create mode 100644 docs/troubleshooting/restart.md delete mode 100644 docs/troubleshooting/restart.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index e68b151..0000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,8 +0,0 @@ -Contributing to The Littlest JupyterHub development ---------------------------------------------------- - -This is an open source project that is developed and maintained by volunteers. -Your contribution is integral to the future of the project. Thank you! - -See the `contributing guide `_ -for information on the different ways of contributing to The Littlest JupyterHub. diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 214518a..0000000 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,4 +0,0 @@ -- [ ] Add / update documentation -- [ ] Add tests - - diff --git a/docs/conf.py b/docs/conf.py index 321b5f2..ec5cba6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,9 +23,10 @@ extensions = [ "sphinx_copybutton", "sphinxext.opengraph", "sphinxext.rediraffe", + "myst_parser", ] root_doc = "index" -source_suffix = [".rst"] +source_suffix = [".md"] # -- Options for HTML output ------------------------------------------------- @@ -59,6 +60,18 @@ html_context = { "doc_path": "docs", } +# -- MyST configuration ------------------------------------------------------ +# ref: https://myst-parser.readthedocs.io/en/latest/configuration.html +# +myst_heading_anchors = 2 + +myst_enable_extensions = [ + # available extensions: https://myst-parser.readthedocs.io/en/latest/syntax/optional.html + "attrs_inline", + "colon_fence", + "deflist", + "fieldlist", +] # -- Options for linkcheck builder ------------------------------------------- # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder diff --git a/docs/contributing/code-review.rst b/docs/contributing/code-review.md similarity index 69% rename from docs/contributing/code-review.rst rename to docs/contributing/code-review.md index 76a2e8b..ce96e3d 100644 --- a/docs/contributing/code-review.rst +++ b/docs/contributing/code-review.md @@ -1,37 +1,32 @@ -.. _contributing/code-review: +(contributing-code-review)= -====================== -Code Review guidelines -====================== +# Code Review guidelines This document outlines general guidelines to follow when you are making or reviewing a Pull Request. -Have empathy -============ +## Have empathy -We recommend reading `On Empathy & Pull Requests `_ -and `How about code reviews `_ +We recommend reading [On Empathy & Pull Requests](https://slack.engineering/on-empathy-pull-requests-979e4257d158) +and [How about code reviews](https://slack.engineering/how-about-code-reviews-2695fb10d034) to learn more about being empathetic in code reviews. -Write documentation -=================== +## Write documentation If your pull request touches any code, you must write or update documentation for it. For this project, documentation is a lot more important than the code. -If a feature is not documented, it does not exist. If a behavior is not documented, -it is a bug. +If a feature is not documented, it does not exist. If a behavior is not documented, +it is a bug. Do not worry about having perfect documentation! Documentation improves over time. The requirement is to have documentation before merging a pull request, -not to have *perfect* documentation before merging a pull request. If you +not to have _perfect_ documentation before merging a pull request. If you are new and not sure how to add documentation, other contributors will be happy to guide you. -See :ref:`contributing/docs` for guidelines on writing documentation. +See {ref}`contributing/docs` for guidelines on writing documentation. -Write tests -=========== +## Write tests If your pull request touches any code, you must write unit or integration tests to exercise it. This helps validate & communicate that your pull request works @@ -48,4 +43,4 @@ add more tests. If you are unsure what kind of tests to add for your pull request, other contributors to the repo will be happy to help guide you! -See :ref:`contributing/tests` for guidelines on writing tests. +See {ref}`contributing/tests` for guidelines on writing tests. diff --git a/docs/contributing/dev-setup.md b/docs/contributing/dev-setup.md new file mode 100644 index 0000000..35f8016 --- /dev/null +++ b/docs/contributing/dev-setup.md @@ -0,0 +1,72 @@ +(contributing-dev-setup)= + +# Setting up Development Environment + +The easiest & safest way to develop & test TLJH is with [Docker](https://www.docker.com/). + +1. Install Docker Community Edition by following the instructions on + [their website](https://www.docker.com/community-edition). + +2. Clone the [git repo](https://github.com/jupyterhub/the-littlest-jupyterhub) (or your fork of it). + +3. Build a docker image that has a functional systemd in it. + + ```bash + docker build -t tljh-systemd . -f integration-tests/Dockerfile + ``` + +4. Run a docker container with the image in the background, while bind mounting + your TLJH repository under `/srv/src`. + + ```bash + docker run \ + --privileged \ + --detach \ + --name=tljh-dev \ + --publish 12000:80 \ + --mount type=bind,source=$(pwd),target=/srv/src \ + tljh-systemd + ``` + +5. Get a shell inside the running docker container. + + ```bash + docker exec -it tljh-dev /bin/bash + ``` + +6. Run the bootstrapper from inside the container (see step above): + The container image is already set up to default to a `dev` install, so + it'll install from your local repo rather than from github. + + ```console + python3 /srv/src/bootstrap/bootstrap.py --admin admin + ``` + +> Or, if you would like to setup the admin's password during install, +> you can use this command (replace "admin" with the desired admin username +> and "password" with the desired admin password): +> +> > ```console +> > python3 /srv/src/bootstrap/bootstrap.py --admin admin:password +> > ``` +> > +> > The primary hub environment will also be in your PATH already for convenience. + +1. You should be able to access the JupyterHub from your browser now at + [http://localhost:12000](http://localhost:12000). Congratulations, you are + set up to develop TLJH! + +2. Make some changes to the repository. You can test easily depending on what + you changed. + + - If you changed the `bootstrap/bootstrap.py` script or any of its dependencies, + you can test it by running `python3 /srv/src/bootstrap/bootstrap.py`. + - If you changed the `tljh/installer.py` code (or any of its dependencies), + you can test it by running `python3 -m tljh.installer`. + - If you changed `tljh/jupyterhub_config.py`, `tljh/configurer.py`, + `/opt/tljh/config/` or any of their dependencies, you only need to + restart jupyterhub for them to take effect. `tljh-config reload hub` + should do that. + +{ref}`troubleshooting/logs` has information on looking at various logs in the container +to debug issues you might have. diff --git a/docs/contributing/dev-setup.rst b/docs/contributing/dev-setup.rst deleted file mode 100644 index 40997a7..0000000 --- a/docs/contributing/dev-setup.rst +++ /dev/null @@ -1,75 +0,0 @@ -.. _contributing/dev-setup: - -================================== -Setting up Development Environment -================================== - -The easiest & safest way to develop & test TLJH is with `Docker `_. - -#. Install Docker Community Edition by following the instructions on - `their website `_. - -#. Clone the `git repo `_ (or your fork of it). -#. Build a docker image that has a functional systemd in it. - - .. code-block:: bash - - docker build -t tljh-systemd . -f integration-tests/Dockerfile - -#. Run a docker container with the image in the background, while bind mounting - your TLJH repository under ``/srv/src``. - - .. code-block:: bash - - docker run \ - --privileged \ - --detach \ - --name=tljh-dev \ - --publish 12000:80 \ - --mount type=bind,source=$(pwd),target=/srv/src \ - tljh-systemd - -#. Get a shell inside the running docker container. - - .. code-block:: bash - - docker exec -it tljh-dev /bin/bash - -#. Run the bootstrapper from inside the container (see step above): - The container image is already set up to default to a ``dev`` install, so - it'll install from your local repo rather than from github. - - .. code-block:: console - - python3 /srv/src/bootstrap/bootstrap.py --admin admin - - Or, if you would like to setup the admin's password during install, - you can use this command (replace "admin" with the desired admin username - and "password" with the desired admin password): - - .. code-block:: console - - python3 /srv/src/bootstrap/bootstrap.py --admin admin:password - - The primary hub environment will also be in your PATH already for convenience. - -#. You should be able to access the JupyterHub from your browser now at - `http://localhost:12000 `_. Congratulations, you are - set up to develop TLJH! - -#. Make some changes to the repository. You can test easily depending on what - you changed. - - * If you changed the ``bootstrap/bootstrap.py`` script or any of its dependencies, - you can test it by running ``python3 /srv/src/bootstrap/bootstrap.py``. - - * If you changed the ``tljh/installer.py`` code (or any of its dependencies), - you can test it by running ``python3 -m tljh.installer``. - - * If you changed ``tljh/jupyterhub_config.py``, ``tljh/configurer.py``, - ``/opt/tljh/config/`` or any of their dependencies, you only need to - restart jupyterhub for them to take effect. ``tljh-config reload hub`` - should do that. - -:ref:`troubleshooting/logs` has information on looking at various logs in the container -to debug issues you might have. diff --git a/docs/contributing/docs.rst b/docs/contributing/docs.md similarity index 58% rename from docs/contributing/docs.rst rename to docs/contributing/docs.md index be6d6ae..be45e0a 100644 --- a/docs/contributing/docs.rst +++ b/docs/contributing/docs.md @@ -1,13 +1,11 @@ -.. _contributing/docs: +(contributing-docs)= -===================== -Writing documentation -===================== +# Writing documentation -.. note:: - - Heavily inspired by the - `django project's guidelines `_ +:::{note} +Heavily inspired by the +[django project's guidelines](https://docs.djangoproject.com/en/dev/internals/contributing/writing-documentation/) +::: We place a high importance on consistency, readability and completeness of documentation. If a feature is not documented, it does not exist. If a behavior is not documented, @@ -17,70 +15,62 @@ possible. Documentation changes generally come in two forms: -* General improvements: typo corrections, error fixes and better +- General improvements: typo corrections, error fixes and better explanations through clearer writing and more examples. - -* New features: documentation of features that have been added to the +- New features: documentation of features that have been added to the framework since the last release. This section explains how writers can craft their documentation changes in the most useful and least error-prone ways. -Getting the raw documentation -============================= +## Getting the raw documentation Though TLJH's documentation is intended to be read as HTML at -https://the-littlest-jupyterhub.readthedocs.io/, we edit it as a collection of text files for -maximum flexibility. These files live in the top-level ``docs/`` directory of +, we edit it as a collection of text files for +maximum flexibility. These files live in the top-level `docs/` directory of TLJH's repository. If you'd like to start contributing to our docs, get the development version of TLJH from the source code repository. The development version has the latest-and-greatest documentation, just as it has latest-and-greatest code. -Getting started with Sphinx -=========================== +## Getting started with Sphinx -TLJH's documentation uses the Sphinx__ documentation system, which in turn -is based on docutils__. The basic idea is that lightly-formatted plain-text +TLJH's documentation uses the [Sphinx](http://sphinx-doc.org/) documentation system, which in turn +is based on [docutils](http://docutils.sourceforge.net/). The basic idea is that lightly-formatted plain-text 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 the Sphinx dependencies: -.. code-block:: console +```console +$ cd docs/ +$ pip install -r requirements.txt +``` - $ cd docs/ - $ pip install -r requirements.txt +Then from the `docs` directory, build the HTML: -Then from the ``docs`` directory, build the HTML: - -.. code-block:: console - - $ make html +```console +$ make html +``` If you encounter this error, it's likely that you are running inside a virtual environment. -.. code-block:: console +```console +Error in "currentmodule" directive: +``` - Error in "currentmodule" directive: - -To get started contributing, you'll want to read the :ref:`reStructuredText -reference ` +To get started contributing, you'll want to read the {ref}`reStructuredText reference ` Your locally-built documentation will be themed differently than the -documentation at `the-littlest-jupyterhub.readthedocs.io `_. +documentation at [the-littlest-jupyterhub.readthedocs.io](https://the-littlest-jupyterhub.readthedocs.io). This is OK! If your changes look good on your local machine, they'll look good on the website. -How the documentation is organized -================================== +## How the documentation is organized The documentation is organized into several categories: -* **Tutorials** take the reader by the hand through a series +- **Tutorials** take the reader by the hand through a series of steps to create something. The important thing in a tutorial is to help the reader achieve something @@ -97,7 +87,7 @@ The documentation is organized into several categories: systems. These should cross-link a lot to other parts of the documentation, avoid forcing the user to learn to SSH if possible & have lots of screenshots. -* **Topic guides** aim to explain a concept or subject at a +- **Topic guides** aim to explain a concept or subject at a fairly high level. Link to reference material rather than repeat it. Use examples and don't be @@ -107,7 +97,7 @@ The documentation is organized into several categories: Providing background context helps a newcomer connect the topic to things that they already know. -* **Reference guides** contain technical reference for APIs. +- **Reference guides** contain technical reference for APIs. They describe the functioning of TLJH's internal machinery and instruct in its use. @@ -119,7 +109,7 @@ The documentation is organized into several categories: yourself explaining basic concepts, you may want to move that material to a topic guide. -* **How-to guides** are recipes that take the reader through +- **How-to guides** are recipes that take the reader through steps in key subjects. What matters most in a how-to guide is what a user wants to achieve. @@ -131,83 +121,76 @@ The documentation is organized into several categories: hesitate to refer the reader back to the appropriate tutorial rather than repeat the same material. -* **Troubleshooting guides** help reader answer the question "Why is my JupyterHub +- **Troubleshooting guides** help reader answer the question "Why is my JupyterHub not working?". These guides help readers try find causes for their symptoms, and hopefully fix the issues. Some of these need to be specific to cloud providers, and that is acceptable. -Writing style -============= +## Writing style -Typically, documentation is written in second person, referring to the reader as “you”. +Typically, documentation is written in second person, referring to the reader as “you”. When using pronouns in reference to a hypothetical person, such as "a user with a running notebook", gender neutral pronouns (they/their/them) should be used. Instead of: -* he or she... use they. -* him or her... use them. -* his or her... use their. -* his or hers... use theirs. -* himself or herself... use themselves. +- he or she... use they. +- him or her... use them. +- his or her... use their. +- his or hers... use theirs. +- himself or herself... use themselves. -Commonly used terms -=================== +## Commonly used terms Here are some style guidelines on commonly used terms throughout the documentation: -* **TLJH** -- common abbreviation of The Littlest JupyterHub. Fully +- **TLJH** -- common abbreviation of The Littlest JupyterHub. Fully capitalized except when used in code / the commandline. - -* **Python** -- when referring to the language, capitalize Python. - -* **Notebook Interface** -- generic term for referring to JupyterLab, +- **Python** -- when referring to the language, capitalize Python. +- **Notebook Interface** -- generic term for referring to JupyterLab, nteract, classic notebook & other user interfaces for accessing - -Guidelines for reStructuredText files -===================================== +## Guidelines for reStructuredText files These guidelines regulate the format of our reST (reStructuredText) documentation: -* In section titles, capitalize only initial words and proper nouns. +- In section titles, capitalize only initial words and proper nouns. -* Wrap the documentation at 120 characters wide, unless a code example +- Wrap the documentation at 120 characters wide, unless a code example is significantly less readable when split over two lines, or for another good reason. +- Use these heading styles: -* Use these heading styles:: + ``` + === + One + === - === - One - === + Two + === - Two - === + Three + ----- - Three - ----- + Four + ~~~~ - Four - ~~~~ + Five + ^^^^ + ``` - Five - ^^^^ - -Documenting new features -======================== +## Documenting new features Our policy for new features is: - All new features must have appropriate documentation before they - can be merged. +> All new features must have appropriate documentation before they +> can be merged. -Choosing image size -=================== +## Choosing image size When adding images to the documentation, try to keep them as small as possible. Larger images make the site load more slowly on browsers, and may make the site @@ -217,37 +200,34 @@ If you're adding screenshots, make the size of your shot as small as possible. If you're uploading large images, consider using an image optimizer in order to reduce its size. -For example, for PNG files, use OptiPNG and AdvanceCOMP's ``advpng``: +For example, for PNG files, use OptiPNG and AdvanceCOMP's `advpng`: -.. code-block:: console - - $ cd docs - $ optipng -o7 -zm1-9 -i0 -strip all `find . -type f -not -path "./_build/*" -name "*.png"` - $ advpng -z4 `find . -type f -not -path "./_build/*" -name "*.png"` +```console +$ cd docs +$ optipng -o7 -zm1-9 -i0 -strip all `find . -type f -not -path "./_build/*" -name "*.png"` +$ advpng -z4 `find . -type f -not -path "./_build/*" -name "*.png"` +``` This is based on OptiPNG version 0.7.5. Older versions may complain about the -``--strip all`` option being lossy. +`--strip all` option being lossy. -Spelling check -============== +## Spelling check Before you commit your docs, it's a good idea to run the spelling checker. You'll need to install a couple packages first: -* `pyenchant `_ (which requires - `enchant `_) +- [pyenchant](https://pypi.org/project/pyenchant/) (which requires + [enchant](https://www.abisource.com/projects/enchant/)) +- [sphinxcontrib-spelling](https://pypi.org/project/sphinxcontrib-spelling/) -* `sphinxcontrib-spelling - `_ - -Then from the ``docs`` directory, run ``make spelling``. Wrong words (if any) +Then from the `docs` directory, run `make spelling`. Wrong words (if any) along with the file and line number where they occur will be saved to -``_build/spelling/output.txt``. +`_build/spelling/output.txt`. If you encounter false-positives (error output that actually is correct), do one of the following: -* Surround inline code or brand/technology names with grave accents (`). -* Find synonyms that the spell checker recognizes. -* If, and only if, you are sure the word you are using is correct - add it - to ``docs/spelling_wordlist`` (please keep the list in alphabetical order). +- Surround inline code or brand/technology names with grave accents (\`). +- Find synonyms that the spell checker recognizes. +- If, and only if, you are sure the word you are using is correct - add it + to `docs/spelling_wordlist` (please keep the list in alphabetical order). diff --git a/docs/contributing/index.rst b/docs/contributing/index.md similarity index 55% rename from docs/contributing/index.rst rename to docs/contributing/index.md index d8e294b..97d2c1c 100644 --- a/docs/contributing/index.rst +++ b/docs/contributing/index.md @@ -1,6 +1,4 @@ -============ -Contributing -============ +# Contributing ✨ Thank you for thinking about contributing to the littlest JupyterHub! ✨ @@ -9,14 +7,15 @@ Your contribution is integral to the future of the project. Thank you! This section contains documentation for people who want to contribute. -You can find the `source code on GitHub `_ +You can find the [source code on GitHub](https://github.com/jupyterhub/the-littlest-jupyterhub/tree/HEAD/tljh) -.. toctree:: - :titlesonly: +```{toctree} +:titlesonly: true - docs - dev-setup - tests - plugins - code-review - packages +docs +dev-setup +tests +plugins +code-review +packages +``` diff --git a/docs/contributing/packages.rst b/docs/contributing/packages.md similarity index 61% rename from docs/contributing/packages.rst rename to docs/contributing/packages.md index 72d329c..84267be 100644 --- a/docs/contributing/packages.rst +++ b/docs/contributing/packages.md @@ -1,46 +1,40 @@ -.. _contributing/packages: +(contributing-packages)= -======================= -Environments & Packages -======================= +# Environments & Packages TLJH installs packages from different sources during installation. This document describes the various sources and how to upgrade versions of packages installed. -Python Environments -=================== +## Python Environments TLJH sets up two python environments during installation. 1. **Hub Environment**. JupyterHub, authenticators, spawners, TLJH plugins and the TLJH configuration management code is installed into this - environment. A `venv `_ is used, + environment. A [venv](https://docs.python.org/3/library/venv.html) is used, primarily since conda does not support ARM CPUs and we'd like to support the RaspberryPI someday. Admins generally do not install custom packages in this environment. - 2. **User Environment**. Jupyter Notebook, JupyterLab, nteract, kernels, and packages the users wanna use (such as numpy, scipy, etc) are installed - here. A `conda `_ environment is used here, since - a lot of scientific packages are available from Conda. ``pip`` is still + here. A [conda](https://conda.io) environment is used here, since + a lot of scientific packages are available from Conda. `pip` is still used to install Jupyter specific packages, primarily because most notebook - extensions are still available only on `PyPI `_. + extensions are still available only on [PyPI](https://pypi.org). Admins can install packages here for use by all users. -Python package versions -======================= +## Python package versions -In ``installer.py``, most Python packages have a version specified. This +In `installer.py`, most Python packages have a version specified. This can be upgraded freely whenever needed. Some of them have version checks -in ``integration-tests/test_extensions.py``, so those might need +in `integration-tests/test_extensions.py`, so those might need updating too. -Apt packages -============ +## Apt packages Base operating system packages, including Python itself, are installed -via ``apt`` from the base Ubuntu repositories. +via `apt` from the base Ubuntu repositories. We generally do not pin versions of packages provided by apt, instead just using the latest versions provided by Ubuntu. diff --git a/docs/contributing/plugins.md b/docs/contributing/plugins.md new file mode 100644 index 0000000..a076485 --- /dev/null +++ b/docs/contributing/plugins.md @@ -0,0 +1,132 @@ +(contributing-plugins)= + +# TLJH Plugins + +TLJH plugins are the official way to make customized 'spins' or 'stacks' +with TLJH as the base. For example, the earth sciences community can make +a plugin that installs commonly used packages, set up authentication +and pre-download useful datasets. The mybinder.org community can +make a plugin that gives you a single-node, single-repository mybinder.org. +Plugins are very powerful, so the possibilities are endless. + +## Design + +[pluggy](https://github.com/pytest-dev/pluggy) is used to implement +plugin functionality. TLJH exposes specific **hooks** that your plugin +can provide implementations for. This allows us to have specific hook +points in the application that can be explicitly extended by plugins, +balancing the need to change TLJH internals in the future with the +stability required for a good plugin ecosystem. + +## Installing Plugins + +Include `--plugin ` in the Installer script. See {ref}`topic/customizing-installer` for more info. + +## Writing a simple plugins + +We shall try to write a simple plugin that installs a few libraries, +and use it to explain how the plugin mechanism works. We shall call +this plugin `tljh-simple`. + +### Plugin directory layout + +We recommend creating a new git repo for your plugin. Plugins are +normal python packages - however, since they are usually simpler, +we recommend they live in one file. + +For `tljh-simple`, the repository's structure should look like: + +```none +tljh_simple: + - tljh_simple.py + - setup.py + - README.md + - LICENSE +``` + +The `README.md` (or `README.rst` file) contains human readable +information about what your plugin does for your users. `LICENSE` +specifies the license used by your plugin - we recommend the +3-Clause BSD License, since that is what is used by TLJH itself. + +### `setup.py` - metadata & registration + +`setup.py` marks this as a python package, and contains metadata +about the package itself. It should look something like: + +```python +from setuptools import setup + +setup( + name="tljh-simple", + author="YuviPanda", + version="0.1", + license="3-clause BSD", + url='https://github.com/yuvipanda/tljh-simple', + entry_points={"tljh": ["simple = tljh_simple"]}, + py_modules=["tljh_simple"], +) +``` + +This is a mostly standard `setup.py` file. `entry_points={"tljh": ["simple = tljh_simple]}` +'registers' the module `tljh_simple` (in file `tljh_simple.py`) with TLJH as a plugin. + +### `tljh_simple.py` - implementation + +In `tljh_simple.py`, you provide implementations for whichever hooks +you want to extend. + +A hook implementation is a function that has the following characteristics: + +1. Has same name as the hook +2. Accepts some or all of the parameters defined for the hook +3. Is decorated with the `hookimpl` decorator function, imported from + `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/main/tljh/hooks.py) +in the source repository. Example implementations of each hook can be referenced from +[integration-tests/plugins/simplest/tljh_simplest.py](https://github.com/jupyterhub/the-littlest-jupyterhub/blob/main/integration-tests/plugins/simplest/tljh_simplest.py). + +This example provides an implementation for the `tljh_extra_user_conda_packages` +hook, which can return a list of conda packages that'll be installed in users' +environment from conda-forge. + +```python +from tljh.hooks import hookimpl + +@hookimpl +def tljh_extra_user_conda_packages(): + return [ + 'xarray', + 'iris', + 'dask', + ] +``` + +## Publishing plugins + +Plugins are python packages and should be published on PyPI. Users +can also install them directly from GitHub - although this is +not good long term practice. + +The python package should be named `tljh-`. + +## List of known plugins + +If you are looking for a way to extend or customize your TLJH deployment, you might want to look for existing plugins. + +Here is a non-exhaustive list of known TLJH plugins: + +- [tljh-pangeo](https://github.com/yuvipanda/tljh-pangeo): TLJH plugin for setting up the Pangeo Stack. +- [tljh-voila-gallery](https://github.com/voila-dashboards/tljh-voila-gallery): TLJH plugin that installs a gallery of Voilà dashboards. +- [tljh-repo2docker](https://github.com/plasmabio/tljh-repo2docker): TLJH plugin to build multiple user environments with + [repo2docker](https://repo2docker.readthedocs.io). +- [tljh-shared-directory](https://github.com/kafonek/tljh-shared-directory): TLJH plugin which sets up a _shared directory_ + for the Hub users in `/srv/scratch`. +- [tljh-db](https://github.com/sinzlab/tljh-db): TLJH plugin for working with mysql databases. + +If you have authored a plugin, please open a PR to add it to this list! + +We also recommend adding the `tljh-plugin` topic to the GitHub repository to make it more discoverable: +[https://github.com/topics/tljh-plugin](https://github.com/topics/tljh-plugin) diff --git a/docs/contributing/plugins.rst b/docs/contributing/plugins.rst deleted file mode 100644 index ecbf398..0000000 --- a/docs/contributing/plugins.rst +++ /dev/null @@ -1,147 +0,0 @@ -.. _contributing/plugins: - -============ -TLJH Plugins -============ - -TLJH plugins are the official way to make customized 'spins' or 'stacks' -with TLJH as the base. For example, the earth sciences community can make -a plugin that installs commonly used packages, set up authentication -and pre-download useful datasets. The mybinder.org community can -make a plugin that gives you a single-node, single-repository mybinder.org. -Plugins are very powerful, so the possibilities are endless. - -Design -====== - -`pluggy `_ is used to implement -plugin functionality. TLJH exposes specific **hooks** that your plugin -can provide implementations for. This allows us to have specific hook -points in the application that can be explicitly extended by plugins, -balancing the need to change TLJH internals in the future with the -stability required for a good plugin ecosystem. - -Installing Plugins -================== - -Include ``--plugin `` in the Installer script. See :ref:`topic/customizing-installer` for more info. - -Writing a simple plugins -======================== - -We shall try to write a simple plugin that installs a few libraries, -and use it to explain how the plugin mechanism works. We shall call -this plugin ``tljh-simple``. - -Plugin directory layout ------------------------ - -We recommend creating a new git repo for your plugin. Plugins are -normal python packages - however, since they are usually simpler, -we recommend they live in one file. - -For ``tljh-simple``, the repository's structure should look like: - -.. code-block:: none - - tljh_simple: - - tljh_simple.py - - setup.py - - README.md - - LICENSE - -The ``README.md`` (or ``README.rst`` file) contains human readable -information about what your plugin does for your users. ``LICENSE`` -specifies the license used by your plugin - we recommend the -3-Clause BSD License, since that is what is used by TLJH itself. - -``setup.py`` - metadata & registration --------------------------------------- - -``setup.py`` marks this as a python package, and contains metadata -about the package itself. It should look something like: - -.. code-block:: python - - from setuptools import setup - - setup( - name="tljh-simple", - author="YuviPanda", - version="0.1", - license="3-clause BSD", - url='https://github.com/yuvipanda/tljh-simple', - entry_points={"tljh": ["simple = tljh_simple"]}, - py_modules=["tljh_simple"], - ) - - -This is a mostly standard ``setup.py`` file. ``entry_points={"tljh": ["simple = tljh_simple]}`` -'registers' the module ``tljh_simple`` (in file ``tljh_simple.py``) with TLJH as a plugin. - -``tljh_simple.py`` - implementation ------------------------------------ - -In ``tljh_simple.py``, you provide implementations for whichever hooks -you want to extend. - -A hook implementation is a function that has the following characteristics: - -#. Has same name as the hook -#. Accepts some or all of the parameters defined for the hook -#. Is decorated with the ``hookimpl`` decorator function, imported from - ``tljh.hooks``. - -The current list of available hooks and when they are called can be -seen in `tljh/hooks.py `_ -in the source repository. Example implementations of each hook can be referenced from -`integration-tests/plugins/simplest/tljh_simplest.py -`_. - - -This example provides an implementation for the ``tljh_extra_user_conda_packages`` -hook, which can return a list of conda packages that'll be installed in users' -environment from conda-forge. - -.. code-block:: python - - from tljh.hooks import hookimpl - - @hookimpl - def tljh_extra_user_conda_packages(): - return [ - 'xarray', - 'iris', - 'dask', - ] - - -Publishing plugins -================== - -Plugins are python packages and should be published on PyPI. Users -can also install them directly from GitHub - although this is -not good long term practice. - -The python package should be named ``tljh-``. - - -List of known plugins -===================== - -If you are looking for a way to extend or customize your TLJH deployment, you might want to look for existing plugins. - -Here is a non-exhaustive list of known TLJH plugins: - -- `tljh-pangeo `_: TLJH plugin for setting up the Pangeo Stack. -- `tljh-voila-gallery `_: TLJH plugin that installs a gallery of Voilà dashboards. -- `tljh-repo2docker `_: TLJH plugin to build multiple user environments with - `repo2docker `_. -- `tljh-shared-directory `_: TLJH plugin which sets up a *shared directory* - for the Hub users in ``/srv/scratch``. -- `tljh-db `_: TLJH plugin for working with mysql databases. - -If you have authored a plugin, please open a PR to add it to this list! - -We also recommend adding the ``tljh-plugin`` topic to the GitHub repository to make it more discoverable: -`https://github.com/topics/tljh-plugin `_ diff --git a/docs/contributing/tests.md b/docs/contributing/tests.md new file mode 100644 index 0000000..3e8c1a7 --- /dev/null +++ b/docs/contributing/tests.md @@ -0,0 +1,62 @@ +(contributing-tests)= + +# Testing TLJH + +Unit and integration tests are a core part of TLJH, as important as +the code & documentation. They help validate that the code works as +we think it does, and continues to do so when changes occur. They +also help communicate in precise terms what we expect our code +to do. + +## Integration tests + +TLJH is a _distribution_ where the primary value is the many +opinionated choices we have made on components to use and how +they fit together. Integration tests are perfect for testing +that the various components fit together and work as they should. +So we write a lot of integration tests, and put in more effort +towards them than unit tests. + +All integration tests are run in [GitHub Actions](https://github.com/jupyterhub/the-littlest-jupyterhub/actions) +for each PR and merge, making sure we don't have broken tests +for too long. + +The integration tests are in the `integration-tests` directory +in the git repository. `py.test` is used to write the integration +tests. Each file should contain tests that can be run in any order +against the same installation of TLJH. + +### Running integration tests locally + +You need `docker` installed and callable by the user running +the integration tests without needing sudo. + +You can then run the tests with: + +```bash +.github/integration-test.py run-test +``` + +- `` is an identifier for the tests - you can choose anything you want +- `>` is list of test files (under `integration-tests`) that should be run in one go. + +For example, to run all the basic tests, you would write: + +```bash +.github/integration-test.py run-test basic-tests \ + test_hub.py \ + test_proxy.py \ + test_install.py \ + test_extensions.py +``` + +This will run the tests in the three files against the same installation +of TLJH and report errors. + +If you would like to run the tests with a custom pip spec for the bootstrap script, you can use the `--bootstrap-pip-spec` +parameter: + +```bash +.github/integration-test.py run-test \ + --bootstrap-pip-spec="git+https://github.com/your-username/the-littlest-jupyterhub.git@branch-name" +``` diff --git a/docs/contributing/tests.rst b/docs/contributing/tests.rst deleted file mode 100644 index f093ef4..0000000 --- a/docs/contributing/tests.rst +++ /dev/null @@ -1,66 +0,0 @@ -.. _contributing/tests: - -============ -Testing TLJH -============ - -Unit and integration tests are a core part of TLJH, as important as -the code & documentation. They help validate that the code works as -we think it does, and continues to do so when changes occur. They -also help communicate in precise terms what we expect our code -to do. - -Integration tests -================= - -TLJH is a *distribution* where the primary value is the many -opinionated choices we have made on components to use and how -they fit together. Integration tests are perfect for testing -that the various components fit together and work as they should. -So we write a lot of integration tests, and put in more effort -towards them than unit tests. - -All integration tests are run in `GitHub Actions `_ -for each PR and merge, making sure we don't have broken tests -for too long. - -The integration tests are in the ``integration-tests`` directory -in the git repository. ``py.test`` is used to write the integration -tests. Each file should contain tests that can be run in any order -against the same installation of TLJH. - -Running integration tests locally ---------------------------------- - -You need ``docker`` installed and callable by the user running -the integration tests without needing sudo. - -You can then run the tests with: - -.. code-block:: bash - - .github/integration-test.py run-test - -- ```` is an identifier for the tests - you can choose anything you want -- ``>`` is list of test files (under ``integration-tests``) that should be run in one go. - -For example, to run all the basic tests, you would write: - -.. code-block:: bash - - .github/integration-test.py run-test basic-tests \ - test_hub.py \ - test_proxy.py \ - test_install.py \ - test_extensions.py - -This will run the tests in the three files against the same installation -of TLJH and report errors. - -If you would like to run the tests with a custom pip spec for the bootstrap script, you can use the ``--bootstrap-pip-spec`` -parameter: - -.. code-block:: bash - - .github/integration-test.py run-test \ - --bootstrap-pip-spec="git+https://github.com/your-username/the-littlest-jupyterhub.git@branch-name" diff --git a/docs/howto/admin/admin-users.md b/docs/howto/admin/admin-users.md new file mode 100644 index 0000000..99b935e --- /dev/null +++ b/docs/howto/admin/admin-users.md @@ -0,0 +1,100 @@ +(howto-admin-admin-users)= + +# Add / Remove admin users + +Admin users in TLJH have the following powers: + +1. Full root access to the server with passwordless `sudo`. + This lets them do literally whatever they want in the server +2. Access servers / home directories of all other users +3. Install new packages for everyone with `conda`, `pip` or `apt` +4. Change configuration of TLJH + +This is a lot of power, so make sure you know who you are giving it +to. Admin users should have decent passwords / secure login mechanisms, +so attackers can not easily gain control of the system. + +:::{important} +You should make sure an admin user is present when you **install** TLJH +the very first time. It is recommended that you also set a password +for the admin at this step. The {ref}`--admin ` +flag passed to the installer does this. If you had forgotten to do so, the +easiest way to fix this is to run the installer again. +::: + +## Adding admin users from the JupyterHub interface + +There are two primary user interfaces for doing work on your JupyterHub. By +default, this is the Notebook Interface, and will be used in this section. +If you are using JupyterLab, you can access the Notebook Interface by replacing +`/lab` with `/tree` in your URL. + +1. First, navigate to the Jupyter Notebook interface home page. You can do this + by going to the URL `/user//tree`. + +2. Open the **Control Panel** by clicking the control panel button on the top + right of your JupyterHub. + + ```{image} ../../images/control-panel-button.png + :alt: Control panel button in notebook, top right + ``` + +3. In the control panel, open the **Admin** link in the top left. + + ```{image} ../../images/admin/admin-access-button.png + :alt: Admin button in control panel, top left + ``` + + This opens up the JupyterHub admin page, where you can add / delete users, + start / stop peoples' servers and see who is online. + +4. Click the **Add Users** button. + + ```{image} ../../images/admin/add-users-button.png + :alt: Add Users button in the admin page + ``` + + A **Add Users** dialog box opens up. + +5. Type the names of users you want to add to this JupyterHub in the dialog box, + one per line. **Make sure to tick the Admin checkbox**. + + ```{image} ../../images/admin/add-users-dialog.png + :alt: Adding users with add users dialog + ``` + +6. Click the **Add Users** button in the dialog box. Your users are now added + to the JupyterHub with administrator privileges! + +## Adding admin users from the command line + +Sometimes it is easier to add or remove admin users from the command line (for +example, if you forgot to add an admin user when first setting up your JupyterHub). + +### Adding new admin users + +New admin users can be added by executing the following commands on an +admin terminal: + +```bash +sudo tljh-config add-item users.admin +sudo tljh-config reload +``` + +If the user is already using the JupyterHub, they might have to stop and +start their server from the control panel to gain new powers. + +### Removing admin users + +You can remove an existing admin user by executing the following commands in +an admin terminal: + +```bash +sudo tljh-config remove-item users.admin +sudo tljh-config reload +``` + +If the user is already using the JupyterHub, they will continue to have +some of their admin powers until their server is stopped. Another admin +can force their server to stop by clicking 'Stop Server' in the admin +panel. diff --git a/docs/howto/admin/admin-users.rst b/docs/howto/admin/admin-users.rst deleted file mode 100644 index f1dcf5a..0000000 --- a/docs/howto/admin/admin-users.rst +++ /dev/null @@ -1,102 +0,0 @@ -.. _howto/admin/admin-users: - -======================== -Add / Remove admin users -======================== - -Admin users in TLJH have the following powers: - -#. Full root access to the server with passwordless ``sudo``. - This lets them do literally whatever they want in the server -#. Access servers / home directories of all other users -#. Install new packages for everyone with ``conda``, ``pip`` or ``apt`` -#. Change configuration of TLJH - -This is a lot of power, so make sure you know who you are giving it -to. Admin users should have decent passwords / secure login mechanisms, -so attackers can not easily gain control of the system. - -.. important:: - - You should make sure an admin user is present when you **install** TLJH - the very first time. It is recommended that you also set a password - for the admin at this step. The :ref:`--admin ` - flag passed to the installer does this. If you had forgotten to do so, the - easiest way to fix this is to run the installer again. - -Adding admin users from the JupyterHub interface -================================================ - -There are two primary user interfaces for doing work on your JupyterHub. By -default, this is the Notebook Interface, and will be used in this section. -If you are using JupyterLab, you can access the Notebook Interface by replacing -``/lab`` with ``/tree`` in your URL. - -#. First, navigate to the Jupyter Notebook interface home page. You can do this - by going to the URL ``/user//tree``. - -#. Open the **Control Panel** by clicking the control panel button on the top - right of your JupyterHub. - - .. image:: ../../images/control-panel-button.png - :alt: Control panel button in notebook, top right - -#. In the control panel, open the **Admin** link in the top left. - - .. image:: ../../images/admin/admin-access-button.png - :alt: Admin button in control panel, top left - - This opens up the JupyterHub admin page, where you can add / delete users, - start / stop peoples' servers and see who is online. - -#. Click the **Add Users** button. - - .. image:: ../../images/admin/add-users-button.png - :alt: Add Users button in the admin page - - A **Add Users** dialog box opens up. - -#. Type the names of users you want to add to this JupyterHub in the dialog box, - one per line. **Make sure to tick the Admin checkbox**. - - .. image:: ../../images/admin/add-users-dialog.png - :alt: Adding users with add users dialog - -#. Click the **Add Users** button in the dialog box. Your users are now added - to the JupyterHub with administrator privileges! - -Adding admin users from the command line -======================================== - -Sometimes it is easier to add or remove admin users from the command line (for -example, if you forgot to add an admin user when first setting up your JupyterHub). - -Adding new admin users ----------------------- - -New admin users can be added by executing the following commands on an -admin terminal: - -.. code-block:: bash - - sudo tljh-config add-item users.admin - sudo tljh-config reload - -If the user is already using the JupyterHub, they might have to stop and -start their server from the control panel to gain new powers. - -Removing admin users --------------------- - -You can remove an existing admin user by executing the following commands in -an admin terminal: - -.. code-block:: bash - - sudo tljh-config remove-item users.admin - sudo tljh-config reload - -If the user is already using the JupyterHub, they will continue to have -some of their admin powers until their server is stopped. Another admin -can force their server to stop by clicking 'Stop Server' in the admin -panel. diff --git a/docs/howto/admin/enable-extensions.md b/docs/howto/admin/enable-extensions.md new file mode 100644 index 0000000..6966018 --- /dev/null +++ b/docs/howto/admin/enable-extensions.md @@ -0,0 +1,56 @@ +(howto-admin-extensions)= + +# Enabling Jupyter Notebook extensions + +Jupyter contributed notebook +[extensions](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/index.html) are +community-contributed and maintained plug-ins to the Jupyter notebook. These extensions +serve many purposes, from [pedagogical tools](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/codefolding/readme.html) +to tools for [converting](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/latex_envs/README.html) +and [editing](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/spellchecker/README.html) +notebooks. + +Extensions are often added and enabled through the graphical user interface of the notebook. +However, this interface only makes the extension available to the user, not all users on a +hub. Instead, to make contributed extensions available to your users, you will use the command +line. This can be completed using the terminal in the JupyterHub (or via SSH-ing into your +VM and using this terminal). + +(tljh-extension-cli)= + +## Enabling extensions via the command line + +1. There are [multiple ways](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/install.html) + to install contributed extensions. For this example, we will use `pip`. + + ```bash + sudo -E pip install jupyter_contrib_nbextensions + ``` + +2. Next, add the notebook extension style files to the Jupyter configuration files. + + ```bash + sudo -E jupyter contrib nbextension install --sys-prefix + ``` + +3. Then, you will enable the extensions you would like to use. The syntax for this is + `jupyter nbextension enable` followed by the path to the desired extension's main file. + For example, to enable [scratchpad](https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/scratchpad/README.html), + you would type the following: + + ```bash + sudo -E jupyter nbextension enable scratchpad/main --sys-prefix + ``` + +4. When this is completed, the enabled extension should be visible in the extension list: + + ```bash + jupyter nbextension list + ``` + +5. You can also verify the availability of the extension via its user interface in the notebook. + For example, spellchecker adds an ABC checkmark icon to the interface. + + ```{image} ../../images/admin/enable-spellcheck.png + :alt: spellcheck-interface-changes + ``` diff --git a/docs/howto/admin/enable-extensions.rst b/docs/howto/admin/enable-extensions.rst deleted file mode 100644 index 5029193..0000000 --- a/docs/howto/admin/enable-extensions.rst +++ /dev/null @@ -1,58 +0,0 @@ -.. _howto/admin/extensions: - -==================================== -Enabling Jupyter Notebook extensions -==================================== - -Jupyter contributed notebook -`extensions `_ are -community-contributed and maintained plug-ins to the Jupyter notebook. These extensions -serve many purposes, from `pedagogical tools `_ -to tools for `converting `_ -and `editing `_ -notebooks. - -Extensions are often added and enabled through the graphical user interface of the notebook. -However, this interface only makes the extension available to the user, not all users on a -hub. Instead, to make contributed extensions available to your users, you will use the command -line. This can be completed using the terminal in the JupyterHub (or via SSH-ing into your -VM and using this terminal). - -.. _tljh_extension_cli: - -Enabling extensions via the command line -======================================== - -#. There are `multiple ways `_ - to install contributed extensions. For this example, we will use ``pip``. - - .. code-block:: bash - - sudo -E pip install jupyter_contrib_nbextensions - -#. Next, add the notebook extension style files to the Jupyter configuration files. - - .. code-block:: bash - - sudo -E jupyter contrib nbextension install --sys-prefix - -#. Then, you will enable the extensions you would like to use. The syntax for this is - ``jupyter nbextension enable`` followed by the path to the desired extension's main file. - For example, to enable `scratchpad `_, - you would type the following: - - .. code-block:: bash - - sudo -E jupyter nbextension enable scratchpad/main --sys-prefix - -#. When this is completed, the enabled extension should be visible in the extension list: - - .. code-block:: bash - - jupyter nbextension list - -#. You can also verify the availability of the extension via its user interface in the notebook. - For example, spellchecker adds an ABC checkmark icon to the interface. - - .. image:: ../../images/admin/enable-spellcheck.png - :alt: spellcheck-interface-changes diff --git a/docs/howto/admin/https.md b/docs/howto/admin/https.md new file mode 100644 index 0000000..2a4a508 --- /dev/null +++ b/docs/howto/admin/https.md @@ -0,0 +1,118 @@ +(howto-admin-https)= + +# Enable HTTPS + +Every JupyterHub deployment should enable HTTPS! + +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 ` 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 ` +method. + +:::{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. + +To do that, you would have to log in to the website of your registrar +and go to the DNS records section. The interface will look like something +similar to this: + +> ```{image} ../../images/dns.png +> :alt: Adding an entry to the DNS records +> ``` + +::: + +(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 `. +::: + +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. + +Once you have loaded this, your config should look like: + +``` +sudo tljh-config show +``` + +```yaml +https: + enabled: true + letsencrypt: + email: you@example.com + domains: + - yourhub.yourdomain.edu +``` + +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 . + +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 + +You may already have an SSL key and certificate. +If so, you can tell your deployment to use these files: + +``` +sudo tljh-config set https.enabled true +sudo tljh-config set https.tls.key /etc/mycerts/mydomain.key +sudo tljh-config set https.tls.cert /etc/mycerts/mydomain.cert +``` + +Once you have loaded this, your config should look like: + +``` +sudo tljh-config show +``` + +```yaml +https: + enabled: true + tls: + key: /etc/mycerts/mydomain.key + cert: /etc/mycerts/mydomain.cert +``` + +Finally, you can reload the proxy to load the new configuration: + +``` +sudo tljh-config reload proxy +``` + +and now access your Hub securely at . + +## Troubleshooting + +If you're having trouble with HTTPS, looking at the {ref}`traefik proxy logs ` might help. diff --git a/docs/howto/admin/https.rst b/docs/howto/admin/https.rst deleted file mode 100644 index 768b45c..0000000 --- a/docs/howto/admin/https.rst +++ /dev/null @@ -1,112 +0,0 @@ -.. _howto/admin/https: - -============ -Enable HTTPS -============ - -Every JupyterHub deployment should enable HTTPS! - -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 `_, or setting it up -:ref:`manually ` 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 ` -method. - -.. 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. - - To do that, you would have to log in to the website of your registrar - and go to the DNS records section. The interface will look like something - similar to this: - - .. image:: ../../images/dns.png - :alt: Adding an entry to the DNS records - -.. _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 `. - -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. - -Once you have loaded this, your config should look like:: - - sudo tljh-config show - -.. sourcecode:: yaml - - https: - enabled: true - letsencrypt: - email: you@example.com - domains: - - yourhub.yourdomain.edu - -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. - -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 -============================================== - -You may already have an SSL key and certificate. -If so, you can tell your deployment to use these files:: - - sudo tljh-config set https.enabled true - sudo tljh-config set https.tls.key /etc/mycerts/mydomain.key - sudo tljh-config set https.tls.cert /etc/mycerts/mydomain.cert - - -Once you have loaded this, your config should look like:: - - sudo tljh-config show - - -.. sourcecode:: yaml - - https: - enabled: true - tls: - key: /etc/mycerts/mydomain.key - cert: /etc/mycerts/mydomain.cert - -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 ` might help. diff --git a/docs/howto/admin/nbresuse.rst b/docs/howto/admin/nbresuse.md similarity index 53% rename from docs/howto/admin/nbresuse.rst rename to docs/howto/admin/nbresuse.md index 9991406..982933c 100644 --- a/docs/howto/admin/nbresuse.rst +++ b/docs/howto/admin/nbresuse.md @@ -1,15 +1,14 @@ -.. _howto/admin/nbresuse: +(howto-admin-nbresuse)= -======================= -Check your memory usage -======================= +# Check your memory usage -The `jupyter-resource-usage `_ extension is part of +The [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage) extension is part of the default installation, and tells you how much memory your user is using right now, and what the memory limit for your user is. It is shown in the top right corner of the notebook interface. Note that this is memory usage for everything your user is running through the Jupyter notebook interface, not just the specific notebook it is shown on. -.. image:: ../../images/nbresuse.png - :alt: Memory limit / usage shown with jupyter-resource-usage +```{image} ../../images/nbresuse.png +:alt: Memory limit / usage shown with jupyter-resource-usage +``` diff --git a/docs/howto/admin/resize.rst b/docs/howto/admin/resize.md similarity index 52% rename from docs/howto/admin/resize.rst rename to docs/howto/admin/resize.md index 99e99c7..0cd3f71 100644 --- a/docs/howto/admin/resize.rst +++ b/docs/howto/admin/resize.md @@ -1,60 +1,58 @@ -.. _howto/admin/resize: +(howto-admin-resize)= -================================================= -Resize the resources available to your JupyterHub -================================================= +# Resize the resources available to your JupyterHub As you are using your JupyterHub, you may need to increase or decrease the amount of resources allocated to your TLJH install. The kinds of resources that can be allocated, as well as the process to do so, will depend on the provider / interface for your VM. We recommend consulting the installation page for your provider for more information. This -page covers the steps your should take on your JupyterHub *after* you've reallocated resources on +page covers the steps your should take on your JupyterHub _after_ you've reallocated resources on the cloud provider of your choice. Currently there are instructions to resize your resources on the following providers: -* :ref:`Digital Ocean `. +- {ref}`Digital Ocean `. Once resources have been reallocated, you must tell TLJH to make use of these resources, and verify that the resources have become available. -Verifying a Resize -================== +## Verifying a Resize -#. Once you have resized your server, tell the JupyterHub to make use of +1. Once you have resized your server, tell the JupyterHub to make use of these new resources. To accomplish this, follow the instructions in - :ref:`topic/tljh-config` to set new memory or CPU limits and reload the hub. This can be completed + {ref}`topic/tljh-config` to set new memory or CPU limits and reload the hub. This can be completed using the terminal in the JupyterHub (or via SSH-ing into your VM and using this terminal). -#. TLJH configuration options can be verified by viewing the tljh-config output. +2. TLJH configuration options can be verified by viewing the tljh-config output. - .. code-block:: bash - - sudo tljh-config show + ```bash + sudo tljh-config show + ``` Double-check that your changes are reflected in the output. -#. **To verify changes to memory**, confirm that it worked by starting +3. **To verify changes to memory**, confirm that it worked by starting a new server (if you had one previously running, click "Control Panel -> Stop My Server" to shut down your active server first), opening a notebook, and checking the value of the - `jupyter-resource-usage `_ extension in the upper-right. + [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage) extension in the upper-right. - .. image:: ../../images/nbresuse.png - :alt: jupyter-resource-usage demonstration + ```{image} ../../images/nbresuse.png + :alt: jupyter-resource-usage demonstration + ``` -#. **To verify changes to CPU**, use the ``nproc`` from a terminal. +4. **To verify changes to CPU**, use the `nproc` from a terminal. This command displays the number of available cores, and should be equal to the number of cores you selected in your provider's interface. - .. code-block:: bash + ```bash + nproc --all + ``` - nproc --all - -#. **To verify currently-available disk space**, use the ``df`` command in a terminal. This shows - how much disk space is available. The ``-hT`` argument allows us to have this printed in a human readable +5. **To verify currently-available disk space**, use the `df` command in a terminal. This shows + how much disk space is available. The `-hT` argument allows us to have this printed in a human readable format, and condenses the output to show one storage volume. Note that currently you cannot change the disk space on a per-user basis. - .. code-block:: bash - - df -hT /home + ```bash + df -hT /home + ``` diff --git a/docs/howto/admin/resource-estimation.rst b/docs/howto/admin/resource-estimation.md similarity index 70% rename from docs/howto/admin/resource-estimation.rst rename to docs/howto/admin/resource-estimation.md index c818b3d..8c1585e 100644 --- a/docs/howto/admin/resource-estimation.rst +++ b/docs/howto/admin/resource-estimation.md @@ -1,85 +1,76 @@ -.. _howto/admin/resource-estimation: +(howto-admin-resource-estimation)= -=================================== -Estimate Memory / CPU / Disk needed -=================================== +# Estimate Memory / CPU / Disk needed This page helps you estimate how much Memory / CPU / Disk the server you install The Littlest JupyterHub on should have. These are just guidelines to help with estimation - your actual needs will vary. -Memory -====== +## Memory Memory is usually the biggest determinant of server size in most JupyterHub installations. At minimum, your server must have at least **1GB** of RAM for TLJH to install. -.. math:: +$$ +Recommended\, Memory = +(Max\, concurrent\, users \times Max\, mem\, per\, user) + 128MB +$$ - Recommended\, Memory = - (Max\, concurrent\, users \times Max\, mem\, per\, user) + 128MB - - -The ``128MB`` is overhead for TLJH and related services. **Server Memory Recommended** +The `128MB` is overhead for TLJH and related services. **Server Memory Recommended** is the amount of Memory (RAM) the server you acquire should have - we recommend erring on the side of 'more Memory'. The other terms are explained below. -Maximum concurrent users ------------------------- +### Maximum concurrent users Even if your class has 100 students, most of them will not be using the JupyterHub actively at a single given moment. At 2am on a normal night, maybe you'll have 10 students using it. At 2am before a final, maybe you'll have 60 students using it. Maybe you'll have a lab session with all 100 of your students using it at the same time. -The *maximum* number of users actively using the JupyterHub at any given time determines +The _maximum_ number of users actively using the JupyterHub at any given time determines how much memory your server will need. You'll get better at estimating this number over time. We generally recommend between 40-60% of your total class size to start with. -Maximum memory allowed per user -------------------------------- +### Maximum memory allowed per user Depending on what kind of work your users are doing, they will use different amounts of memory. The easiest way to determine this is to run through a typical user -workflow yourself, and measure how much memory is used. You can use :ref:`howto/admin/nbresuse` +workflow yourself, and measure how much memory is used. You can use {ref}`howto/admin/nbresuse` to determine how much memory your user is using. A good rule of thumb is to take the maximum amount of memory you used during your session, and add 20-40% headroom for users to 'play around'. This is the maximum amount of memory that should be given to each user. -If users use *more* than this alloted amount of memory, their notebook kernel will *restart*. +If users use _more_ than this alloted amount of memory, their notebook kernel will _restart_. -CPU -=== +## CPU CPU estimation is more forgiving than Memory estimation. If there isn't enough CPU for your users, their computation becomes very slow - but does not stop, unlike with RAM. -.. math:: +$$ +Recommended\, CPU = (Max\, concurrent\, users \times Max\, CPU\, usage\, per\, user) + 20\% +$$ - Recommended\, CPU = (Max\, concurrent\, users \times Max\, CPU\, usage\, per\, user) + 20\% - -The ``20%`` is overhead for TLJH and related services. This is around 20% of a +The `20%` is overhead for TLJH and related services. This is around 20% of a single modern CPU. This, of course, is just an estimate. We recommend using the same process used to estimate Memory required for estimating CPU required. You cannot use jupyter-resource-usage for this, but you should carry out normal workflow and investigate the CPU usage on the machine. -Disk space -========== +## Disk space Unlike Memory & CPU, disk space is predicated on **total** number of users, rather than **maximum concurrent** users. -.. math:: +$$ +Recommended\, Disk\, Size = (Total\, users \times Max\, disk\, usage\, per\, user) + 2GB +$$ - Recommended\, Disk\, Size = (Total\, users \times Max\, disk\, usage\, per\, user) + 2GB - -Resizing your server -==================== +## Resizing your server Lots of cloud providers let your dynamically resize your server if you need it to be larger or smaller. Usually this requires a restart of the whole server - diff --git a/docs/howto/admin/systemd.md b/docs/howto/admin/systemd.md new file mode 100644 index 0000000..468cea7 --- /dev/null +++ b/docs/howto/admin/systemd.md @@ -0,0 +1,82 @@ +(howto-admin-systemd)= + +# Customizing `systemd` services + +By default, TLJH configures two `systemd` services to run JupyterHub and Traefik. + +These services come with a default set of settings, which are specified in +[jupyterhub.service](https://github.com/jupyterhub/the-littlest-jupyterhub/blob/HEAD/tljh/systemd-units/jupyterhub.service) and +[traefik.service](https://github.com/jupyterhub/the-littlest-jupyterhub/blob/HEAD/tljh/systemd-units/traefik.service). +They look like the following: + +```bash +[Unit] +Requires=traefik.service +After=traefik.service + +[Service] +User=root +Restart=always +WorkingDirectory=/opt/tljh/state +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +Environment=TLJH_INSTALL_PREFIX=/opt/tljh +ExecStart=/opt/tljh/hub/bin/python3 -m jupyterhub.app -f jupyterhub_config.py --upgrade-db + +[Install] +WantedBy=multi-user.target +``` + +However in some cases, admins might want to have better control on these settings. + +For example when mounting shared volumes over the network using [Samba](), +these namespacing settings might be a bit too strict and prevent users from accessing the shared volumes. + +## Overriding settings with `override.conf` + +To override the `jupyterhub` settings, it is possible to provide a custom `/etc/systemd/system/jupyterhub.service.d/override.conf` file. + +Here is an example for the content of the file: + +```bash +[Service] +PrivateTmp=no +PrivateDevices=no +ProtectKernelTunables=no +ProtectKernelModules=no +``` + +This example should be useful in the case of mounting volumes using Samba and sharing them with the JupyterHub users. +You might also want to provide your own options, which are listed in the +[systemd documentation](https://www.freedesktop.org/software/systemd/man/systemd.exec.html). + +Then make sure to reload the daemon and the `jupyterhub` service: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart jupyterhub +``` + +Then check the status with: + +```bash +sudo systemctl status jupyterhub +``` + +The output should look like the following: + +```{image} ../../images/admin/jupyterhub-systemd-status.png +:alt: Checking the status of the JupyterHub systemd service +``` + +To override the `traefik` settings, create a new file under `/etc/systemd/system/traefik.service.d/override.conf` +and follow the same steps. + +## References + +If you would like to learn more about the `systemd` security features, check out these references: + +- [List of systemd settings](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) +- [Mastering systemd: Securing and sandboxing applications and services](https://www.redhat.com/sysadmin/mastering-systemd) diff --git a/docs/howto/admin/systemd.rst b/docs/howto/admin/systemd.rst deleted file mode 100644 index d2a5058..0000000 --- a/docs/howto/admin/systemd.rst +++ /dev/null @@ -1,88 +0,0 @@ -.. _howto/admin/systemd: - -================================ -Customizing ``systemd`` services -================================ - -By default, TLJH configures two ``systemd`` services to run JupyterHub and Traefik. - -These services come with a default set of settings, which are specified in -`jupyterhub.service `_ and -`traefik.service `_. -They look like the following: - -.. code-block:: bash - - [Unit] - Requires=traefik.service - After=traefik.service - - [Service] - User=root - Restart=always - WorkingDirectory=/opt/tljh/state - PrivateTmp=yes - PrivateDevices=yes - ProtectKernelTunables=yes - ProtectKernelModules=yes - Environment=TLJH_INSTALL_PREFIX=/opt/tljh - ExecStart=/opt/tljh/hub/bin/python3 -m jupyterhub.app -f jupyterhub_config.py --upgrade-db - - [Install] - WantedBy=multi-user.target - - -However in some cases, admins might want to have better control on these settings. - -For example when mounting shared volumes over the network using `Samba `_, -these namespacing settings might be a bit too strict and prevent users from accessing the shared volumes. - - -Overriding settings with ``override.conf`` -========================================== - -To override the ``jupyterhub`` settings, it is possible to provide a custom ``/etc/systemd/system/jupyterhub.service.d/override.conf`` file. - -Here is an example for the content of the file: - -.. code-block:: bash - - [Service] - PrivateTmp=no - PrivateDevices=no - ProtectKernelTunables=no - ProtectKernelModules=no - -This example should be useful in the case of mounting volumes using Samba and sharing them with the JupyterHub users. -You might also want to provide your own options, which are listed in the -`systemd documentation `_. - -Then make sure to reload the daemon and the ``jupyterhub`` service: - -.. code-block:: bash - - sudo systemctl daemon-reload - sudo systemctl restart jupyterhub - -Then check the status with: - -.. code-block:: bash - - sudo systemctl status jupyterhub - -The output should look like the following: - -.. image:: ../../images/admin/jupyterhub-systemd-status.png - :alt: Checking the status of the JupyterHub systemd service - -To override the ``traefik`` settings, create a new file under ``/etc/systemd/system/traefik.service.d/override.conf`` -and follow the same steps. - - -References -========== - -If you would like to learn more about the ``systemd`` security features, check out these references: - -- `List of systemd settings `_ -- `Mastering systemd: Securing and sandboxing applications and services `_ diff --git a/docs/howto/auth/awscognito.md b/docs/howto/auth/awscognito.md new file mode 100644 index 0000000..cb96100 --- /dev/null +++ b/docs/howto/auth/awscognito.md @@ -0,0 +1,128 @@ +(howto-auth-awscognito)= + +# Authenticate using AWS Cognito + +The **AWS Cognito Authenticator** lets users log into your JupyterHub using +cognito user pools. To do so, you'll first need to register and configure a +cognito user pool and app, and then provide information about this +application to your `tljh` configuration. + +## Create an AWS Cognito application + +1. Create a user pool [Getting Started with User Pool](https://docs.aws.amazon.com/cognito/latest/developerguide/getting-started-with-cognito-user-pools.html). + + When you have completed creating a user pool, app, and domain you should have the following settings available to you: + + - **App client id**: From the App client page + + - **App client secret** From the App client page + + - **Callback URL** This should be the domain you are hosting you server on: + + ``` + http(s):///hub/oauth_callback + ``` + + - **Signout URL**: This is the landing page for a user when they are not logged on: + + ``` + http(s):// + ``` + + > - **Auth Domain** Create an auth domain e.g. \: + > + > ``` + > https://<.auth.eu-west-1.amazoncognito.com + > ``` + +## Install and configure an AWS EC2 Instance with userdata + +By adding following script to the ec2 instance user data you should be +able to configure the instance automatically, replace relevant placeholders: + +``` +#!/bin/bash +############################################## +# Ensure tljh is up to date +############################################## +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin insightadmin + +############################################## +# Setup AWS Cognito OAuthenticator +############################################## +echo > /opt/tljh/config/jupyterhub_config.d/awscognito.py <`_. - - When you have completed creating a user pool, app, and domain you should have the following settings available to you: - - * **App client id**: From the App client page - * **App client secret** From the App client page - * **Callback URL** This should be the domain you are hosting you server on:: - - http(s):///hub/oauth_callback - - * **Signout URL**: This is the landing page for a user when they are not logged on:: - - http(s):// - - * **Auth Domain** Create an auth domain e.g. :: - - https://<.auth.eu-west-1.amazoncognito.com - - -Install and configure an AWS EC2 Instance with userdata -======================================================= - -By adding following script to the ec2 instance user data you should be -able to configure the instance automatically, replace relevant placeholders:: - - #!/bin/bash - ############################################## - # Ensure tljh is up to date - ############################################## - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --admin insightadmin - - ############################################## - # Setup AWS Cognito OAuthenticator - ############################################## - echo > /opt/tljh/config/jupyterhub_config.d/awscognito.py < + ``` + + Remember to replace `` with the password you choose. + +2. Enable the authenticator and reload config to apply configuration: + + ```bash + sudo tljh-config set auth.type dummy + ``` + + ```bash + sudo tljh-config reload + ``` + +Users who are currently logged in will continue to be logged in. When they +log out and try to log back in, they will be asked to provide a username and +password. + +## Changing the password + +The password used by DummyAuthenticator can be changed with the following +commands: + +```bash +tljh-config set auth.DummyAuthenticator.password +``` + +```bash +tljh-config reload +``` diff --git a/docs/howto/auth/dummy.rst b/docs/howto/auth/dummy.rst deleted file mode 100644 index 5a92d89..0000000 --- a/docs/howto/auth/dummy.rst +++ /dev/null @@ -1,51 +0,0 @@ -.. _howto/auth/dummy: - -===================================================== -Authenticate *any* user with a single shared password -===================================================== - -The **Dummy Authenticator** lets *any* user log in with the given password. -This authenticator is **extremely insecure**, so do not use it if you can -avoid it. - -Enabling the authenticator -========================== - -1. Always use DummyAuthenticator with a password. You can communicate this - password to all your users via an out of band mechanism (like writing on - a whiteboard). Once you have selected a password, configure TLJH to use - the password by executing the following from an admin console. - - .. code-block:: bash - - sudo tljh-config set auth.DummyAuthenticator.password - - Remember to replace ```` with the password you choose. - -2. Enable the authenticator and reload config to apply configuration: - - .. code-block:: bash - - sudo tljh-config set auth.type dummy - - .. code-block:: bash - - sudo tljh-config reload - -Users who are currently logged in will continue to be logged in. When they -log out and try to log back in, they will be asked to provide a username and -password. - -Changing the password -===================== - -The password used by DummyAuthenticator can be changed with the following -commands: - -.. code-block:: bash - - tljh-config set auth.DummyAuthenticator.password - -.. code-block:: bash - - tljh-config reload diff --git a/docs/howto/auth/firstuse.md b/docs/howto/auth/firstuse.md new file mode 100644 index 0000000..4bb814a --- /dev/null +++ b/docs/howto/auth/firstuse.md @@ -0,0 +1,79 @@ +(howto-auth-firstuse)= + +# Let users choose a password when they first log in + +The **First Use Authenticator** lets users choose their own password. +Upon their first log-in attempt, whatever password they use will be stored +as their password for subsequent log in attempts. This is +the default authenticator that ships with TLJH. + +## Enabling the authenticator + +:::{note} +the FirstUseAuthenticator is enabled by default in TLJH. +::: + +1. Enable the authenticator and reload config to apply the configuration: + +```bash +sudo tljh-config set auth.type firstuseauthenticator.FirstUseAuthenticator +sudo tljh-config reload +``` + +Users who are currently logged in will continue to be logged in. When they +log out and try to log back in, they will be asked to provide a username and +password. + +## Users changing their own password + +Users can change their password by first logging into their account and then visiting +the url `/hub/auth/change-password`. + +## Allowing anyone to log in to your JupyterHub + +By default, you need to manually create user accounts before they will be able +to log in to your JupyterHub. If you wish to allow **any** user to access +the JupyterHub, run the following command. + +```bash +tljh-config set auth.FirstUseAuthenticator.create_users true +tljh-config reload +``` + +## Resetting user password + +The admin can reset user passwords by _deleting_ the user from the JupyterHub admin +page. This logs the user out, but does **not** remove any of their data or +home directories. The user can then set a new password by logging in again with +their new password. + +1. As an admin user, open the **Control Panel** by clicking the control panel + button on the top right of your JupyterHub. + + ```{image} ../../images/control-panel-button.png + :alt: Control panel button in notebook, top right + ``` + +2. In the control panel, open the **Admin** link in the top left. + + ```{image} ../../images/admin/admin-access-button.png + :alt: Admin button in control panel, top left + ``` + + This opens up the JupyterHub admin page, where you can add / delete users, + start / stop peoples' servers and see who is online. + +3. **Delete** the user whose password needs resetting. Remember this **does not** + delete their data or home directory. + + ```{image} ../../images/auth/firstuse/delete-user.png + :alt: Delete user button for each user + ``` + + If there is a confirmation dialog, confirm the deletion. This will also log the + user out if they were currently running. + +4. Re-create the user whose password needs resetting within that same dialog. + +5. Ask the user to log in again with their new password as usual. This will be their + new password going forward. diff --git a/docs/howto/auth/firstuse.rst b/docs/howto/auth/firstuse.rst deleted file mode 100644 index b81f6f4..0000000 --- a/docs/howto/auth/firstuse.rst +++ /dev/null @@ -1,81 +0,0 @@ -.. _howto/auth/firstuse: - -================================================== -Let users choose a password when they first log in -================================================== - -The **First Use Authenticator** lets users choose their own password. -Upon their first log-in attempt, whatever password they use will be stored -as their password for subsequent log in attempts. This is -the default authenticator that ships with TLJH. - -Enabling the authenticator -========================== - -.. note:: the FirstUseAuthenticator is enabled by default in TLJH. - -#. Enable the authenticator and reload config to apply the configuration: - -.. code-block:: bash - - sudo tljh-config set auth.type firstuseauthenticator.FirstUseAuthenticator - sudo tljh-config reload - -Users who are currently logged in will continue to be logged in. When they -log out and try to log back in, they will be asked to provide a username and -password. - -Users changing their own password -================================= - -Users can change their password by first logging into their account and then visiting -the url ``/hub/auth/change-password``. - -Allowing anyone to log in to your JupyterHub -============================================ - -By default, you need to manually create user accounts before they will be able -to log in to your JupyterHub. If you wish to allow **any** user to access -the JupyterHub, run the following command. - -.. code-block:: bash - - tljh-config set auth.FirstUseAuthenticator.create_users true - tljh-config reload - - -Resetting user password -======================= - -The admin can reset user passwords by *deleting* the user from the JupyterHub admin -page. This logs the user out, but does **not** remove any of their data or -home directories. The user can then set a new password by logging in again with -their new password. - -#. As an admin user, open the **Control Panel** by clicking the control panel - button on the top right of your JupyterHub. - - .. image:: ../../images/control-panel-button.png - :alt: Control panel button in notebook, top right - -#. In the control panel, open the **Admin** link in the top left. - - .. image:: ../../images/admin/admin-access-button.png - :alt: Admin button in control panel, top left - - This opens up the JupyterHub admin page, where you can add / delete users, - start / stop peoples' servers and see who is online. - -#. **Delete** the user whose password needs resetting. Remember this **does not** - delete their data or home directory. - - .. image:: ../../images/auth/firstuse/delete-user.png - :alt: Delete user button for each user - - If there is a confirmation dialog, confirm the deletion. This will also log the - user out if they were currently running. - -#. Re-create the user whose password needs resetting within that same dialog. - -#. Ask the user to log in again with their new password as usual. This will be their - new password going forward. diff --git a/docs/howto/auth/github.md b/docs/howto/auth/github.md new file mode 100644 index 0000000..c9b6709 --- /dev/null +++ b/docs/howto/auth/github.md @@ -0,0 +1,108 @@ +(howto-auth-github)= + +# Authenticate using GitHub Usernames + +The **GitHub Authenticator** lets users log into your JupyterHub using their +GitHub user ID / password. To do so, you'll first need to register an +application with GitHub, and then provide information about this +application to your `tljh` configuration. + +:::{note} +You'll need a GitHub account in order to complete these steps. +::: + +## Step 1: Create a GitHub application + +1. Go to the [GitHub OAuth app creation page](https://github.com/settings/applications/new). + + - **Application name**: Choose a descriptive application name (e.g. `tljh`) + + - **Homepage URL**: Use the IP address or URL of your JupyterHub. e.g. `` http(s)://` ``. + + - **Application description**: Use any description that you like. + + - **Authorization callback URL**: Insert text with the following form: + + ``` + http(s):///hub/oauth_callback + ``` + + - When you're done filling in the page, it should look something like this: + + > ```{image} ../../images/auth/github/create_application.png + > :alt: Create a GitHub OAuth application + > ``` + +2. Click "Register application". 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/github/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 GitHub application information** +with the new IP address. +::: + +## Configure your JupyterHub to use the GitHub 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`. + +1. Log in as an administrator account to your JupyterHub. + +2. Open a terminal window. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +3. Configure the GitHub OAuthenticator to use your client ID, client secret and callback URL with the following commands: + + ``` + sudo tljh-config set auth.GitHubOAuthenticator.client_id '' + ``` + + ``` + sudo tljh-config set auth.GitHubOAuthenticator.client_secret '' + ``` + + ``` + sudo tljh-config set auth.GitHubOAuthenticator.oauth_callback_url 'http(s):///hub/oauth_callback' + ``` + +4. Tell your JupyterHub to _use_ the GitHub OAuthenticator for authentication: + + ``` + sudo tljh-config set auth.type oauthenticator.github.GitHubOAuthenticator + ``` + +5. Restart your JupyterHub so that new users see these changes: + + ``` + sudo tljh-config reload + ``` + +## Confirm that the new authenticator works + +1. **Open an incognito window** in your browser (do not log out until you confirm + that the new authentication method works!) + +2. Go to your JupyterHub URL. + +3. You should see a GitHub login button like below: + + ```{image} ../../images/auth/github/login_button.png + :alt: The GitHub authenticator login button. + ``` + +4. After you log in with your GitHub credentials, you should be directed to the + Jupyter interface used in this JupyterHub. + +5. **If this does not work** you can revert back to the default + JupyterHub authenticator by following the steps in {ref}`howto/auth/firstuse`. diff --git a/docs/howto/auth/github.rst b/docs/howto/auth/github.rst deleted file mode 100644 index 9516150..0000000 --- a/docs/howto/auth/github.rst +++ /dev/null @@ -1,93 +0,0 @@ -.. _howto/auth/github: - -=================================== -Authenticate using GitHub Usernames -=================================== - -The **GitHub Authenticator** lets users log into your JupyterHub using their -GitHub user ID / password. To do so, you'll first need to register an -application with GitHub, and then provide information about this -application to your ``tljh`` configuration. - -.. note:: - - You'll need a GitHub account in order to complete these steps. - -Step 1: Create a GitHub application -=================================== - -#. Go to the `GitHub OAuth app creation page `_. - - * **Application name**: Choose a descriptive application name (e.g. ``tljh``) - * **Homepage URL**: Use the IP address or URL of your JupyterHub. e.g. ``http(s)://```. - * **Application description**: Use any description that you like. - * **Authorization callback URL**: Insert text with the following form:: - - http(s):///hub/oauth_callback - - * When you're done filling in the page, it should look something like this: - - .. image:: ../../images/auth/github/create_application.png - :alt: Create a GitHub OAuth application -#. Click "Register application". You'll be taken to a page with the registered application details. -#. 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/github/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 GitHub application information** - with the new IP address. - -Configure your JupyterHub to use the GitHub 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 GitHub OAuthenticator to use your client ID, client secret and callback URL with the following commands:: - - sudo tljh-config set auth.GitHubOAuthenticator.client_id '' - - :: - - sudo tljh-config set auth.GitHubOAuthenticator.client_secret '' - - :: - - sudo tljh-config set auth.GitHubOAuthenticator.oauth_callback_url 'http(s):///hub/oauth_callback' - -#. Tell your JupyterHub to *use* the GitHub OAuthenticator for authentication:: - - sudo tljh-config set auth.type oauthenticator.github.GitHubOAuthenticator - -#. 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 GitHub login button like below: - - .. image:: ../../images/auth/github/login_button.png - :alt: The GitHub authenticator login button. - -#. After you log in with your GitHub 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`. diff --git a/docs/howto/auth/google.md b/docs/howto/auth/google.md new file mode 100644 index 0000000..8ecb3b6 --- /dev/null +++ b/docs/howto/auth/google.md @@ -0,0 +1,133 @@ +(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)://`. + + - **Authorized redirect URIs**: Insert text with the following form: + + ``` + http(s):///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`. + +1. Log in as an administrator account to your JupyterHub. + +2. Open a terminal window. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +3. 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 '' + ``` + + ``` + sudo tljh-config set auth.GoogleOAuthenticator.client_secret '' + ``` + + ``` + sudo tljh-config set auth.GoogleOAuthenticator.oauth_callback_url 'http(s):///hub/oauth_callback' + ``` + +4. Tell your JupyterHub to _use_ the Google OAuthenticator for authentication: + + ``` + sudo tljh-config set auth.type oauthenticator.google.GoogleOAuthenticator + ``` + +5. Restart your JupyterHub so that new users see these changes: + + ``` + sudo tljh-config reload + ``` + +## Confirm that the new authenticator works + +1. **Open an incognito window** in your browser (do not log out until you confirm + that the new authentication method works!) + +2. Go to your JupyterHub URL. + +3. You should see a Google login button like below: + + ```{image} ../../images/auth/google/login_button.png + :alt: The Google authenticator login button. + ``` + +4. After you log in with your Google credentials, you should be directed to the + Jupyter interface used in this JupyterHub. + +5. **If this does not work** you can revert back to the default + JupyterHub authenticator by following the steps in {ref}`howto/auth/firstuse`. diff --git a/docs/howto/auth/google.rst b/docs/howto/auth/google.rst deleted file mode 100644 index d3e9e60..0000000 --- a/docs/howto/auth/google.rst +++ /dev/null @@ -1,119 +0,0 @@ -.. _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 `_ -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 `_ -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)://``. - * **Authorized redirect URIs**: Insert text with the following form:: - - http(s):///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 '' - - :: - - sudo tljh-config set auth.GoogleOAuthenticator.client_secret '' - - :: - - sudo tljh-config set auth.GoogleOAuthenticator.oauth_callback_url 'http(s):///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`. diff --git a/docs/howto/auth/nativeauth.md b/docs/howto/auth/nativeauth.md new file mode 100644 index 0000000..92f3a03 --- /dev/null +++ b/docs/howto/auth/nativeauth.md @@ -0,0 +1,33 @@ +(howto-auth-nativeauth)= + +# Let users sign up with a username and password + +The **Native Authenticator** lets users signup for creating a new username +and password. +When they signup, they won't be able to login until they are authorized by an +admin. Users that are characterized as admin have to signup as well, but they +will be authorized automatically. + +## Enabling the authenticator + +Enable the authenticator and reload config to apply the configuration: + +```bash +sudo tljh-config set auth.type nativeauthenticator.NativeAuthenticator +sudo tljh-config reload +``` + +## Allowing all users to be authorized after signup + +By default, all users created on signup don't have authorization to login. +If you wish to allow **any** user to access +the JupyterHub just after the signup, run the following command: + +```bash +tljh-config set auth.NativeAuthenticator.open_signup true +tljh-config reload +``` + +## Optional features + +More optional features are available on the `authenticator documentation ` diff --git a/docs/howto/auth/nativeauth.rst b/docs/howto/auth/nativeauth.rst deleted file mode 100644 index a2701b1..0000000 --- a/docs/howto/auth/nativeauth.rst +++ /dev/null @@ -1,40 +0,0 @@ -.. _howto/auth/nativeauth: - -============================================== -Let users sign up with a username and password -============================================== - -The **Native Authenticator** lets users signup for creating a new username -and password. -When they signup, they won't be able to login until they are authorized by an -admin. Users that are characterized as admin have to signup as well, but they -will be authorized automatically. - - -Enabling the authenticator -========================== - -Enable the authenticator and reload config to apply the configuration: - -.. code-block:: bash - - sudo tljh-config set auth.type nativeauthenticator.NativeAuthenticator - sudo tljh-config reload - - -Allowing all users to be authorized after signup -================================================ - -By default, all users created on signup don't have authorization to login. -If you wish to allow **any** user to access -the JupyterHub just after the signup, run the following command: - -.. code-block:: bash - - tljh-config set auth.NativeAuthenticator.open_signup true - tljh-config reload - -Optional features -================= - -More optional features are available on the `authenticator documentation ` diff --git a/docs/howto/content/add-data.md b/docs/howto/content/add-data.md new file mode 100644 index 0000000..0ff6d59 --- /dev/null +++ b/docs/howto/content/add-data.md @@ -0,0 +1,100 @@ +(howto-content-add-data)= + +# Adding data to the JupyterHub + +This section covers how to add data to your JupyterHub either from the internet +or from your own machine. To learn how to **share data** that is already +on your JupyterHub, see {ref}`howto/content/share-data`. + +:::{note} +When you add data using the methods on this page, you will **only add it +to your user directory**. This is not a place that is accessible to others. +For information on sharing this data with users on the JupyterHub, see +{ref}`howto/content/share-data`. +::: + +## Adding data from your local machine + +The easiest way to add data to your JupyterHub is to use the "Upload" user +interface. To do so, follow these steps: + +1. First, navigate to the Jupyter Notebook interface home page. You can do this + by going to the URL `/user//tree`. + +2. Click the "Upload" button to open the file chooser window. + + ```{image} ../../images/content/upload-button.png + :alt: The upload button in Jupyter. + ``` + +3. Choose the file you wish to upload. You may select multiple files if you + wish. + +4. Click "Upload" for each file that you wish to upload. + + ```{image} ../../images/content/file-upload-buttons.png + :alt: Multiple file upload buttons. + ``` + +5. Wait for the progress bar to finish for each file. These files will now + be on your JupyterHub, your home user's home directory. + +To learn how to **share** this data with new users on the JupyterHub, +see {ref}`howto/content/share-data`. + +## Downloading data from the command line + +If the data of interest is on the internet, you may also use code in order +to download it to your JupyterHub. There are several ways of doing this, so +we'll cover the simplest approach using the unix tool `wget`. + +1. Log in to your JupyterHub and open a terminal window. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +2. Use `wget` to download the file to your current directory in the terminal. + + ```bash + wget + ``` + +### Example: Downloading the [gapminder](https://www.gapminder.org/) dataset. + +In this example we'll download the [gapminder](https://www.gapminder.org/) +dataset, which contains information about country GDP and live expectancy over +time. You can download it from your browser [at this link](https://swcarpentry.github.io/python-novice-gapminder/files/python-novice-gapminder-data.zip). + +1. Log in to your JupyterHub and open a terminal window. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +2. Use `wget` to download the gapminder dataset to your current directory in + the terminal. + + ```bash + wget https://swcarpentry.github.io/python-novice-gapminder/files/python-novice-gapminder-data.zip + ``` + +3. This is a **zip** file, so we'll need to download a unix tool called "unzip" + in order to unzip it. + + ```bash + sudo apt install unzip + ``` + +4. Finally, unzip the file: + + ```bash + unzip python-novice-gapminder-data.zip + ``` + +5. Confirm that your data was unzipped. It could be in a folder called `data/`. + +To learn how to **share** this data with new users on the JupyterHub, +see {ref}`howto/content/share-data`. + +% TODO: Downloading data with the "download" module in Python? https://github.com/choldgraf/download diff --git a/docs/howto/content/add-data.rst b/docs/howto/content/add-data.rst deleted file mode 100644 index c9ba8f0..0000000 --- a/docs/howto/content/add-data.rst +++ /dev/null @@ -1,97 +0,0 @@ -.. _howto/content/add-data: - -============================= -Adding data to the JupyterHub -============================= - -This section covers how to add data to your JupyterHub either from the internet -or from your own machine. To learn how to **share data** that is already -on your JupyterHub, see :ref:`howto/content/share-data`. - -.. note:: - - When you add data using the methods on this page, you will **only add it - to your user directory**. This is not a place that is accessible to others. - For information on sharing this data with users on the JupyterHub, see - :ref:`howto/content/share-data`. - -Adding data from your local machine -=================================== - -The easiest way to add data to your JupyterHub is to use the "Upload" user -interface. To do so, follow these steps: - -#. First, navigate to the Jupyter Notebook interface home page. You can do this - by going to the URL ``/user//tree``. -#. Click the "Upload" button to open the file chooser window. - - .. image:: ../../images/content/upload-button.png - :alt: The upload button in Jupyter. -#. Choose the file you wish to upload. You may select multiple files if you - wish. -#. Click "Upload" for each file that you wish to upload. - - .. image:: ../../images/content/file-upload-buttons.png - :alt: Multiple file upload buttons. -#. Wait for the progress bar to finish for each file. These files will now - be on your JupyterHub, your home user's home directory. - -To learn how to **share** this data with new users on the JupyterHub, -see :ref:`howto/content/share-data`. - -Downloading data from the command line -====================================== - -If the data of interest is on the internet, you may also use code in order -to download it to your JupyterHub. There are several ways of doing this, so -we'll cover the simplest approach using the unix tool ``wget``. - -#. Log in to your JupyterHub and open a terminal window. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New terminal button. - -#. Use ``wget`` to download the file to your current directory in the terminal. - - .. code-block:: bash - - wget - -Example: Downloading the `gapminder `_ dataset. ---------------------------------------------------------------------------- - -In this example we'll download the `gapminder `_ -dataset, which contains information about country GDP and live expectancy over -time. You can download it from your browser `at this link `_. - -#. Log in to your JupyterHub and open a terminal window. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New terminal button. - -#. Use ``wget`` to download the gapminder dataset to your current directory in - the terminal. - - .. code-block:: bash - - wget https://swcarpentry.github.io/python-novice-gapminder/files/python-novice-gapminder-data.zip - -#. This is a **zip** file, so we'll need to download a unix tool called "unzip" - in order to unzip it. - - .. code-block:: bash - - sudo apt install unzip - -#. Finally, unzip the file: - - .. code-block:: bash - - unzip python-novice-gapminder-data.zip - -#. Confirm that your data was unzipped. It could be in a folder called ``data/``. - -To learn how to **share** this data with new users on the JupyterHub, -see :ref:`howto/content/share-data`. - -.. TODO: Downloading data with the "download" module in Python? https://github.com/choldgraf/download diff --git a/docs/howto/content/nbgitpuller.rst b/docs/howto/content/nbgitpuller.md similarity index 53% rename from docs/howto/content/nbgitpuller.rst rename to docs/howto/content/nbgitpuller.md index 5daf7b9..e190bb2 100644 --- a/docs/howto/content/nbgitpuller.rst +++ b/docs/howto/content/nbgitpuller.md @@ -1,11 +1,8 @@ -.. _howto/content/nbgitpuller: +(howto-content-nbgitpuller)= -================================================ -Distributing materials to users with nbgitpuller -================================================ +# Distributing materials to users with nbgitpuller -Goal -==== +## Goal A very common need when using JupyterHub is to easily distribute study materials / lab notebooks to students. @@ -29,38 +26,34 @@ This tutorial will walk you through the process of creating a magic nbgitpuller link that users of your JupyterHub can click to fetch the latest version of materials from a git repo. -Pre-requisites -============== +## Pre-requisites 1. A JupyterHub set up with The Littlest JupyterHub 2. A git repository containing materials to distribute -Step 1: Generate nbgitpuller link -================================= +## Step 1: Generate nbgitpuller link -The quickest way to generate a link is to use `nbgitpuller.link -`_, but other options exist as described in the -`nbgitpuller project's documentation -`_. +The quickest way to generate a link is to use [nbgitpuller.link](https://jupyterhub.github.io/nbgitpuller/link.html), but other options exist as described in the +[nbgitpuller project's documentation](https://jupyterhub.github.io/nbgitpuller/use.html). -Step 2: Users click on the nbgitpuller link -=========================================== +## Step 2: Users click on the nbgitpuller link -#. Send the link to your users in some way - email, slack, post a - shortened version (with `bit.ly `_ maybe) on the wall, or - put it on your syllabus page (like `UC Berkeley's data8 does `_). +1. Send the link to your users in some way - email, slack, post a + shortened version (with [bit.ly](https://bit.ly) maybe) on the wall, or + put it on your syllabus page (like [UC Berkeley's data8 does](http://data8.org/sp18/)). Whatever works for you :) -#. When users click the link, they will be asked to log in to the hub +2. When users click the link, they will be asked to log in to the hub if they have not already. -#. Users will see a progress bar as the git repository is fetched & any +3. Users will see a progress bar as the git repository is fetched & any automatic merging required is performed. - .. image:: ../../images/nbgitpuller/pull-progress.png - :alt: Progress bar with git repository being pulled + ```{image} ../../images/nbgitpuller/pull-progress.png + :alt: Progress bar with git repository being pulled + ``` -#. Users will now be redirected to the notebook specified in the URL! +4. Users will now be redirected to the notebook specified in the URL! This workflow lets users land directly in the notebook you specified without having to understand much about git or the JupyterHub interface. diff --git a/docs/howto/content/share-data.md b/docs/howto/content/share-data.md new file mode 100644 index 0000000..672cc75 --- /dev/null +++ b/docs/howto/content/share-data.md @@ -0,0 +1,137 @@ +(howto-content-share-data)= + +# Share data with your users + +There are a few options for sharing data with your users, this page covers +a few useful patterns. + +## Option 1: Distributing data with `nbgitpuller` + +For small datasets, the simplest way to share data with your users is via +`nbgitpuller` links. In this case, users click on your link and the dataset +contained in the link's target repository is downloaded to the user's home +directory. Note that a copy of the dataset will be made for each user. + +For information on creating and sharing `nbgitpuller` links, see +{ref}`howto/content/nbgitpuller`. + +## Option 2: Create a read-only shared folder for data + +If your data is large or you don't want copies of it to exist, you can create +a read-only shared folder that users have access to. To do this, follow these +steps: + +1. **Log** in to your JupyterHub as an **administrator user**. + +2. **Create a terminal session** with your JupyterHub interface. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +3. **Create a folder** where your data will live. We recommend placing shared + data in `/srv`. The following command creates two folders (`/srv/data` and + `/srv/data/my_shared_data_folder`). + + ```bash + sudo mkdir -p /srv/data/my_shared_data_folder + ``` + +4. **Download the data** into this folder. See {ref}`howto/content/add-data` for + details on how to do this. + +5. All users now have read access to the data in this folder. + +### Add a link to the shared folder in the user home directory + +Optionally, you may also **create a symbolic link to the shared data folder** +that you created above in each **new user's** home directory. + +To do this, you can use the server's **user skeleton directory** (`/etc/skel`). +Anything that is placed in this directory will also +show up in a new user's home directory. + +To create a link to the shared folder in the user skeleton directory, +follow these steps: + +1. `cd` into the skeleton directory: + + ```bash + cd /etc/skel + ``` + +2. **Create a symbolic link** to the data folder + + ```bash + sudo ln -s /srv/data/my_shared_data_folder my_shared_data_folder + ``` + +3. **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. + After you log in as a **new user**, the folder should appear in your new + user home directory. + +From now on, when a new user account is created, their home directory will +have this symbolic link (and any other files in `/etc/skel`) in their home +directory. This will have **no effect on the directories of existing +users**. + +## Option 3: Create a directory for users to share Notebooks and other files + +You may want a place for users to share files with each other rather than +only having administrators share files with users (Option 2). In this +configuration, any user can put files into `/srv/scratch` that other users +can read. However, only the user that created the file can edit the file. + +One way for users to share or "publish" Notebooks in a JupyterHub environment +is to create a shared directory. Any user can create files in the directory, +but only the creator may edit that file afterwards. + +For instance, in a Hub with three users, User A develops a Notebook in their +`/home` directory. When it is ready to share, User A copies it to the +`shared` directory. At that time, User B and User C can see User A's +Notebook and run it themselves (or view it in a Dashboard layout +such as `voila` or `panel` if that is running in the Hub), but User B +and User C cannot edit the Notebook. Only User A can make changes. + +1. **Log** in to your JupyterHub as an **administrator user**. + +2. **Create a terminal session** with your JupyterHub interface. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +3. **Create a folder** where your data will live. We recommend placing shared + data in `/srv`. The following command creates a directory `/srv/scratch` + + ```bash + sudo mkdir -p /srv/scratch + ``` + +4. **Change group ownership** of the new folder + + ```bash + sudo chown root:jupyterhub-users /srv/scratch + ``` + +5. **Change default permissions to use group**. The default permissions for new + sub-directories uses the global umask (`drwxr-sr-x`), the `chmod g+s` tells + new files to use the default permissions for the group `jupyterhub-users` + (`rw-r--r--`) + + ```bash + sudo chmod 777 /srv/scratch + sudo chmod g+s /srv/scratch + ``` + +6. **Create a symbolic link** to the scratch folder in users home directories + + ```bash + sudo ln -s /srv/scratch /etc/skel/scratch + ``` + +:::{note} +The TLJH Plugin at installs `voila` and sets up the directories as specified above. +Include `--plugin git+https://github.com/kafonek/tljh-shared-directory` in your deployment startup script to install it. +::: diff --git a/docs/howto/content/share-data.rst b/docs/howto/content/share-data.rst deleted file mode 100644 index 16d1b55..0000000 --- a/docs/howto/content/share-data.rst +++ /dev/null @@ -1,139 +0,0 @@ -.. _howto/content/share-data: - -========================== -Share data with your users -========================== - -There are a few options for sharing data with your users, this page covers -a few useful patterns. - -Option 1: Distributing data with `nbgitpuller` -============================================== - -For small datasets, the simplest way to share data with your users is via -``nbgitpuller`` links. In this case, users click on your link and the dataset -contained in the link's target repository is downloaded to the user's home -directory. Note that a copy of the dataset will be made for each user. - -For information on creating and sharing ``nbgitpuller`` links, see -:ref:`howto/content/nbgitpuller`. - -Option 2: Create a read-only shared folder for data -=================================================== - -If your data is large or you don't want copies of it to exist, you can create -a read-only shared folder that users have access to. To do this, follow these -steps: - -#. **Log** in to your JupyterHub as an **administrator user**. - -#. **Create a terminal session** with your JupyterHub interface. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New terminal button. -#. **Create a folder** where your data will live. We recommend placing shared - data in ``/srv``. The following command creates two folders (``/srv/data`` and - ``/srv/data/my_shared_data_folder``). - - .. code-block:: bash - - sudo mkdir -p /srv/data/my_shared_data_folder - -#. **Download the data** into this folder. See :ref:`howto/content/add-data` for - details on how to do this. - -#. All users now have read access to the data in this folder. - -Add a link to the shared folder in the user home directory ----------------------------------------------------------- - -Optionally, you may also **create a symbolic link to the shared data folder** -that you created above in each **new user's** home directory. - -To do this, you can use the server's **user skeleton directory** (``/etc/skel``). -Anything that is placed in this directory will also -show up in a new user's home directory. - -To create a link to the shared folder in the user skeleton directory, -follow these steps: - -#. ``cd`` into the skeleton directory: - - .. code-block:: bash - - cd /etc/skel - -#. **Create a symbolic link** to the data folder - - .. code-block:: bash - - 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. - After you log in as a **new user**, the folder should appear in your new - user home directory. - -From now on, when a new user account is created, their home directory will -have this symbolic link (and any other files in ``/etc/skel``) in their home -directory. This will have **no effect on the directories of existing -users**. - -Option 3: Create a directory for users to share Notebooks and other files -========================================================================= - -You may want a place for users to share files with each other rather than -only having administrators share files with users (Option 2). In this -configuration, any user can put files into ``/srv/scratch`` that other users -can read. However, only the user that created the file can edit the file. - -One way for users to share or "publish" Notebooks in a JupyterHub environment -is to create a shared directory. Any user can create files in the directory, -but only the creator may edit that file afterwards. - -For instance, in a Hub with three users, User A develops a Notebook in their -``/home`` directory. When it is ready to share, User A copies it to the -`shared` directory. At that time, User B and User C can see User A's -Notebook and run it themselves (or view it in a Dashboard layout -such as ``voila`` or ``panel`` if that is running in the Hub), but User B -and User C cannot edit the Notebook. Only User A can make changes. - -#. **Log** in to your JupyterHub as an **administrator user**. - -#. **Create a terminal session** with your JupyterHub interface. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New terminal button. - -#. **Create a folder** where your data will live. We recommend placing shared - data in ``/srv``. The following command creates a directory ``/srv/scratch`` - - .. code-block:: bash - - sudo mkdir -p /srv/scratch - -#. **Change group ownership** of the new folder - - .. code-block:: bash - - sudo chown root:jupyterhub-users /srv/scratch - -#. **Change default permissions to use group**. The default permissions for new - sub-directories uses the global umask (``drwxr-sr-x``), the ``chmod g+s`` tells - new files to use the default permissions for the group ``jupyterhub-users`` - (``rw-r--r--``) - - .. code-block:: bash - - sudo chmod 777 /srv/scratch - sudo chmod g+s /srv/scratch - -#. **Create a symbolic link** to the scratch folder in users home directories - - .. code-block:: bash - - sudo ln -s /srv/scratch /etc/skel/scratch - -.. note:: - The TLJH Plugin at https://github.com/kafonek/tljh-shared-directory installs ``voila`` and sets up the directories as specified above. - Include ``--plugin git+https://github.com/kafonek/tljh-shared-directory`` in your deployment startup script to install it. diff --git a/docs/howto/env/notebook-interfaces.rst b/docs/howto/env/notebook-interfaces.rst deleted file mode 100644 index a5a780a..0000000 --- a/docs/howto/env/notebook-interfaces.rst +++ /dev/null @@ -1,56 +0,0 @@ -.. _howto/env/notebook_interfaces: - -======================================= -Change default User Interface for users -======================================= - -By default, logging into TLJH puts you in the classic Jupyter Notebook interface -we all know and love. However, there are at least two other popular notebook -interfaces you can use: - -1. `JupyterLab `_ -2. `nteract `_ - -Both these interfaces are also shipped with tljh by default. You can try them -temporarily, or set them to be the default interface whenever you login. - -Trying an alternate interface temporarily -========================================= - -When you log in & start your server, by default the URL in your browser -will be something like ``/user//tree``. The ``/tree`` is what tells -the notebook server to give you the classic notebook interface. - -* **For the JupyterLab interface**: change ``/tree`` to ``/lab``. -* **For the nteract interface**: change ``/tree`` to ``/nteract`` - -You can play around with them and see what fits your use cases best. - -Changing the default user interface -=================================== - -You can change the default interface users get when they log in by modifying -``config.yaml`` as an admin user. - -#. To launch **JupyterLab** when users log in, run the following in an admin console: - - .. code-block:: yaml - - sudo tljh-config set user_environment.default_app jupyterlab - -#. Alternatively, to launch **nteract** when users log in, run the following in the admin console: - - .. code-block:: yaml - - sudo tljh-config set user_environment.default_app nteract - -#. Apply the changes by restarting JupyterHub. This should not disrupt current users. - - .. code-block:: yaml - - sudo tljh-config reload hub - - If this causes problems, check the :ref:`troubleshoot_logs_jupyterhub` for clues - on what went wrong. - -Users might have to restart their servers from control panel to get the new interface. diff --git a/docs/howto/env/server-resources.rst b/docs/howto/env/server-resources.rst deleted file mode 100644 index 1295672..0000000 --- a/docs/howto/env/server-resources.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _howto/env/server-resources: - -====================================== -Configure resources available to users -====================================== - -To configure the resources that are available to your users (such as RAM, CPU -and Disk Space), see the section :ref:`tljh-set-user-limits`. For information -on **resizing** the environment available to users *after* you've created your -JupyterHub, see :ref:`howto/admin/resize`. diff --git a/docs/howto/env/user-environment.rst b/docs/howto/env/user-environment.rst deleted file mode 100644 index c1f90b9..0000000 --- a/docs/howto/env/user-environment.rst +++ /dev/null @@ -1,209 +0,0 @@ -.. _howto/env/user_environment: - -================================== -Install conda, pip or apt packages -================================== - -:abbr:`TLJH (The Littlest JupyterHub)` starts all users in the same `conda `_ -environment. Packages / libraries installed in this environment are available -to all users on the JupyterHub. Users with :ref:`admin rights ` can install packages -easily. - -.. _howto/env/user_environment_pip: - -Installing pip packages -======================= - -`pip `_ is the recommended tool for installing packages -in Python from the `Python Packaging Index (PyPI) `_. PyPI has -almost 145,000 packages in it right now, so a lot of what you need is going to be there! - -1. Log in as an admin user and open a Terminal in your Jupyter Notebook. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New Terminal button under New menu - - If you already have a terminal open as an admin user, that should work too! - -2. Install a package! - - .. code-block:: bash - - sudo -E pip install numpy - - This installs the ``numpy`` library from PyPI and makes it available - to all users. - - .. note:: - - If you get an error message like ``sudo: pip: command not found``, - make sure you are not missing the ``-E`` parameter after ``sudo``. - -.. _howto/env/user_environment_conda: - -Installing conda packages -========================= - -Conda lets you install new languages (such as new versions of python, node, R, etc) -as well as packages in those languages. For lots of scientific software, installing -with conda is often simpler & easier than installing with pip - especially if it -links to C / Fortran code. - -We recommend installing packages from `conda-forge `_, -a community maintained repository of conda packages. - -1. Log in as an admin user and open a Terminal in your Jupyter Notebook. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New Terminal button under New menu - - If you already have a terminal open as an admin user, that should work too! - -2. Install a package! - - .. code-block:: bash - - sudo -E conda install -c conda-forge gdal - - This installs the ``gdal`` library from ``conda-forge`` and makes it available - to all users. ``gdal`` is much harder to install with pip. - - .. note:: - - If you get an error message like ``sudo: conda: command not found``, - make sure you are not missing the ``-E`` parameter after ``sudo``. - -.. _howto/env/user_environment_apt: - -Installing apt packages -======================= - -`apt `_ is the official package -manager for the `Ubuntu Linux distribution `_. You can install -utilities (such as ``vim``, ``sl``, ``htop``, etc), servers (``postgres``, ``mysql``, ``nginx``, etc) -and a lot more languages than present in ``conda`` (``haskell``, ``prolog``, ``INTERCAL``). -Some third party software (such as `RStudio `_) -is distributed as ``.deb`` files, which are the files ``apt`` uses to install software. - -You can search for packages with `Ubuntu Package search `_ - -make sure to look in the version of Ubuntu you are using! - -1. Log in as an admin user and open a Terminal in your Jupyter Notebook. - - .. image:: ../../images/notebook/new-terminal-button.png - :alt: New Terminal button under New menu - - If you already have a terminal open as an admin user, that should work too! - -2. Update list of packages available. This makes sure you get the latest version of - the packages possible from the repositories. - - .. code-block:: bash - - sudo apt update - -3. Install the packages you want. - - .. code-block:: bash - - sudo apt install mysql-server git - - This installs (and starts) a ``MySQL `` database server - and ``git``. - - -User environment location -========================= - -The user environment is a conda environment set up in ``/opt/tljh/user``, with -a Python3 kernel as the default. It is readable by all users, but writeable only -by users who have root access. This makes it possible for JupyterHub admins (who have -root access with ``sudo``) to install software in the user environment easily. - -Accessing user environment outside JupyterHub -============================================= - -We add ``/opt/tljh/user/bin`` to the ``$PATH`` environment variable for all JupyterHub -users, so everything installed in the user environment is available to them automatically. -If you are using ``ssh`` to access your server instead, you can get access to the same -environment with: - -.. code-block:: bash - - export PATH=/opt/tljh/user/bin:${PATH} - -Whenever you run any command now, the user environment will be searched first before -your system environment is. So if you run ``python3 ``, it'll use the ``python3`` -installed in the user environment (``/opt/tljh/user/bin/python3``) rather than the ``python3`` -installed in your system environment (``/usr/bin/python3``). This is usually what you want! - -To make this change 'stick', you can add the line to the end of the ``.bashrc`` file in -your home directory. - -When using ``sudo``, the ``PATH`` environment variable is usually reset, for security -reasons. This leads to error messages like: - -.. code-block:: console - - $ sudo conda install -c conda-forge gdal - sudo: conda: command not found - -The most common & portable way to fix this when using ``ssh`` is: - -.. code-block:: bash - - sudo PATH=${PATH} conda install -c conda-forge gdal - - -Upgrade to a newer Python version -================================= - -All new TLJH installs use miniconda 4.7.10, which comes with a Python 3.7 -environment for the users. The previously TLJH installs came with miniconda 4.5.4, -which meant a Python 3.6 environment. - -To upgrade the Python version of the user environment, one can: - -* **Start fresh on a machine that doesn't have TLJH already installed.** - - See the :ref:`installation guide ` section about how to install TLJH. - -* **Upgrade Python manually.** - - Because upgrading Python for existing installs can break packages already installed - under the old Python, upgrading your current TLJH installation, will NOT upgrade - the Python version of the user environment, but you may do so manually. - - **Steps:** - - 1. Activate the user environment, if using ssh. - If the terminal was started with JupyterHub, this step can be skipped: - - .. code-block:: bash - - source /opt/tljh/user/bin/activate - - 2. Get the list of currently installed pip packages (so you can later install them under the - new Python): - - .. code-block:: bash - - pip freeze > pip_pkgs.txt - - 3. Update all conda installed packages in the environment: - - .. code-block:: bash - - sudo PATH=${PATH} conda update --all - - 4. Update Python version: - - .. code-block:: bash - - sudo PATH=${PATH} conda install python=3.7 - - 5. Install the pip packages previously saved: - - .. code-block:: bash - - pip install -r pip_pkgs.txt diff --git a/docs/howto/index.md b/docs/howto/index.md new file mode 100644 index 0000000..2bc0273 --- /dev/null +++ b/docs/howto/index.md @@ -0,0 +1,67 @@ +# How-To Guides + +How-To guides answer the question 'How do I...?' for a lot of topics. + +## Content and data + +```{toctree} +:caption: Content and data +:titlesonly: true + +content/nbgitpuller +content/add-data +content/share-data +``` + +## The user environment + +```{toctree} +:caption: The user environment +:titlesonly: true + +env/user-environment +env/notebook-interfaces +env/server-resources +``` + +## Authentication + +We have a special set of How-To Guides on using various forms of authentication +with your JupyterHub. For more information on Authentication, see +{ref}`topic/authenticator-configuration` + +```{toctree} +:titlesonly: true + +auth/dummy +auth/github +auth/google +auth/awscognito +auth/firstuse +auth/nativeauth +``` + +## Administration and security + +```{toctree} +:caption: Administration and security +:titlesonly: true + +admin/admin-users +admin/resource-estimation +admin/resize +admin/nbresuse +admin/https +admin/enable-extensions +admin/systemd +``` + +## Cloud provider configuration + +```{toctree} +:caption: Cloud provider configuration +:titlesonly: true + +providers/digitalocean +providers/azure +``` diff --git a/docs/howto/index.rst b/docs/howto/index.rst deleted file mode 100644 index 610d582..0000000 --- a/docs/howto/index.rst +++ /dev/null @@ -1,68 +0,0 @@ -How-To Guides -============= - -How-To guides answer the question 'How do I...?' for a lot of topics. - -Content and data ----------------- - -.. toctree:: - :titlesonly: - :caption: Content and data - - content/nbgitpuller - content/add-data - content/share-data - -The user environment --------------------- - -.. toctree:: - :titlesonly: - :caption: The user environment - - env/user-environment - env/notebook-interfaces - env/server-resources - -Authentication --------------- - -We have a special set of How-To Guides on using various forms of authentication -with your JupyterHub. For more information on Authentication, see -:ref:`topic/authenticator-configuration` - -.. toctree:: - :titlesonly: - - auth/dummy - auth/github - auth/google - auth/awscognito - auth/firstuse - auth/nativeauth - -Administration and security ---------------------------- - -.. toctree:: - :titlesonly: - :caption: Administration and security - - admin/admin-users - admin/resource-estimation - admin/resize - admin/nbresuse - admin/https - admin/enable-extensions - admin/systemd - -Cloud provider configuration ----------------------------- - -.. toctree:: - :titlesonly: - :caption: Cloud provider configuration - - providers/digitalocean - providers/azure diff --git a/docs/howto/providers/azure.md b/docs/howto/providers/azure.md new file mode 100644 index 0000000..b61b75d --- /dev/null +++ b/docs/howto/providers/azure.md @@ -0,0 +1,38 @@ +(howto-providers-azure)= + +# Perform common Microsoft Azure configuration tasks + +This page lists various common tasks you can perform on your +[Microsoft Azure virtual machine](https://azure.microsoft.com/services/virtual-machines/?WT.mc_id=TLJH-github-taallard). + +(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 (e.g. storage and network) 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" on the left hand panel + +- 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. +::: + +If you no longer need any of your resources you can delete the entire resource group. + +- Go to "Reosurce groups" on the left hand panel +- Click on your resource group +- Click on "Delete resource group" you will then be asked to confirm the operation. This operation will take between 5 and 10 minutes. diff --git a/docs/howto/providers/azure.rst b/docs/howto/providers/azure.rst deleted file mode 100644 index fab16a8..0000000 --- a/docs/howto/providers/azure.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _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 (e.g. storage and network) 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" on the left hand panel -* 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. - -If you no longer need any of your resources you can delete the entire resource group. - -* Go to "Reosurce groups" on the left hand panel -* Click on your resource group -* Click on "Delete resource group" you will then be asked to confirm the operation. This operation will take between 5 and 10 minutes. diff --git a/docs/howto/providers/digitalocean.md b/docs/howto/providers/digitalocean.md new file mode 100644 index 0000000..4528f6e --- /dev/null +++ b/docs/howto/providers/digitalocean.md @@ -0,0 +1,42 @@ +(howto-providers-digitalocean)= + +# Perform common Digital Ocean configuration tasks + +This page lists various common tasks you can perform on your +Digital Ocean virtual machine. + +(howto-providers-digitalocean-resize)= + +## Resizing your droplet + +As you use your JupyterHub, you may find that you need more memory, +disk space, or CPUs. Digital Ocean servers can be resized in the +"Resize Droplet" panel. These instructions take you through the process. + +1. First, click on the name of your newly-created + Droplet to enter its configuration page. + +2. Next, **turn off your Droplet**. This allows DigitalOcean to make + modifications to your VM. This will shut down your JupyterHub (temporarily). + + ```{image} ../../images/providers/digitalocean/power-off.png + :alt: Power off your Droplet + :width: 200px + ``` + +3. Once your Droplet has been turned off, click "Resize", + which will take you to a menu with options to resize your VM. + + ```{image} ../../images/providers/digitalocean/resize-droplet.png + :alt: Resize panel of digital ocean + ``` + +4. Decide what kinds of resources you'd like to resize, then click on a new VM + type in the list below. Finally, click "Resize". This may take a few moments! + +5. Once your Droplet is resized, **turn your Droplet back on**. This makes your JupyterHub + available to the world once again. This will take a few moments to complete. + +Now that you've resized your Droplet, you may want to change the resources available +to your users. Further information on making more resources available to +users and verifying resource availability can be found in {ref}`howto/admin/resize`. diff --git a/docs/howto/providers/digitalocean.rst b/docs/howto/providers/digitalocean.rst deleted file mode 100644 index f3fcf30..0000000 --- a/docs/howto/providers/digitalocean.rst +++ /dev/null @@ -1,43 +0,0 @@ -.. _howto/providers/digitalocean: - -================================================ -Perform common Digital Ocean configuration tasks -================================================ - -This page lists various common tasks you can perform on your -Digital Ocean virtual machine. - -.. _howto/providers/digitalocean/resize: - -Resizing your droplet -===================== - -As you use your JupyterHub, you may find that you need more memory, -disk space, or CPUs. Digital Ocean servers can be resized in the -"Resize Droplet" panel. These instructions take you through the process. - -#. First, click on the name of your newly-created - Droplet to enter its configuration page. - -#. Next, **turn off your Droplet**. This allows DigitalOcean to make - modifications to your VM. This will shut down your JupyterHub (temporarily). - - .. image:: ../../images/providers/digitalocean/power-off.png - :alt: Power off your Droplet - :width: 200px - -#. Once your Droplet has been turned off, click "Resize", - which will take you to a menu with options to resize your VM. - - .. image:: ../../images/providers/digitalocean/resize-droplet.png - :alt: Resize panel of digital ocean - -#. Decide what kinds of resources you'd like to resize, then click on a new VM - type in the list below. Finally, click "Resize". This may take a few moments! - -#. Once your Droplet is resized, **turn your Droplet back on**. This makes your JupyterHub - available to the world once again. This will take a few moments to complete. - -Now that you've resized your Droplet, you may want to change the resources available -to your users. Further information on making more resources available to -users and verifying resource availability can be found in :ref:`howto/admin/resize`. diff --git a/docs/index.rst b/docs/index.md similarity index 65% rename from docs/index.rst rename to docs/index.md index f66f4b9..b542c4b 100644 --- a/docs/index.rst +++ b/docs/index.md @@ -1,21 +1,16 @@ -======================= -The Littlest JupyterHub -======================= +# The Littlest JupyterHub -A simple `JupyterHub `_ distribution for +A simple [JupyterHub](https://github.com/jupyterhub/jupyterhub) distribution for a small (0-100) number of users on a single server. We recommend reading -:ref:`topic/whentouse` to determine if this is the right tool for you. +{ref}`topic/whentouse` to determine if this is the right tool for you. - -Development Status -================== +## Development Status 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 -============ +## Installation The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on an amd64 or arm64 CPU architecture. We aim to support 'stable' and Long-Term Support (LTS) versions. @@ -27,58 +22,58 @@ We have a bunch of tutorials to get you started. on it. These are **recommended** if you do not have much experience setting up servers. - .. toctree:: - :titlesonly: - :maxdepth: 2 + ```{toctree} + :maxdepth: 2 + :titlesonly: true - install/index + install/index + ``` Once you are ready to run your server for real, -it's a good idea to proceed directly to :doc:`howto/admin/https`. +it's a good idea to proceed directly to {doc}`howto/admin/https`. -How-To Guides -============= +## How-To Guides How-To guides answer the question 'How do I...?' for a lot of topics. -.. toctree:: - :maxdepth: 2 +```{toctree} +:maxdepth: 2 - howto/index +howto/index +``` -Topic Guides -============ +## Topic Guides Topic guides provide in-depth explanations of specific topics. -.. toctree:: - :titlesonly: - :maxdepth: 2 +```{toctree} +:maxdepth: 2 +:titlesonly: true - topic/index +topic/index +``` - -Troubleshooting -=============== +## Troubleshooting In time, all systems have issues that need to be debugged. Troubleshooting guides help you find what is broken & hopefully fix it. -.. toctree:: - :titlesonly: - :maxdepth: 2 +```{toctree} +:maxdepth: 2 +:titlesonly: true - troubleshooting/index +troubleshooting/index +``` -Contributing -============ +## Contributing We want you to contribute to TLJH in the ways that are most useful and exciting to you. This section contains documentation helpful to people contributing in various ways. -.. toctree:: - :titlesonly: - :maxdepth: 2 +```{toctree} +:maxdepth: 2 +:titlesonly: true - contributing/index +contributing/index +``` diff --git a/docs/install/amazon.md b/docs/install/amazon.md new file mode 100644 index 0000000..0ee8915 --- /dev/null +++ b/docs/install/amazon.md @@ -0,0 +1,279 @@ +(install-amazon)= + +# Installing on Amazon Web Services + +## Goal + +To have a JupyterHub with admin users and a user environment with conda / pip packages. + +## Prerequisites + +1. An Amazon Web Services account. + + If asked to choose a default region, choose the one closest to the majority + of your users. + +## Step 1: Installing The Littlest JupyterHub + +Let's create the server on which we can run JupyterHub. + +1. Go to [Amazon Web Services](https://aws.amazon.com/) and click the gold + button 'Sign In to the Console' in the upper right. Log in with your Amazon Web + Services account. + + If you need to adjust your region from your default, there is a drop-down + menu between your name and the **Support** menu on the far right of the dark + navigation bar across the top of the window. Adjust the region to match the + closest one to the majority of your users. + +2. On the screen listing all the available services, pick **EC2** under **Compute** + on the left side at the top of the first column. + + ```{image} ../images/providers/amazon/compute_services.png + :alt: Select EC2 + ``` + + This will take you to the **EC2 Management Console**. + +3. From the navigation menu listing on the far left side of the **EC2 Management + Console**, choose **Instances** under the light gray **INSTANCES** sub-heading. + + ```{image} ../images/providers/amazon/instances_from_console.png + :alt: Select Instances from console + ``` + +4. In the main window of the **EC2 Management Console**, towards the top left, + click on the bright blue **Launch Instance** button. + + ```{image} ../images/providers/amazon/launch_instance_button.png + :alt: Click launch instance + ``` + + This will start the 'launch instance wizard' process. This lets you customize + the kind of server you want, the resources it will have and its name. + +5. On the page **Step 1: Choose an Amazon Machine Image (AMI)** you are going + to pick the base image your remote server will have. The view will + default to the 'Quick-start' tab selected and just a few down the page, select + **Ubuntu Server 22.04 LTS (HVM), SSD Volume Type - ami-XXXXXXXXXXXXXXXXX**, + leaving `64-bit (x86)` toggled. + + ```{image} ../images/providers/amazon/select_ubuntu_18.png + :alt: Click Ubuntu server 22.04 + ``` + + The `ami` alpha-numeric at the end references the specific Amazon machine + image, ignore this as Amazon updates them routinely. The + **Ubuntu Server 22.04 LTS (HVM)** is the important part. + +6. After selecting the AMI, you'll be at **Step 2: Choose an Instance Type**. + + There will be a long listing of the types and numbers of CPUs that Amazon + offers. Select the one you want and then select the button + `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. + We recommend you use a server with at least 2GB of RAM, such as a **t3.small**. + However, if you need to minimise costs you can use a server with **1GB** RAM such as a **t2.micro**, but performance will be limited. + + 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. + + `GPU graphics` and `GPU compute` products are also available around half way down the page + +7. Under **Step 3: Configure Instance Details**, scroll to the bottom of the page + and toggle the arrow next to **Advanced Details**. Scroll down to 'User data'. Copy + the text below, and paste it into the **User data** text box. Replace + `` with the name of the first **admin user** for this + JupyterHub. This admin user can log in after the JupyterHub is set up, and + configure it. **Remember to add your username**! + + ```bash + #!/bin/bash + curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin + ``` + + ```{image} ../images/providers/amazon/script_in_user_data.png + :alt: Install JupyterHub with the script in the User data textbox + ``` + + :::{note} + See {ref}`topic/installer-actions` for a detailed description and + {ref}`topic/customizing-installer` for other options that can be used. + ::: + +8. Under **Step 4: Add Storage**, you can change the **size** and **type of your + disk by adjusting the value in \*\*Size (GiB)** and selecting **Volume Type**. + + ```{image} ../images/providers/amazon/change_size_type.png + :alt: Selecting disk size and type + ``` + + Check out {ref}`howto/admin/resource-estimation` to help pick + how much Disk space your server needs. + + Hover over the encircled `i` next to **Volume Type** for an explanation of + each. Leaving the default as is is fine. `General Purpose SSD (gp2)` is + recommended for most workloads. With `Provisioned IOPS SSD (io1)` being the + highest-performance SSD volume. Magnetic (standard) is a previous generation + volume and not suited for a hub for multi-users. + + When finished, click **Next: Add Tags** in the bottom right corner. + +9. Under **Step 5: Add Tags**, click **Add Tag** and enter **Name** under the + **Key** field. In the **Value** field in the **Name** row, give your new + server a memorable name that identifies what purpose this JupyterHub will be + used for. + + ```{image} ../images/providers/amazon/name_hub.png + :alt: Use tags to name the hub. + ``` + +10. 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 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). + + If you have never used your Amazon account before, you'll have to select + **Create a new security group**. You should give it a disitnguishing name + under **Security group name** + such as `ssh_web` for future reference. If you have, one from before you can + select it and adjust it to have the rules you need, if you prefer. + + The rules will default to include `SSH`. Leave that there, and then click on + the **Add Rule** button. Under **Type** for the new rule, change the field + to **HTTP**. The other boxes will get filled in appropritely. Again, click on + the **Add Rule** button. This time under **Type** for the new rule, change + the field to **HTTPS**. + + The warning is there to remind you this opens things up to some degree but + this is necessary in order to let your users connect. However, this warning + is a good reminder that you should monitor your server to insure it is + available for users who may need it. + + ```{image} ../images/providers/amazon/set_security_groups.png + :alt: Allow HTTP & HTTPS traffic to your server + ``` + +11. When the security rules are set, click on the blue button in the bottom + right **Review and Launch**. This will give you a chance to review things + because very soon you'll be launching and start paying for any resources you + use. + + Note that you'll see two HTTP listings and two HTTPS listings under + **Security Groups** even though you only made one for each. This is normal & + necessary to match both IPv4 & IPv6 types of IP addresses. + + When you are happy, press the blue **Launch** button in the bottom right + corner to nearly conclude your journey through the instance launch wizard. + + ```{image} ../images/providers/amazon/finally_launch.png + :alt: Launch your server + ``` + +12. In the dialog box that pops up as the last step before launching is + 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 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. + + Download and keep the key pair file unless you are associating one you already + have. + + ```{image} ../images/providers/amazon/create_key_pair.png + :alt: Associate key pair + ``` + +13. With the key pair associated, click the **Launch instances** button to + start creating the server that'll run TLJH. + + ```{image} ../images/providers/amazon/launch_now.png + :alt: Trigger actual launch + ``` + +14. Following the launch initiation, you'll be taken to a **Launch Status** + notification screen. You can see more information about the details if you + click on the alphanumeric link to the launching instance following the text, + "`The following instance launches have been initiated:`". + + ```{image} ../images/providers/amazon/launch_status_screen.png + :alt: Launch status notice + ``` + +15. That link will take you back to the **EC2 Management Console** with settings + that will limit the view in the console to just that instance. (Delete the + filter in the search bar if you want to see any other instances you may + have.) At first the server will be starting up, and then when the + **Instance state** is green the server is running. + + ```{image} ../images/providers/amazon/running_server.png + :alt: Server is running. + ``` + + If you already have instances running in your account, the screen will look + different if you disable that filter. But you want to pay attention to the + row with the name of the server you made. + +16. In a few seconds your server will be created, and you can see the + **Public IP** used to access it in the panel at the bottom of the console. + If it isn't displayed, click on the row for that instance in the console. It + will look like a pattern similar to **12.30.230.127**. + + ```{image} ../images/providers/amazon/public_ip.png + :alt: public IP + ``` + +17. The Littlest JupyterHub is now installing in the background on your new + server. It takes around 10 minutes for this installation to complete. + +18. Check if the installation is complete by copying the **Public IP** + of your server, and trying to access it from within a browser. If it has been + 10 minutes, paste the public IP into the URL bar of your browser and hit + return to try to connect. + + Accessing the JupyterHub will fail until the installation is complete, + so be patient. The next step below this one shows the login window you are + expecting to see when trying the URL and things work. + While waiting until the appropriate time to try, another way to check if + things are churning away, is to open the **System Log**. To do this, go to + the **EC2 Management Console** & highlight the instance by clicking on that + row and then right-click **Monitor and troubleshoot** > **Get system log**. + +19. When the Jupyterhub creation process finishes and the hub is ready to show + the login, the **System Log** should look similar to the image below. Scroll to + the bottom of your output from the previous step. + Note the line **Starting TLJH installer**, you may also see **Started jupyterhub.service** + + ```{image} ../images/providers/amazon/completed_system_log.png + :alt: Completed system log + ``` + +20. When the installation is complete, it should give you a JupyterHub login page. + + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` + +21. Login using the **admin user name** you used in step 7, 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. + +22. Congratulations, you have a running working JupyterHub! + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` diff --git a/docs/install/amazon.rst b/docs/install/amazon.rst deleted file mode 100644 index 4dbe054..0000000 --- a/docs/install/amazon.rst +++ /dev/null @@ -1,269 +0,0 @@ -.. _install/amazon: - -================================= -Installing on Amazon Web Services -================================= - -Goal -==== - -To have a JupyterHub with admin users and a user environment with conda / pip packages. - -Prerequisites -============= - -#. An Amazon Web Services account. - - If asked to choose a default region, choose the one closest to the majority - of your users. - -Step 1: Installing The Littlest JupyterHub -========================================== - -Let's create the server on which we can run JupyterHub. - -#. Go to `Amazon Web Services `_ and click the gold - button 'Sign In to the Console' in the upper right. Log in with your Amazon Web - Services account. - - If you need to adjust your region from your default, there is a drop-down - menu between your name and the **Support** menu on the far right of the dark - navigation bar across the top of the window. Adjust the region to match the - closest one to the majority of your users. - -#. On the screen listing all the available services, pick **EC2** under **Compute** - on the left side at the top of the first column. - - .. image:: ../images/providers/amazon/compute_services.png - :alt: Select EC2 - - This will take you to the **EC2 Management Console**. - -#. From the navigation menu listing on the far left side of the **EC2 Management - Console**, choose **Instances** under the light gray **INSTANCES** sub-heading. - - .. image:: ../images/providers/amazon/instances_from_console.png - :alt: Select Instances from console - -#. In the main window of the **EC2 Management Console**, towards the top left, - click on the bright blue **Launch Instance** button. - - .. image:: ../images/providers/amazon/launch_instance_button.png - :alt: Click launch instance - - This will start the 'launch instance wizard' process. This lets you customize - the kind of server you want, the resources it will have and its name. - - -#. On the page **Step 1: Choose an Amazon Machine Image (AMI)** you are going - to pick the base image your remote server will have. The view will - default to the 'Quick-start' tab selected and just a few down the page, select - **Ubuntu Server 22.04 LTS (HVM), SSD Volume Type - ami-XXXXXXXXXXXXXXXXX**, - leaving `64-bit (x86)` toggled. - - .. image:: ../images/providers/amazon/select_ubuntu_18.png - :alt: Click Ubuntu server 22.04 - - The `ami` alpha-numeric at the end references the specific Amazon machine - image, ignore this as Amazon updates them routinely. The - **Ubuntu Server 22.04 LTS (HVM)** is the important part. - - -#. After selecting the AMI, you'll be at **Step 2: Choose an Instance Type**. - - There will be a long listing of the types and numbers of CPUs that Amazon - offers. Select the one you want and then select the button - `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. - We recommend you use a server with at least 2GB of RAM, such as a **t3.small**. - However, if you need to minimise costs you can use a server with **1GB** RAM such as a **t2.micro**, but performance will be limited. - - You may wish to consult the listing `here `_ - because it shows cost per hour. The **On Demand** price is the pertinent cost. - - ``GPU graphics`` and ``GPU compute`` products are also available around half way down the page - -#. Under **Step 3: Configure Instance Details**, scroll to the bottom of the page - and toggle the arrow next to **Advanced Details**. Scroll down to 'User data'. Copy - the text below, and paste it into the **User data** text box. Replace - ```` with the name of the first **admin user** for this - JupyterHub. This admin user can log in after the JupyterHub is set up, and - configure it. **Remember to add your username**! - - .. code-block:: bash - - #!/bin/bash - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --admin - - .. image:: ../images/providers/amazon/script_in_user_data.png - :alt: Install JupyterHub with the script in the User data textbox - - .. note:: - - See :ref:`topic/installer-actions` for a detailed description and - :ref:`topic/customizing-installer` for other options that can be used. - -#. Under **Step 4: Add Storage**, you can change the **size** and **type of your - disk by adjusting the value in **Size (GiB)** and selecting **Volume Type**. - - .. image:: ../images/providers/amazon/change_size_type.png - :alt: Selecting disk size and type - - Check out :ref:`howto/admin/resource-estimation` to help pick - how much Disk space your server needs. - - Hover over the encircled `i` next to **Volume Type** for an explanation of - each. Leaving the default as is is fine. `General Purpose SSD (gp2)` is - recommended for most workloads. With `Provisioned IOPS SSD (io1)` being the - highest-performance SSD volume. Magnetic (standard) is a previous generation - volume and not suited for a hub for multi-users. - - When finished, click **Next: Add Tags** in the bottom right corner. - -#. Under **Step 5: Add Tags**, click **Add Tag** and enter **Name** under the - **Key** field. In the **Value** field in the **Name** row, give your new - server a memorable name that identifies what purpose this JupyterHub will be - used for. - - .. image:: ../images/providers/amazon/name_hub.png - :alt: Use tags to name the hub. - -#. 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 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). - - If you have never used your Amazon account before, you'll have to select - **Create a new security group**. You should give it a disitnguishing name - under **Security group name** - such as `ssh_web` for future reference. If you have, one from before you can - select it and adjust it to have the rules you need, if you prefer. - - The rules will default to include `SSH`. Leave that there, and then click on - the **Add Rule** button. Under **Type** for the new rule, change the field - to **HTTP**. The other boxes will get filled in appropritely. Again, click on - the **Add Rule** button. This time under **Type** for the new rule, change - the field to **HTTPS**. - - The warning is there to remind you this opens things up to some degree but - this is necessary in order to let your users connect. However, this warning - is a good reminder that you should monitor your server to insure it is - available for users who may need it. - - .. image:: ../images/providers/amazon/set_security_groups.png - :alt: Allow HTTP & HTTPS traffic to your server - -#. When the security rules are set, click on the blue button in the bottom - right **Review and Launch**. This will give you a chance to review things - because very soon you'll be launching and start paying for any resources you - use. - - Note that you'll see two HTTP listings and two HTTPS listings under - **Security Groups** even though you only made one for each. This is normal & - necessary to match both IPv4 & IPv6 types of IP addresses. - - When you are happy, press the blue **Launch** button in the bottom right - corner to nearly conclude your journey through the instance launch wizard. - - .. image:: ../images/providers/amazon/finally_launch.png - :alt: Launch your server - -#. In the dialog box that pops up as the last step before launching is - 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 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. - - Download and keep the key pair file unless you are associating one you already - have. - - .. image:: ../images/providers/amazon/create_key_pair.png - :alt: Associate key pair - -#. With the key pair associated, click the **Launch instances** button to - start creating the server that'll run TLJH. - - .. image:: ../images/providers/amazon/launch_now.png - :alt: Trigger actual launch - - -#. Following the launch initiation, you'll be taken to a **Launch Status** - notification screen. You can see more information about the details if you - click on the alphanumeric link to the launching instance following the text, - "`The following instance launches have been initiated:`". - - .. image:: ../images/providers/amazon/launch_status_screen.png - :alt: Launch status notice - -#. That link will take you back to the **EC2 Management Console** with settings - that will limit the view in the console to just that instance. (Delete the - filter in the search bar if you want to see any other instances you may - have.) At first the server will be starting up, and then when the - **Instance state** is green the server is running. - - .. image:: ../images/providers/amazon/running_server.png - :alt: Server is running. - - If you already have instances running in your account, the screen will look - different if you disable that filter. But you want to pay attention to the - row with the name of the server you made. - -#. In a few seconds your server will be created, and you can see the - **Public IP** used to access it in the panel at the bottom of the console. - If it isn't displayed, click on the row for that instance in the console. It - will look like a pattern similar to **12.30.230.127**. - - .. image:: ../images/providers/amazon/public_ip.png - :alt: public IP - -#. The Littlest JupyterHub is now installing in the background on your new - server. It takes around 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 from within a browser. If it has been - 10 minutes, paste the public IP into the URL bar of your browser and hit - return to try to connect. - - Accessing the JupyterHub will fail until the installation is complete, - so be patient. The next step below this one shows the login window you are - expecting to see when trying the URL and things work. - While waiting until the appropriate time to try, another way to check if - things are churning away, is to open the **System Log**. To do this, go to - the **EC2 Management Console** & highlight the instance by clicking on that - row and then right-click **Monitor and troubleshoot** > **Get system log**. - -#. When the Jupyterhub creation process finishes and the hub is ready to show - the login, the **System Log** should look similar to the image below. Scroll to - the bottom of your output from the previous step. - Note the line **Starting TLJH installer**, you may also see **Started jupyterhub.service** - - .. image:: ../images/providers/amazon/completed_system_log.png - :alt: Completed system log - -#. 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 7, 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 diff --git a/docs/install/azure.md b/docs/install/azure.md new file mode 100644 index 0000000..9b66ebb --- /dev/null +++ b/docs/install/azure.md @@ -0,0 +1,218 @@ +(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). + +This tutorial leads you step-by-step for you to manually deploy your own JupyterHub on Azure cloud. + +:::{note} +✨ The `Deploy to Azure button` project allows you to deploy your own JupyterHub with minimal manual configuration steps. The deploy to Azure button allows you to have a vanilla configuration in just one-click and by assigning some variables. + +Check it out at [https://github.com/trallard/TLJH-azure-button](https://github.com/trallard/TLJH-azure-button). +::: + +## 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 start by creating the Virtual Machine in which we can run TLJH (The Littlest JupyterHub). + +1. Go to [Azure portal](https://portal.azure.com/) and login with your Azure account. +2. Expand the left-hand panel by clicking on the ">>" button on the top left corner of your dashboard. Find the Virtual Machines tab and click on it. + +```{image} ../images/providers/azure/azure-vms.png +:alt: Virtual machines on Azure portal +``` + +1. 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 screen. +A new screen with all the options for Virtual Machines in Azure will displayed. + +> ```{image} ../images/providers/azure/create-vm.png +> :alt: Create VM from the marketplace +> ``` + +1. **Choose an Ubuntu server for your VM**: + : - Click `Ubuntu Server 22.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 + ``` + +2. 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 keep your Azure tools/resources together in an availability region (e.g. WestEurope). If you already have one you'd like to use it select that resource. + + :::{note} + If you have never created a Resource Group, click on **Create new** + ::: + + ```{image} ../images/providers/azure/new-rg.png + :alt: Create a new resource group + ``` + + - **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 22.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. + +3. 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)). + +4. Disks (Storage): + : - **Disk options**: select 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/create-disk.png + :alt: Create and attach disk + ``` + + ```{image} ../images/providers/azure/disk-vm.png + :alt: Choose a disk size + ``` + +5. 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 + ``` + +6. 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 + ``` + +7. 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: + + ```bash + #!/bin/bash + curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin + ``` + + 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. + ::: + +8. Check the summary and confirm the creation of your Virtual Machine. + +9. 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 + ``` + +10. 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. + +11. Click on the **Go to resource button** + : ```{image} ../images/providers/azure/goto-vm.png + :alt: Go to VM + + ``` + + ``` + +12. Check if the installation is completed 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. + +13. When the installation is complete, it should give you a JupyterHub login page. + + > ```{image} ../images/first-login.png + > :alt: JupyterHub log-in page + > ``` + +14. 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. + +15. Congratulations, you have a running working JupyterHub! 🎉 + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` diff --git a/docs/install/azure.rst b/docs/install/azure.rst deleted file mode 100644 index 68f29c5..0000000 --- a/docs/install/azure.rst +++ /dev/null @@ -1,192 +0,0 @@ -.. _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 `_. - -This tutorial leads you step-by-step for you to manually deploy your own JupyterHub on Azure cloud. - -.. note:: ✨ The ``Deploy to Azure button`` project allows you to deploy your own JupyterHub with minimal manual configuration steps. The deploy to Azure button allows you to have a vanilla configuration in just one-click and by assigning some variables. - - Check it out at `https://github.com/trallard/TLJH-azure-button `_. - -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 `_) - -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 `_. - - -Step 1: Installing The Littlest JupyterHub -========================================== - -We start by creating the Virtual Machine in which we can run TLJH (The Littlest JupyterHub). - -#. Go to `Azure portal `_ and login with your Azure account. -#. Expand the left-hand panel by clicking on the ">>" button on the top left corner of your dashboard. 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 screen. -A new screen with all the options for Virtual Machines in Azure will displayed. - - .. image:: ../images/providers/azure/create-vm.png - :alt: Create VM from the marketplace - -#. **Choose an Ubuntu server for your VM**: - * Click `Ubuntu Server 22.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 keep your Azure tools/resources together in an availability region (e.g. WestEurope). If you already have one you'd like to use it select that resource. - - .. note:: If you have never created a Resource Group, click on **Create new** - - .. image:: ../images/providers/azure/new-rg.png - :alt: Create a new resource group - - * **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 22.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 `_ - - * Select a suitable image (to check available images and prices in your region `click on this link `_). - -#. Disks (Storage): - * **Disk options**: select 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/create-disk.png - :alt: Create and attach disk - - .. image:: ../images/providers/azure/disk-vm.png - :alt: Choose a 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 -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --admin - - 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 completed 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 diff --git a/docs/install/custom-server.md b/docs/install/custom-server.md new file mode 100644 index 0000000..df64e65 --- /dev/null +++ b/docs/install/custom-server.md @@ -0,0 +1,95 @@ +(install-custom)= + +# 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} +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 + +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 +a server you have access to. + +## Pre-requisites + +1. Some familiarity with the command line. +2. A server running Ubuntu 20.04+ where you have root access (Ubuntu 22.04 LTS recommended). +3. At least **1GB** of RAM on your server. +4. Ability to `ssh` into the server & run commands from the prompt. +5. An **IP address** where the server can be reached from the browsers of your target audience. + +If you run into issues, look at the specific {ref}`troubleshooting guide ` +for custom server installations. + +## Step 1: Installing The Littlest JupyterHub + +1. Using a terminal program, SSH into your server. This should give you a prompt where you can + type commands. + +2. Make sure you have `python3`, `python3-dev`, `curl` and `git` installed. + + ``` + sudo apt install python3 python3-dev git curl + ``` + +3. Copy the text below, and paste it into the terminal. Replace + `` with the name of the first **admin user** for this + 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**! + + ```bash + curl -L https://tljh.jupyter.org/bootstrap.py | sudo -E 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. + ::: + +4. Press `Enter` to start the installation process. This will take 5-10 minutes, + and will say `Done!` when the installation process is complete. + +5. Copy the **Public IP** of your server, and try accessing `http://` from + your browser. If everything went well, this should give you a JupyterHub login page. + + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` + +6. Login using the **admin user name** you used in step 3. You can choose any + password that you wish. Use a + strong password & note it down somewhere, since this will be the password for + the admin user account from now on. + +7. Congratulations, you have a running working JupyterHub! + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` + +## Step 4: Setup HTTPS + +Once you are ready to run your server for real, and have a domain, it's a good +idea to proceed directly to {ref}`howto/admin/https`. diff --git a/docs/install/custom-server.rst b/docs/install/custom-server.rst deleted file mode 100644 index 3e60c74..0000000 --- a/docs/install/custom-server.rst +++ /dev/null @@ -1,99 +0,0 @@ -.. _install/custom: - -============================= -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:: - - 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 -==== - -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 -a server you have access to. - -Pre-requisites -============== - -#. Some familiarity with the command line. -#. A server running Ubuntu 20.04+ where you have root access (Ubuntu 22.04 LTS recommended). -#. At least **1GB** of RAM on your server. -#. Ability to ``ssh`` into the server & run commands from the prompt. -#. An **IP address** where the server can be reached from the browsers of your target audience. - -If you run into issues, look at the specific :ref:`troubleshooting guide ` -for custom server installations. - -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``, ``python3-dev``, ``curl`` and ``git`` installed. - - .. code:: - - sudo apt install python3 python3-dev git curl - -#. Copy the text below, and paste it into the terminal. Replace - ```` with the name of the first **admin user** for this - 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**! - - .. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py | sudo -E 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. - -#. Copy the **Public IP** of your server, and try accessing ``http://`` from - your browser. If everything went well, this 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 3. You can choose any - password that you wish. 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 - -Step 4: Setup HTTPS -=================== - -Once you are ready to run your server for real, and have a domain, it's a good -idea to proceed directly to :ref:`howto/admin/https`. diff --git a/docs/install/digitalocean.md b/docs/install/digitalocean.md new file mode 100644 index 0000000..03b49e2 --- /dev/null +++ b/docs/install/digitalocean.md @@ -0,0 +1,123 @@ +(insatll-digitalocean)= + +# Installing on Digital Ocean + +## 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 +[DigitalOcean](https://digitalocean.com). + +## Pre-requisites + +1. A DigitalOcean account with a payment method attached. + +## Step 1: Installing The Littlest JupyterHub + +Let's create the server on which we can run JupyterHub. + +1. Log in to [DigitalOcean](https://digitalocean.com). You might need to + attach a credit card or other payment method to your account before you + can proceed with the tutorial. + +2. Click the **Create** button on the top right, and select **Droplets** from + the dropdown menu. DigitalOcean calls servers **droplets**. + + ```{image} ../images/providers/digitalocean/create-menu.png + :alt: Dropdown menu on clicking 'create' in top right corner + ``` + + This takes you to a page titled **Create Droplets** that lets you configure + your server. + +3. Under **Choose an image**, select **22.04 x64** under **Ubuntu**. + + ```{image} ../images/providers/digitalocean/select-image.png + :alt: Select 22.04 x64 image under Ubuntu + ``` + +4. Under **Choose a size**, select the size of the server you want. The default + (4GB RAM, 2CPUs, 20 USD / month) is not a bad start. You can resize your server + later if you need. + + Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + how much Memory, CPU & disk space your server needs. + +5. Scroll down to **Select additional options**, and select **User data**. + + ```{image} ../images/providers/digitalocean/additional-options.png + :alt: Turn on User Data in additional options + ``` + + This opens up a textbox where you can enter a script that will be run + when the server is created. We will use this to set up The Littlest JupyterHub + on this server. + +6. Copy the text below, and paste it into the user data text box. Replace + `` 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**! + + ```bash + #!/bin/bash + curl -L https://tljh.jupyter.org/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. + ::: + +7. Under the **Finalize and create** section, enter a `hostname` that descriptively + identifies this server for you. + + ```{image} ../images/providers/digitalocean/hostname.png + :alt: Select suitable hostname for your server + ``` + +8. Click the **Create** button! You will be taken to a different screen, + where you can see progress of your server being created. + + ```{image} ../images/providers/digitalocean/server-create-wait.png + :alt: Server being created + ``` + +9. In a few seconds your server will be created, and you can see the **public IP** + used to access it. + + ```{image} ../images/providers/digitalocean/server-create-done.png + :alt: Server finished creating, public IP available + ``` + +10. The Littlest JupyterHub is now installing in the background on your new server. + It takes around 5-10 minutes for this installation to complete. + +11. 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. + +12. When the installation is complete, it should give you a JupyterHub login page. + + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` + +13. 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. + +14. Congratulations, you have a running working JupyterHub! + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` diff --git a/docs/install/digitalocean.rst b/docs/install/digitalocean.rst deleted file mode 100644 index cbf40d5..0000000 --- a/docs/install/digitalocean.rst +++ /dev/null @@ -1,119 +0,0 @@ -.. _insatll/digitalocean: - -=========================== -Installing on Digital Ocean -=========================== - -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 -`DigitalOcean `_. - -Pre-requisites -============== - -#. A DigitalOcean account with a payment method attached. - -Step 1: Installing The Littlest JupyterHub -========================================== - -Let's create the server on which we can run JupyterHub. - -#. Log in to `DigitalOcean `_. You might need to - attach a credit card or other payment method to your account before you - can proceed with the tutorial. - -#. Click the **Create** button on the top right, and select **Droplets** from - the dropdown menu. DigitalOcean calls servers **droplets**. - - .. image:: ../images/providers/digitalocean/create-menu.png - :alt: Dropdown menu on clicking 'create' in top right corner - - This takes you to a page titled **Create Droplets** that lets you configure - your server. - -#. Under **Choose an image**, select **22.04 x64** under **Ubuntu**. - - .. image:: ../images/providers/digitalocean/select-image.png - :alt: Select 22.04 x64 image under Ubuntu - -#. Under **Choose a size**, select the size of the server you want. The default - (4GB RAM, 2CPUs, 20 USD / month) is not a bad start. You can resize your server - later if you need. - - Check out our guide on How To :ref:`howto/admin/resource-estimation` to help pick - how much Memory, CPU & disk space your server needs. - -#. Scroll down to **Select additional options**, and select **User data**. - - .. image:: ../images/providers/digitalocean/additional-options.png - :alt: Turn on User Data in additional options - - This opens up a textbox where you can enter a script that will be run - when the server is created. We will use this to set up The Littlest JupyterHub - on this server. - -#. Copy the text below, and paste it into the user data text box. Replace - ```` 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 -L https://tljh.jupyter.org/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. - - .. image:: ../images/providers/digitalocean/hostname.png - :alt: Select suitable hostname for your server - -#. Click the **Create** button! You will be taken to a different screen, - where you can see progress of your server being created. - - .. image:: ../images/providers/digitalocean/server-create-wait.png - :alt: Server being created - -#. In a few seconds your server will be created, and you can see the **public IP** - used to access it. - - .. image:: ../images/providers/digitalocean/server-create-done.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 diff --git a/docs/install/google.md b/docs/install/google.md new file mode 100644 index 0000000..60c68a7 --- /dev/null +++ b/docs/install/google.md @@ -0,0 +1,219 @@ +(install-google)= + +# Installing on Google Cloud + +## 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 +[Google Cloud](https://cloud.google.com/). + +## Prerequisites + +1. A Google Cloud account. You might use the free credits for trying it out! + +## Step 1: Installing The Littlest JupyterHub + +Let's create the server on which we can run JupyterHub. + +1. Log in to [Google Cloud Console](https://console.cloud.google.com) with + your Google Account. + +2. Open the navigation menu by clicking the button with three lines on the top + left corner of the page. + + ```{image} ../images/providers/google/left-menu-button.png + :alt: Button to open the menu + ``` + + This opens a menu with all the cloud products Google Cloud offers. + +3. Under **Compute Engine**, select **VM Instances**. + + ```{image} ../images/providers/google/vm-instances-menu.png + :alt: Navigation Menu -> Compute Engine -> VM Instances + ``` + +4. If you are using Google Cloud for the first time, you might have to + enable billing. Google will present a screen asking you to enable billing + to proceed. Click the **Enable Billing** button and follow any prompts + that appear. + + ```{image} ../images/providers/google/enable-billing.png + :alt: Enable billing if needed. + ``` + + It might take a few minutes for your account to be set up. + +5. Once Compute Engine is ready, click the **Create** button to start + creating the server that'll run TLJH. + + ```{image} ../images/providers/google/create-vm-first.png + :alt: Create VM page when using it for the first time. + ``` + + If you already have VMs running in your project, the screen will look + different. But you can find the **Create** button still! + +6. This shows you a page titled **Create an instance**. This lets you customize + the kind of server you want, the resources it will have & what it'll be called. + +7. Under **Name**, give it a memorable name that identifies what purpose this + JupyterHub will be used for. + +8. **Region** specifies the physical location where this server will be hosted. + Generally, pick something close to where most of your users are. Note that + it might increase the cost of your server in some cases! + +9. For **Zone**, pick any of the options. Leaving the default as is is fine. + +10. Under **Machine** type, select the amount of CPU / RAM / GPU you want for your + server. You need at least **1GB** of RAM. + + You can select a preset combination in the default **basic view**. + + ```{image} ../images/providers/google/machine-type-basic.png + :alt: Select a preset VM type + ``` + + If you want to add **GPUs**, you should click the **Customize** button & + use the **Advanced View**. You need to request [a quota increase](https://cloud.google.com/compute/quotas#gpus) + before you can use GPUs. + + ```{image} ../images/providers/google/machine-type-advanced.png + :alt: Select a customized VM size + ``` + + Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + how much Memory / CPU your server needs. + +11. Under **Boot Disk**, click the **Change** button. This lets us change the + operating system and the size of your disk. + + ```{image} ../images/providers/google/boot-disk-button.png + :alt: Changing Boot Disk & disk size + ``` + + This should open a **Boot disk** popup. + +12. Select **Ubuntu 22.04 LTS** from the list of operating system images. + + ```{image} ../images/providers/google/boot-disk-ubuntu.png + :alt: Selecting Ubuntu 22.04 for OS + ``` + +13. You can also change the **type** and **size** of your disk at the bottom + of this popup. + + ```{image} ../images/providers/google/boot-disk-size.png + :alt: Selecting Boot disk type & size + ``` + + **Standard persistent disk** type gives you a slower but cheaper disk, similar + to a hard drive. **SSD persistent disk** gives you a faster but more expensive + disk, similar to an SSD. + + Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + how much Disk space your server needs. + +14. Click the **Select** button to dismiss the Boot disk popup and go back to the + Create an instance screen. + +15. Under **Identity and API access**, select **No service account** for the + **Service account** field. This prevents your JupyterHub users from automatically + accessing other cloud services, increasing security. + + ```{image} ../images/providers/google/no-service-account.png + :alt: Disable service accounts for the server + ``` + +16. Under **Firewall**, check both **Allow HTTP Traffic** and **Allow HTTPS Traffic** + checkboxes. + + ```{image} ../images/providers/google/firewall.png + :alt: Allow HTTP & HTTPS traffic to your server + ``` + +17. Click the **Management, disks, networking, SSH keys** link to expand more + options. + + ```{image} ../images/providers/google/management-button.png + :alt: Expand management options by clicking link. + ``` + + This displays a lot of advanced options, but we'll be only using one of them. + +18. Copy the text below, and paste it into the **Startup script** text box. Replace + `` 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**! + + ```bash + #!/bin/bash + curl -L https://tljh.jupyter.org/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. + ::: + +19. Click the **Create** button at the bottom to start your server! + + ```{image} ../images/providers/google/create-vm-button.png + :alt: Launch an Instance / Advanced Options dialog box + ``` + +20. We'll be sent to the **VM instances** page, where we can see that our server + is being created. + + ```{image} ../images/providers/google/vm-creating.png + :alt: Spinner with vm creating + ``` + +21. In a few seconds your server will be created, and you can see the **External IP** + used to access it. + + ```{image} ../images/providers/google/vm-created.png + :alt: VM created, external IP available + ``` + +22. The Littlest JupyterHub is now installing in the background on your new server. + It takes around 5-10 minutes for this installation to complete. + +23. Check if the installation is complete by **copying** the **External IP** + of your server, and trying to access it with a browser. Do **not click** on the + IP - this will open the link with HTTPS, and will not work. + + Accessing the JupyterHub will also fail until the installation is complete, + so be patient. + +24. When the installation is complete, it should give you a JupyterHub login page. + + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` + +25. 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. + +26. Congratulations, you have a running working JupyterHub! + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` diff --git a/docs/install/google.rst b/docs/install/google.rst deleted file mode 100644 index d6e38be..0000000 --- a/docs/install/google.rst +++ /dev/null @@ -1,205 +0,0 @@ -.. _install/google: - -========================== -Installing on Google Cloud -========================== - -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 -`Google Cloud `_. - -Prerequisites -============= - -#. A Google Cloud account. You might use the free credits for trying it out! - -Step 1: Installing The Littlest JupyterHub -========================================== - -Let's create the server on which we can run JupyterHub. - -#. Log in to `Google Cloud Console `_ with - your Google Account. - -#. Open the navigation menu by clicking the button with three lines on the top - left corner of the page. - - .. image:: ../images/providers/google/left-menu-button.png - :alt: Button to open the menu - - This opens a menu with all the cloud products Google Cloud offers. - -#. Under **Compute Engine**, select **VM Instances**. - - .. image:: ../images/providers/google/vm-instances-menu.png - :alt: Navigation Menu -> Compute Engine -> VM Instances - -#. If you are using Google Cloud for the first time, you might have to - enable billing. Google will present a screen asking you to enable billing - to proceed. Click the **Enable Billing** button and follow any prompts - that appear. - - .. image:: ../images/providers/google/enable-billing.png - :alt: Enable billing if needed. - - It might take a few minutes for your account to be set up. - -#. Once Compute Engine is ready, click the **Create** button to start - creating the server that'll run TLJH. - - .. image:: ../images/providers/google/create-vm-first.png - :alt: Create VM page when using it for the first time. - - If you already have VMs running in your project, the screen will look - different. But you can find the **Create** button still! - -#. This shows you a page titled **Create an instance**. This lets you customize - the kind of server you want, the resources it will have & what it'll be called. - -#. Under **Name**, give it a memorable name that identifies what purpose this - JupyterHub will be used for. - -#. **Region** specifies the physical location where this server will be hosted. - Generally, pick something close to where most of your users are. Note that - it might increase the cost of your server in some cases! - -#. 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. You need at least **1GB** of RAM. - - You can select a preset combination in the default **basic view**. - - .. image:: ../images/providers/google/machine-type-basic.png - :alt: Select a preset VM type - - If you want to add **GPUs**, you should click the **Customize** button & - use the **Advanced View**. You need to request `a quota increase `_ - before you can use GPUs. - - .. image:: ../images/providers/google/machine-type-advanced.png - :alt: Select a customized VM size - - Check out our guide on How To :ref:`howto/admin/resource-estimation` to help pick - how much Memory / CPU your server needs. - -#. Under **Boot Disk**, click the **Change** button. This lets us change the - operating system and the size of your disk. - - .. image:: ../images/providers/google/boot-disk-button.png - :alt: Changing Boot Disk & disk size - - This should open a **Boot disk** popup. - -#. Select **Ubuntu 22.04 LTS** from the list of operating system images. - - .. image:: ../images/providers/google/boot-disk-ubuntu.png - :alt: Selecting Ubuntu 22.04 for OS - -#. You can also change the **type** and **size** of your disk at the bottom - of this popup. - - .. image:: ../images/providers/google/boot-disk-size.png - :alt: Selecting Boot disk type & size - - **Standard persistent disk** type gives you a slower but cheaper disk, similar - to a hard drive. **SSD persistent disk** gives you a faster but more expensive - disk, similar to an SSD. - - Check out our guide on How To :ref:`howto/admin/resource-estimation` to help pick - how much Disk space your server needs. - -#. Click the **Select** button to dismiss the Boot disk popup and go back to the - Create an instance screen. - -#. Under **Identity and API access**, select **No service account** for the - **Service account** field. This prevents your JupyterHub users from automatically - accessing other cloud services, increasing security. - - .. image:: ../images/providers/google/no-service-account.png - :alt: Disable service accounts for the server - -#. Under **Firewall**, check both **Allow HTTP Traffic** and **Allow HTTPS Traffic** - checkboxes. - - .. image:: ../images/providers/google/firewall.png - :alt: Allow HTTP & HTTPS traffic to your server - -#. Click the **Management, disks, networking, SSH keys** link to expand more - options. - - .. image:: ../images/providers/google/management-button.png - :alt: Expand management options by clicking link. - - This displays a lot of advanced options, but we'll be only using one of them. - -#. Copy the text below, and paste it into the **Startup script** text box. Replace - ```` 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 -L https://tljh.jupyter.org/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 - :alt: Launch an Instance / Advanced Options dialog box - -#. We'll be sent to the **VM instances** page, where we can see that our server - is being created. - - .. image:: ../images/providers/google/vm-creating.png - :alt: Spinner with vm creating - -#. In a few seconds your server will be created, and you can see the **External IP** - used to access it. - - .. image:: ../images/providers/google/vm-created.png - :alt: VM created, external 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 **External IP** - of your server, and trying to access it with a browser. Do **not click** on the - IP - this will open the link with HTTPS, and will not work. - - Accessing the JupyterHub will also 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 diff --git a/docs/install/index.rst b/docs/install/index.md similarity index 71% rename from docs/install/index.rst rename to docs/install/index.md index f53b0da..5f4ec71 100644 --- a/docs/install/index.rst +++ b/docs/install/index.md @@ -1,8 +1,6 @@ -.. _install/installing: +(install-installing)= -========== -Installing -========== +# Installing The Littlest JupyterHub (TLJH) can run on any server that is running **Debian 11** or **Ubuntu 20.04** or **22.04** on a amd64 or arm64 CPU architecture. Earlier versions of Ubuntu and Debian are not supported, nor are other Linux distributions. @@ -12,13 +10,14 @@ 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. - .. toctree:: - :titlesonly: - - digitalocean - ovh - jetstream - google - amazon - azure - custom-server +> ```{toctree} +> :titlesonly: true +> +> digitalocean +> ovh +> jetstream +> google +> amazon +> azure +> custom-server +> ``` diff --git a/docs/install/jetstream.md b/docs/install/jetstream.md new file mode 100644 index 0000000..d0ad7e3 --- /dev/null +++ b/docs/install/jetstream.md @@ -0,0 +1,152 @@ +(install-jetstream)= + +# Installing on Jetstream + +## 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 +[Jetstream](https://jetstream-cloud.org/). + +## Prerequisites + +1. A Jetstream account with an XSEDE allocation; for more information, + see the [Jetstream Allocations help page](http://wiki.jetstream-cloud.org/Jetstream+Allocations). + +## Step 1: Installing The Littlest JupyterHub + +Let's create the server on which we can run JupyterHub. + +1. Log in to [the Jetstream portal](https://use.jetstream-cloud.org/). You need an allocation + to launch instances. + +2. Select the **Launch New Instance** option to get going. + + ```{image} ../images/providers/jetstream/launch-instance-first-button.png + :alt: Launch new instance button with description. + ``` + + This takes you to a page with a list of base images you can choose for your + server. + +3. Under **Image Search**, search for **Ubuntu 22.04**, and select the + **Ubuntu 22.04 Devel and Docker** image. + + ```{image} ../images/providers/jetstream/select-image.png + :alt: Select Ubuntu 22.04 x64 image from image list + ``` + +4. Once selected, you will see more information about this image. Click the + **Launch** button on the top right. + + ```{image} ../images/providers/jetstream/launch-instance-second-button.png + :alt: Launch selected image with Launch button on top right + ``` + +5. A dialog titled **Launch an Instance / Basic Options** pops up, with various + options for configuring your instance. + + ```{image} ../images/providers/jetstream/launch-instance-dialog.png + :alt: Launch an Instance / Basic Options dialog box + ``` + + 1. Give your server a descriptive **Instance Name**. + + 2. Select an appropriate **Instance Size**. We suggest m1.medium or larger. + Make sure your instance has at least **1GB** 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. + + 3. If you have multiple allocations, make sure you are 'charging' this server + to the correct allocation. + +6. Click the **Advanced Options** link in the bottom left of the popup. This + lets us configure what the server should do when it starts up. We will use + this to install The Littlest JupyterHub. + + A dialog titled **Launch an Instance / Advanced Options** should pop up. + + ```{image} ../images/providers/jetstream/add-deployment-script-dialog.png + :alt: Dialog box allowing you to add a new script. + ``` + +7. Click the **Create New Script** button. This will open up another dialog + box! + + ```{image} ../images/providers/jetstream/create-script-dialog.png + :alt: Launch an Instance / Advanced Options dialog box + ``` + +8. Under **Input Type**, select **Raw Text**. This should make a text box titled + **Raw Text** visible on the right side of the dialog box. + Copy the text below, and paste it into the **Raw Text** text box. Replace + `` 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**! + + ```bash + #!/bin/bash + curl -L https://tljh.jupyter.org/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. + ::: + +9. Under **Execution Strategy Type**, select **Run script on first boot**. + +10. Under **Deployment Type**, select **Wait for script to complete**. + +11. Click the **Save and Add Script** button on the bottom right. This should hide + the dialog box. + +12. Click the **Continue to Launch** button on the bottom right. This should put you + back in the **Launch an Instance / Basic Options** dialog box again. + +13. Click the **Launch Instance** button on the bottom right. This should turn it + into a spinner, and your server is getting created! + + ```{image} ../images/providers/jetstream/launching-spinner.png + :alt: Launch button turns into a spinner + ``` + +14. You'll now be shown a dashboard with all your servers and their states. The + server you just launched will progress through various stages of set up, + and you can see the progress here. + + ```{image} ../images/providers/jetstream/deployment-in-progress.png + :alt: Instances dashboard showing deployment in progress. + ``` + +15. It will take about ten minutes for your server to come up. The status will + say **Active** and the progress bar will be a solid green. At this point, + your JupyterHub is ready for use! + +16. Copy the **IP Address** of your server, and try accessing it from a web + browser. It should give you a JupyterHub login page. + + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` + +17. Login using the **admin user name** you used in step 8, 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. + +18. Congratulations, you have a running working JupyterHub! + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` diff --git a/docs/install/jetstream.rst b/docs/install/jetstream.rst deleted file mode 100644 index e9e55ad..0000000 --- a/docs/install/jetstream.rst +++ /dev/null @@ -1,145 +0,0 @@ -.. _install/jetstream: - -======================= -Installing on Jetstream -======================= - -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 -`Jetstream `_. - -Prerequisites -============= - -#. A Jetstream account with an XSEDE allocation; for more information, - see the `Jetstream Allocations help page `_. - -Step 1: Installing The Littlest JupyterHub -========================================== - -Let's create the server on which we can run JupyterHub. - -#. Log in to `the Jetstream portal `_. You need an allocation - to launch instances. - -#. Select the **Launch New Instance** option to get going. - - .. image:: ../images/providers/jetstream/launch-instance-first-button.png - :alt: Launch new instance button with description. - - This takes you to a page with a list of base images you can choose for your - server. - -#. Under **Image Search**, search for **Ubuntu 22.04**, and select the - **Ubuntu 22.04 Devel and Docker** image. - - .. image:: ../images/providers/jetstream/select-image.png - :alt: Select Ubuntu 22.04 x64 image from image list - -#. Once selected, you will see more information about this image. Click the - **Launch** button on the top right. - - .. image:: ../images/providers/jetstream/launch-instance-second-button.png - :alt: Launch selected image with Launch button on top right - -#. A dialog titled **Launch an Instance / Basic Options** pops up, with various - options for configuring your instance. - - .. image:: ../images/providers/jetstream/launch-instance-dialog.png - :alt: Launch an Instance / Basic Options dialog box - - #. 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 **1GB** 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. - - #. If you have multiple allocations, make sure you are 'charging' this server - to the correct allocation. - -#. Click the **Advanced Options** link in the bottom left of the popup. This - lets us configure what the server should do when it starts up. We will use - this to install The Littlest JupyterHub. - - A dialog titled **Launch an Instance / Advanced Options** should pop up. - - .. image:: ../images/providers/jetstream/add-deployment-script-dialog.png - :alt: Dialog box allowing you to add a new script. - -#. Click the **Create New Script** button. This will open up another dialog - box! - - .. image:: ../images/providers/jetstream/create-script-dialog.png - :alt: Launch an Instance / Advanced Options dialog box - -#. Under **Input Type**, select **Raw Text**. This should make a text box titled - **Raw Text** visible on the right side of the dialog box. - Copy the text below, and paste it into the **Raw Text** text box. Replace - ```` 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 -L https://tljh.jupyter.org/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**. - -#. Under **Deployment Type**, select **Wait for script to complete**. - -#. Click the **Save and Add Script** button on the bottom right. This should hide - the dialog box. - -#. Click the **Continue to Launch** button on the bottom right. This should put you - back in the **Launch an Instance / Basic Options** dialog box again. - -#. Click the **Launch Instance** button on the bottom right. This should turn it - into a spinner, and your server is getting created! - - .. image:: ../images/providers/jetstream/launching-spinner.png - :alt: Launch button turns into a spinner - -#. You'll now be shown a dashboard with all your servers and their states. The - server you just launched will progress through various stages of set up, - and you can see the progress here. - - .. image:: ../images/providers/jetstream/deployment-in-progress.png - :alt: Instances dashboard showing deployment in progress. - -#. It will take about ten minutes for your server to come up. The status will - say **Active** and the progress bar will be a solid green. At this point, - your JupyterHub is ready for use! - -#. Copy the **IP Address** of your server, and try accessing it from a web - browser. 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 8, 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 diff --git a/docs/install/ovh.md b/docs/install/ovh.md new file mode 100644 index 0000000..74d8dc6 --- /dev/null +++ b/docs/install/ovh.md @@ -0,0 +1,133 @@ +(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 + +1. An OVH account. + +## Step 1: Installing The Littlest JupyterHub + +Let's create the server on which we can run JupyterHub. + +1. Log in to the [OVH Control Panel](https://www.ovh.com/auth/). + +2. Click the **Public Cloud** button in the navigation bar. + + ```{image} ../images/providers/ovh/public-cloud.png + :alt: Public Cloud entry in the navigation bar + ``` + +3. 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 + ``` + +4. Select a name for the project: + + ```{image} ../images/providers/ovh/project-name.png + :alt: Select a name for the project + ``` + +5. 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 + ``` + +6. Using the **Public Cloud interface**, click on **Create an instance**: + + ```{image} ../images/providers/ovh/create-instance.png + :alt: Create a new instance + ``` + +7. **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. + +8. **Select a region**. + +9. Select **Ubuntu 22.04** as the image: + + ```{image} ../images/providers/ovh/distribution.png + :alt: Select Ubuntu 22.04 as the image + ``` + +10. 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. + +11. 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 `` 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**! + + ```bash + #!/bin/bash + curl -L https://tljh.jupyter.org/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. + ::: + + ```{image} ../images/providers/ovh/configuration.png + :alt: Add post-installation script + ``` + +12. Select a billing period: monthly or hourly. + +13. 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 + ``` + +14. 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 + ``` + +15. The Littlest JupyterHub is now installing in the background on your new server. + It takes around 5-10 minutes for this installation to complete. + +16. 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. + +17. When the installation is complete, it should give you a JupyterHub login page. + + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` + +18. 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. + +19. Congratulations, you have a running working JupyterHub! + +## Step 2: Adding more users + +```{eval-rst} +.. include:: add_users.txt +``` + +## Step 3: Install conda / pip packages for all users + +```{eval-rst} +.. include:: add_packages.txt +``` diff --git a/docs/install/ovh.rst b/docs/install/ovh.rst deleted file mode 100644 index 33010b4..0000000 --- a/docs/install/ovh.rst +++ /dev/null @@ -1,127 +0,0 @@ -.. _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 `_. - -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 `_. - -#. 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 22.04** as the image: - - .. image:: ../images/providers/ovh/distribution.png - :alt: Select Ubuntu 22.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 `_. - 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 ```` 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 -L https://tljh.jupyter.org/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. - - - .. 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 diff --git a/docs/requirements.txt b/docs/requirements.txt index 2de526f..2cd397c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +myst-parser>=0.19 pydata-sphinx-theme # Sphix 6.0.0 breaks pydata-sphinx-theme # See pydata/pydata-sphinx-theme#1094 diff --git a/docs/topic/authenticator-configuration.md b/docs/topic/authenticator-configuration.md new file mode 100644 index 0000000..3a64a11 --- /dev/null +++ b/docs/topic/authenticator-configuration.md @@ -0,0 +1,89 @@ +(topic-authenticator-configuration)= + +# Configuring JupyterHub authenticators + +Any [JupyterHub authenticator](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators) +can be used with TLJH. A number of them ship by default with TLJH: + +1. [OAuthenticator](https://github.com/jupyterhub/oauthenticator) - Google, GitHub, CILogon, + GitLab, Globus, Mediawiki, auth0, generic OpenID connect (for KeyCloak, etc) and other + OAuth based authentication methods. +2. [LDAPAuthenticator](https://github.com/jupyterhub/ldapauthenticator) - LDAP & Active Directory. +3. [DummyAuthenticator](https://github.com/yuvipanda/jupyterhub-dummy-authenticator) - Any username, + one shared password. A {ref}`how-to guide on using DummyAuthenticator ` is also + available. +4. [FirstUseAuthenticator](https://github.com/yuvipanda/jupyterhub-firstuseauthenticator) - Users set + their password when they log in for the first time. Default authenticator used in TLJH. +5. [TmpAuthenticator](https://github.com/jupyterhub/tmpauthenticator) - Opens the JupyterHub to the + world, makes a new user every time someone logs in. +6. [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 +everything, this guide shows you how to use any authenticator you want with JupyterHub by following +the authenticator's documentation. + +## Setting authenticator properties + +JupyterHub authenticators are customized by setting _traitlet properties_. In the authenticator's +documentation, you will find these are usually represented as: + +```python +c.. = +``` + +You can set these with `tljh-config` with: + +```bash +sudo tljh-config set auth.. +``` + +### Example + +[LDAPAuthenticator's documentation](https://github.com/jupyterhub/ldapauthenticator#required-configuration) +lists the various configuration options you can set for LDAPAuthenticator. +When the documentation asks you to set `LDAPAuthenticator.server_address` +to some value, you can do that with the following command: + +```bash +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. + +## Enabling the authenticator + +Once you have configured the authenticator as you want, you should then +enable it. Usually, the documentation for the authenticator would ask you to add +something like the following to your `jupyterhub_config.py` to enable it: + +```python +c.JupyterHub.authenticator_class = 'fully-qualified-authenticator-name' +``` + +You can accomplish the same with `tljh-config`: + +```bash +sudo tljh-config set auth.type +``` + +Once enabled, you need to reload JupyterHub for the config to take effect. + +```bash +sudo tljh-config reload +``` + +Try logging in a separate incognito window to check if your configuration works. This +lets you preserve your terminal in case there were errors. If there are +errors, {ref}`troubleshooting/logs` should help you debug them. + +### Example + +From the [documentation](https://github.com/jupyterhub/ldapauthenticator#usage) for +LDAPAuthenticator, we see that the fully qualified name is `ldapauthenticator.LDAPAuthenticator`. +Assuming you have already configured it, the following commands enable LDAPAuthenticator. + +```bash +sudo tljh-config set auth.type ldapauthenticator.LDAPAuthenticator +sudo tljh-config reload +``` diff --git a/docs/topic/authenticator-configuration.rst b/docs/topic/authenticator-configuration.rst deleted file mode 100644 index d9182c7..0000000 --- a/docs/topic/authenticator-configuration.rst +++ /dev/null @@ -1,95 +0,0 @@ -.. _topic/authenticator-configuration: - -===================================== -Configuring JupyterHub authenticators -===================================== - -Any `JupyterHub authenticator `_ -can be used with TLJH. A number of them ship by default with TLJH: - -#. `OAuthenticator `_ - Google, GitHub, CILogon, - GitLab, Globus, Mediawiki, auth0, generic OpenID connect (for KeyCloak, etc) and other - OAuth based authentication methods. -#. `LDAPAuthenticator `_ - LDAP & Active Directory. -#. `DummyAuthenticator `_ - Any username, - one shared password. A :ref:`how-to guide on using DummyAuthenticator ` is also - available. -#. `FirstUseAuthenticator `_ - Users set - their password when they log in for the first time. Default authenticator used in TLJH. -#. `TmpAuthenticator `_ - Opens the JupyterHub to the - world, makes a new user every time someone logs in. -#. `NativeAuthenticator `_ - 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 -everything, this guide shows you how to use any authenticator you want with JupyterHub by following -the authenticator's documentation. - -Setting authenticator properties -================================ - -JupyterHub authenticators are customized by setting *traitlet properties*. In the authenticator's -documentation, you will find these are usually represented as: - -.. code-block:: python - - c.. = - -You can set these with ``tljh-config`` with: - -.. code-block:: bash - - sudo tljh-config set auth.. - -Example -------- - -`LDAPAuthenticator's documentation `_ -lists the various configuration options you can set for LDAPAuthenticator. -When the documentation asks you to set ``LDAPAuthenticator.server_address`` -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' - -Most authenticators require you set multiple configuration options before you can -enable them. Read the authenticator's documentation carefully for more information. - -Enabling the authenticator -========================== - -Once you have configured the authenticator as you want, you should then -enable it. Usually, the documentation for the authenticator would ask you to add -something like the following to your ``jupyterhub_config.py`` to enable it: - -.. code-block:: python - - c.JupyterHub.authenticator_class = 'fully-qualified-authenticator-name' - -You can accomplish the same with ``tljh-config``: - -.. code-block:: bash - - sudo tljh-config set auth.type - -Once enabled, you need to reload JupyterHub for the config to take effect. - -.. code-block:: bash - - sudo tljh-config reload - -Try logging in a separate incognito window to check if your configuration works. This -lets you preserve your terminal in case there were errors. If there are -errors, :ref:`troubleshooting/logs` should help you debug them. - -Example -------- - -From the `documentation `_ for -LDAPAuthenticator, we see that the fully qualified name is ``ldapauthenticator.LDAPAuthenticator``. -Assuming you have already configured it, the following commands enable LDAPAuthenticator. - -.. code-block:: bash - - sudo tljh-config set auth.type ldapauthenticator.LDAPAuthenticator - sudo tljh-config reload diff --git a/docs/topic/customizing-installer.md b/docs/topic/customizing-installer.md new file mode 100644 index 0000000..cfb4ec1 --- /dev/null +++ b/docs/topic/customizing-installer.md @@ -0,0 +1,135 @@ +(topic-customizing-installer)= + +# Customizing the Installer + +The installer can be customized with commandline parameters. The default installer +is executed as: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + +``` + +This page documents the various options you can pass as commandline parameters to the installer. + +(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: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ +| sudo python3 - \ + --admin admin --show-progress-page +``` + +## Adding admin users + +`--admin :` adds user `` to JupyterHub as an admin user +and sets its password to be ``. +Although it is not recommended, it is possible to only set the admin username at this point +and set the admin password after the installation. + +Also, the `--admin` flag can be repeated multiple times. For example, to add `admin-user1` +and `admin-user2` as admins when installing, depending if you would like to set their passwords +during install you would: + +- set `admin-user1` with password `password-user1` and `admin-user2` with `password-user2` using: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin admin-user1:password-user1 --admin admin-user2:password-user2 +``` + +- set `admin-user1` and `admin-user2` to be admins, without any passwords at this stage, using: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin admin-user1 --admin admin-user2 +``` + +- set `admin-user1` with password `password-user1` and `admin-user2` with no password at this stage using: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --admin admin-user1:password-user1 --admin admin-user2 +``` + +## Installing python packages in the user environment + +`--user-requirements-txt-url ` installs packages specified +in the `requirements.txt` located at the given URL into the user environment at install +time. This is very useful when you want to set up a hub with a particular user environment +in one go. + +For example, to install the latest requirements to run UC Berkeley's data8 course +in your new hub, you would run: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --user-requirements-txt-url https://raw.githubusercontent.com/data-8/materials-sp18/HEAD/requirements.txt +``` + +The URL **must** point to a working requirements.txt. If there are any errors, the installation +will fail. + +:::{note} +When pointing to a file on GitHub, make sure to use the 'Raw' version. It should point to +`raw.githubusercontent.com`, not `github.com`. +::: + +## 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 particular class, etc. You can find more information about +writing plugins and a list of existing plugins at {ref}`contributing/plugins`. + +`--plugin ` installs and activates a plugin. You can pass it +however many times you want. Since plugins are distributed as python packages, +`` can be anything that can be passed to `pip install` - +`plugin-name-on-pypi==` and `git+https://github.com/user/repo@tag` +are the most popular ones. Specifying a version or tag is highly recommended. + +For example, to install the PANGEO Plugin version 0.1 (if version 0.1 existed) +in your new TLJH install, you would use: + +```bash +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --plugin git+https://github.com/yuvipanda/tljh-pangeo@v0.1 +``` + +Multiple plugins can be installed at once with: `--plugin `. + +:::{note} +Plugins are extremely powerful and can do a large number of arbitrary things. +Only install plugins you trust. +::: diff --git a/docs/topic/customizing-installer.rst b/docs/topic/customizing-installer.rst deleted file mode 100644 index e3a2b04..0000000 --- a/docs/topic/customizing-installer.rst +++ /dev/null @@ -1,140 +0,0 @@ -.. _topic/customizing-installer: - -========================= -Customizing the Installer -========================= - -The installer can be customized with commandline parameters. The default installer -is executed as: - -.. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - - -This page documents the various options you can pass as commandline parameters to the installer. - -.. _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 --show-progress-page - -Adding admin users -=================== - -``--admin :`` adds user ```` to JupyterHub as an admin user -and sets its password to be ````. -Although it is not recommended, it is possible to only set the admin username at this point -and set the admin password after the installation. - -Also, the ``--admin`` flag can be repeated multiple times. For example, to add ``admin-user1`` -and ``admin-user2`` as admins when installing, depending if you would like to set their passwords -during install you would: - -* set ``admin-user1`` with password ``password-user1`` and ``admin-user2`` with ``password-user2`` using: - -.. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --admin admin-user1:password-user1 --admin admin-user2:password-user2 - -* set ``admin-user1`` and ``admin-user2`` to be admins, without any passwords at this stage, using: - -.. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --admin admin-user1 --admin admin-user2 - -* set ``admin-user1`` with password ``password-user1`` and ``admin-user2`` with no password at this stage using: - -.. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --admin admin-user1:password-user1 --admin admin-user2 - -Installing python packages in the user environment -================================================== - -``--user-requirements-txt-url `` installs packages specified -in the ``requirements.txt`` located at the given URL into the user environment at install -time. This is very useful when you want to set up a hub with a particular user environment -in one go. - -For example, to install the latest requirements to run UC Berkeley's data8 course -in your new hub, you would run: - -.. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --user-requirements-txt-url https://raw.githubusercontent.com/data-8/materials-sp18/HEAD/requirements.txt - -The URL **must** point to a working requirements.txt. If there are any errors, the installation -will fail. - -.. note:: - - When pointing to a file on GitHub, make sure to use the 'Raw' version. It should point to - ``raw.githubusercontent.com``, not ``github.com``. - -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 `_ for earth sciences -research, a stack for a particular class, etc. You can find more information about -writing plugins and a list of existing plugins at :ref:`contributing/plugins`. - -``--plugin `` installs and activates a plugin. You can pass it -however many times you want. Since plugins are distributed as python packages, -```` can be anything that can be passed to ``pip install`` - -``plugin-name-on-pypi==`` and ``git+https://github.com/user/repo@tag`` -are the most popular ones. Specifying a version or tag is highly recommended. - -For example, to install the PANGEO Plugin version 0.1 (if version 0.1 existed) -in your new TLJH install, you would use: - -.. code-block:: bash - - curl -L https://tljh.jupyter.org/bootstrap.py \ - | sudo python3 - \ - --plugin git+https://github.com/yuvipanda/tljh-pangeo@v0.1 - - -Multiple plugins can be installed at once with: ``--plugin ``. - -.. note:: - - Plugins are extremely powerful and can do a large number of arbitrary things. - Only install plugins you trust. diff --git a/docs/topic/escape-hatch.md b/docs/topic/escape-hatch.md new file mode 100644 index 0000000..c55d513 --- /dev/null +++ b/docs/topic/escape-hatch.md @@ -0,0 +1,91 @@ +(topic-escape-hatch)= + +# Custom configuration snippets + +The two main TLJH components are **JupyterHub** and **Traefik**. + +- JupyterHub takes its configuration from the `jupyterhub_config.py` file. +- Traefik loads its: + - [static configuration](https://docs.traefik.io/v1.7/basics/#static-traefik-configuration) + from the `traefik.toml` file. + - [dynamic configuration](https://docs.traefik.io/v1.7/basics/#dynamic-traefik-configuration) + from the `rules` directory. + +The `jupyterhub_config.py` and `traefik.toml` files are created by TLJH during installation +and can be edited by the user only through `tljh-config`. The `rules` directory is also created +during install along with a `rules/rules.toml` file, to be used by JupyterHub to store the routing +table from users to their notebooks. + +:::{note} +Any direct modification to these files is unsupported, and will cause hard to debug issues. +::: + +But because sometimes TLJH needs to be customized in ways that are not officially +supported, an escape hatch has been introduced to allow easily extending the +configuration. Please follow the sections below for how to extend JupyterHub's +and Traefik's configuration outside of `tljh-config` scope. + +## Extending `jupyterhub_config.py` + +The `jupyterhub_config.d` directory lets you load multiple `jupyterhub_config.py` +snippets for your configuration. + +- Any files in `/opt/tljh/config/jupyterhub_config.d` that end in `.py` will + be loaded in alphabetical order as python files to provide configuration for + JupyterHub. +- The configuration files can have any name, but they need to have the `.py` + extension and to respect this format. +- Any config that can go in a regular `jupyterhub_config.py` file is valid in + these files. +- They will be loaded _after_ any of the config options specified with `tljh-config` + are loaded. + +Once you have created and defined your custom JupyterHub config file/s, just reload the +hub for the new configuration to take effect: + +```bash +sudo tljh-config reload hub +``` + +## Extending `traefik.toml` + +The `traefik_config.d` directory lets you load multiple `traefik.toml` +snippets for your configuration. + +- Any files in `/opt/tljh/config/traefik_config.d` that end in `.toml` will be + loaded in alphabetical order to provide configuration for Traefik. +- The configuration files can have any name, but they need to have the `.toml` + extension and to respect this format. +- Any config that can go in a regular `traefik.toml` file is valid in these files. +- They will be loaded _after_ any of the config options specified with `tljh-config` + are loaded. + +Once you have created and defined your custom Traefik config file/s, just reload the +proxy for the new configuration to take effect: + +```bash +sudo tljh-config reload proxy +``` + +:::{warning} +This instructions might change when TLJH will switch to Traefik > 2.0 +::: + +## Extending `rules.toml` + +`Traefik` is configured to load its routing table from the `/opt/tljh/state/rules` +directory. The existing `rules.toml` file inside this directory is used by +`jupyterhub-traefik-proxy` to add the JupyterHub routes from users to their notebook servers +and shouldn't be modified. + +However, the routing table can be extended outside JupyterHub's scope using the `rules` +directory, by adding other dynamic configuration files with the desired routing rules. + +:::{note} +Any files in `/opt/tljh/state/rules` that end in `.toml` will be hot reload by Traefik. +This means that there is no need to reload the proxy service for the rules to take effect. +::: + +Checkout Traefik' docs about [dynamic configuration](https://docs.traefik.io/v1.7/basics/#dynamic-traefik-configuration) +and how to provide dynamic configuration through +[multiple separated files](https://docs.traefik.io/v1.7/configuration/backends/file/#multiple-separated-files). diff --git a/docs/topic/escape-hatch.rst b/docs/topic/escape-hatch.rst deleted file mode 100644 index d2fd869..0000000 --- a/docs/topic/escape-hatch.rst +++ /dev/null @@ -1,94 +0,0 @@ -.. _topic/escape-hatch: - - -============================= -Custom configuration snippets -============================= - -The two main TLJH components are **JupyterHub** and **Traefik**. - -* JupyterHub takes its configuration from the ``jupyterhub_config.py`` file. -* Traefik loads its: - * `static configuration `_ - from the ``traefik.toml`` file. - * `dynamic configuration `_ - from the ``rules`` directory. - -The ``jupyterhub_config.py`` and ``traefik.toml`` files are created by TLJH during installation -and can be edited by the user only through ``tljh-config``. The ``rules`` directory is also created -during install along with a ``rules/rules.toml`` file, to be used by JupyterHub to store the routing -table from users to their notebooks. - -.. note:: - Any direct modification to these files is unsupported, and will cause hard to debug issues. - -But because sometimes TLJH needs to be customized in ways that are not officially -supported, an escape hatch has been introduced to allow easily extending the -configuration. Please follow the sections below for how to extend JupyterHub's -and Traefik's configuration outside of ``tljh-config`` scope. - -Extending ``jupyterhub_config.py`` -================================== - -The ``jupyterhub_config.d`` directory lets you load multiple ``jupyterhub_config.py`` -snippets for your configuration. - -* Any files in ``/opt/tljh/config/jupyterhub_config.d`` that end in ``.py`` will - be loaded in alphabetical order as python files to provide configuration for - JupyterHub. -* The configuration files can have any name, but they need to have the `.py` - extension and to respect this format. -* Any config that can go in a regular ``jupyterhub_config.py`` file is valid in - these files. -* They will be loaded *after* any of the config options specified with ``tljh-config`` - are loaded. - -Once you have created and defined your custom JupyterHub config file/s, just reload the -hub for the new configuration to take effect: - -.. code-block:: bash - - sudo tljh-config reload hub - - -Extending ``traefik.toml`` -========================== - -The ``traefik_config.d`` directory lets you load multiple ``traefik.toml`` -snippets for your configuration. - -* Any files in ``/opt/tljh/config/traefik_config.d`` that end in ``.toml`` will be - loaded in alphabetical order to provide configuration for Traefik. -* The configuration files can have any name, but they need to have the `.toml` - extension and to respect this format. -* Any config that can go in a regular ``traefik.toml`` file is valid in these files. -* They will be loaded *after* any of the config options specified with ``tljh-config`` - are loaded. - -Once you have created and defined your custom Traefik config file/s, just reload the -proxy for the new configuration to take effect: - -.. code-block:: bash - - sudo tljh-config reload proxy - -.. warning:: This instructions might change when TLJH will switch to Traefik > 2.0 - -Extending ``rules.toml`` -======================== - -``Traefik`` is configured to load its routing table from the ``/opt/tljh/state/rules`` -directory. The existing ``rules.toml`` file inside this directory is used by -``jupyterhub-traefik-proxy`` to add the JupyterHub routes from users to their notebook servers -and shouldn't be modified. - -However, the routing table can be extended outside JupyterHub's scope using the ``rules`` -directory, by adding other dynamic configuration files with the desired routing rules. - -.. note:: - * Any files in ``/opt/tljh/state/rules`` that end in ``.toml`` will be hot reload by Traefik. - This means that there is no need to reload the proxy service for the rules to take effect. - -Checkout Traefik' docs about `dynamic configuration `_ -and how to provide dynamic configuration through -`multiple separated files `_. diff --git a/docs/topic/idle-culler.rst b/docs/topic/idle-culler.md similarity index 58% rename from docs/topic/idle-culler.rst rename to docs/topic/idle-culler.md index 21e3889..ecb0243 100644 --- a/docs/topic/idle-culler.rst +++ b/docs/topic/idle-culler.md @@ -1,8 +1,6 @@ -.. _topic/idle-culler: +(topic-idle-culler)= -============================= -Culling idle notebook servers -============================= +# 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 @@ -11,109 +9,103 @@ usage on your JupyterHub. The notebook server monitors activity internally and notifies JupyterHub of recent activity at certain time intervals (the activity interval). If JupyterHub has not been notified of any activity after a certain period (the idle timeout), -the server is considered to be *inactive (idle)* and will be culled (shutdown). +the server is considered to be _inactive (idle)_ and will be culled (shutdown). -The `idle culler `_ is a JupyterHub service that is installed and enabled by default in TLJH. +The [idle culler](https://github.com/jupyterhub/jupyterhub-idle-culler) is a JupyterHub service that is installed and enabled by default in TLJH. It can be configured using tljh-config. For advanced use-cases, like purging old user data, the idle culler configuration can be extended beyond tljh-config options, using custom -`jupyterhub_config.py snippets `__. +[jupyterhub_config.py snippets](https://tljh.jupyter.org/en/latest/topic/escape-hatch.html?highlight=escape-hatch#extending-jupyterhub-config-py). - -Default settings -================ +## 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 +```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 +```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 +```python +services.cull.users = False +``` - services.cull.users = False - - -Configuring the idle culler -=========================== +## Configuring the idle culler The available configuration options are: -Idle timeout ------------- +### 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 +```bash +sudo tljh-config set services.cull.timeout +sudo tljh-config reload +``` - sudo tljh-config set services.cull.timeout - sudo tljh-config reload +### Idle check interval -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 +```bash +sudo tljh-config set services.cull.every +sudo tljh-config reload +``` - sudo tljh-config set services.cull.every - sudo tljh-config reload +### Maximum age -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 +```bash +sudo tljh-config set services.cull.max_age +sudo tljh-config reload +``` - sudo tljh-config set services.cull.max_age - sudo tljh-config reload +### User culling -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*. +suited for temporary-user cases such as _tmpnb_. User culling can be activated using the following command: -.. code-block:: bash +```bash +sudo tljh-config set services.cull.users True +sudo tljh-config reload +``` - sudo tljh-config set services.cull.users True - sudo tljh-config reload +### Concurrency -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 - sudo tljh-config reload +```bash +sudo tljh-config set services.cull.concurrency +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 -========================= +## 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 +```bash +sudo tljh-config set services.cull.enabled False +sudo tljh-config reload +``` diff --git a/docs/topic/index.md b/docs/topic/index.md new file mode 100644 index 0000000..9b9e8e8 --- /dev/null +++ b/docs/topic/index.md @@ -0,0 +1,19 @@ +# Topic Guides + +Topic guides provide in-depth explanations of specific topics. + +```{toctree} +:caption: Topic guides +:titlesonly: true + +whentouse +requirements +security +customizing-installer +installer-actions +tljh-config +authenticator-configuration +escape-hatch +idle-culler +jupyterhub-configurator +``` diff --git a/docs/topic/index.rst b/docs/topic/index.rst deleted file mode 100644 index d4366a7..0000000 --- a/docs/topic/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -============ -Topic Guides -============ - -Topic guides provide in-depth explanations of specific topics. - -.. toctree:: - :titlesonly: - :caption: Topic guides - - whentouse - requirements - security - customizing-installer - installer-actions - tljh-config - authenticator-configuration - escape-hatch - idle-culler - jupyterhub-configurator diff --git a/docs/topic/installer-actions.md b/docs/topic/installer-actions.md new file mode 100644 index 0000000..7dc26ab --- /dev/null +++ b/docs/topic/installer-actions.md @@ -0,0 +1,218 @@ +(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. + +## 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 binary install +of [traefik](http://traefik.io/). This virtual environment is completely managed by TLJH. + +:::{note} +If you try to remove TLJH, revert this action using: + +```bash +sudo rm -rf /opt/tljh/hub +``` + +::: + +## User environment + +By default, a `mambaforge` conda 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: + +```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: + +```bash +sudo env PATH=${PATH} +``` + +By default, `sudo` does not respect any custom environments you have activated. The `env PATH=${PATH}` +'fixes' that. + +:::{note} +If you try to remove TLJH, revert this action using: + +```bash +sudo rm -rf /opt/tljh/user +``` + +::: + +## `tljh-config` symlink + +We create a symlink from `/usr/bin/tljh-config` to `/opt/tljh/hub/bin/tljh-config`, so users +can run `sudo 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/bin`, so it is directly accessible with `sudo tljh-config `. + +:::{note} +If you try to remove TLJH, revert this action using: + +```bash +sudo unlink /usr/bin/tljh-config +``` + +::: + +## `jupyterhub_config.d` directory for custom configuration snippets + +Any files in /opt/tljh/config/jupyterhub_config.d that end in .py and are a valid +JupyterHub configuration will be loaded after any of the config options specified +with tljh-config are loaded. + +:::{note} +If you try to remove TLJH, revert this action using: + +```bash +sudo rm -rf /opt/tljh/config +``` + +::: + +## Systemd Units + +TLJH places 2 systemd units on your computer. They all start on system startup. + +1. `jupyterhub.service` - starts the JupyterHub service. +2. `traefik.service` - starts traefik proxy that manages HTTPS + +In addition, each running Jupyter user gets their own systemd unit of the name `jupyter-`. + +:::{note} +If you try to remove TLJH, revert this action using: + +```bash +# stop the services +systemctl stop jupyterhub.service +systemctl stop traefik.service +systemctl stop jupyter- + +# disable the services +systemctl disable jupyterhub.service +systemctl disable traefik.service +# run this command for all the Jupyter users +systemctl disable jupyter- + +# remove the systemd unit +rm /etc/systemd/system/jupyterhub.service +rm /etc/systemd/system/traefik.service + +# reset the state of all units +systemctl daemon-reload +systemctl reset-failed +``` + +::: + +## State files + +TLJH places 3 `jupyterhub.service` and 4 `traefik.service` state files in `/opt/tljh/state`. +These files save the state of JupyterHub and Traefik services and are meant +to be used and modified solely by these services. + +:::{note} +If you try to remove TLJH, revert this action using: + +```bash +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: + +```bash +sudo rm /var/run/index.html +sudo rm /var/run/favicon.ico +``` + +::: + +## User groups + +TLJH creates two user groups when installed: + +1. `jupyterhub-users` contains all users managed by this JupyterHub +2. `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/`. + +:::{note} +If you try to remove TLJH, in order to remove a user and its home directory, use: + +```bash +sudo userdel -r +``` + +::: + +Keep in mind that the files located in other parts of the file system +will have to be searched for and deleted manually. + +:::{note} +To remove the user groups units: + +```bash +sudo delgroup jupyterhub-users +sudo delgroup jupyterhub-admins +# remove jupyterhub-admins from the sudoers group +sudo rm /etc/sudoers.d/jupyterhub-admins +``` + +::: + +## 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. + +## Removing TLJH + +If trying to wipe out a fresh TLJH installation, follow the instructions on how to revert +each specific modification the TLJH installer does to the system. + +:::{note} +If using a VM, the recommended way to remove TLJH is destroying the VM and start fresh. +::: + +:::{warning} +Completely uninstalling TLJH after it has been used is a difficult task because it's +highly coupled to how the system changed after it has been used and modified by the users. +Thus, we cannot provide instructions on how to proceed in this case. +::: diff --git a/docs/topic/installer-actions.rst b/docs/topic/installer-actions.rst deleted file mode 100644 index 56e0302..0000000 --- a/docs/topic/installer-actions.rst +++ /dev/null @@ -1,214 +0,0 @@ -.. _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. - -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 binary install -of `traefik `_. This virtual environment is completely managed by TLJH. - -.. note:: - If you try to remove TLJH, revert this action using: - - .. code-block:: bash - - sudo rm -rf /opt/tljh/hub - - -User environment -================ - -By default, a ``mambaforge`` conda 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 env PATH=${PATH} - -By default, ``sudo`` does not respect any custom environments you have activated. The ``env PATH=${PATH}`` -'fixes' that. - -.. note:: - If you try to remove TLJH, revert this action using: - - .. code-block:: bash - - sudo rm -rf /opt/tljh/user - -``tljh-config`` symlink -======================== - -We create a symlink from ``/usr/bin/tljh-config`` to ``/opt/tljh/hub/bin/tljh-config``, so users -can run ``sudo 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/bin``, so it is directly accessible with ``sudo tljh-config ``. - -.. note:: - If you try to remove TLJH, revert this action using: - - .. code-block:: bash - - sudo unlink /usr/bin/tljh-config - -``jupyterhub_config.d`` directory for custom configuration snippets -=================================================================== - -Any files in /opt/tljh/config/jupyterhub_config.d that end in .py and are a valid -JupyterHub configuration will be loaded after any of the config options specified -with tljh-config are loaded. - -.. note:: - If you try to remove TLJH, revert this action using: - - .. code-block:: bash - - sudo rm -rf /opt/tljh/config - -Systemd Units -============= - -TLJH places 2 systemd units on your computer. They all start on system startup. - -#. ``jupyterhub.service`` - starts the JupyterHub service. -#. ``traefik.service`` - starts traefik proxy that manages HTTPS - -In addition, each running Jupyter user gets their own systemd unit of the name ``jupyter-``. - -.. note:: - If you try to remove TLJH, revert this action using: - - .. code-block:: bash - - # stop the services - systemctl stop jupyterhub.service - systemctl stop traefik.service - systemctl stop jupyter- - - # disable the services - systemctl disable jupyterhub.service - systemctl disable traefik.service - # run this command for all the Jupyter users - systemctl disable jupyter- - - # remove the systemd unit - rm /etc/systemd/system/jupyterhub.service - rm /etc/systemd/system/traefik.service - - # reset the state of all units - systemctl daemon-reload - systemctl reset-failed - -State files -=========== - -TLJH places 3 `jupyterhub.service` and 4 `traefik.service` state files in `/opt/tljh/state`. -These files save the state of JupyterHub and Traefik services and are meant -to be used and modified solely by these services. - -.. note:: - If you try to remove TLJH, revert this action using: - - .. code-block:: bash - - 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 -=========== - -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/``. - -.. note:: - If you try to remove TLJH, in order to remove a user and its home directory, use: - - .. code-block:: bash - - sudo userdel -r - -Keep in mind that the files located in other parts of the file system -will have to be searched for and deleted manually. - -.. note:: - To remove the user groups units: - - .. code-block:: bash - - sudo delgroup jupyterhub-users - sudo delgroup jupyterhub-admins - # remove jupyterhub-admins from the sudoers group - sudo rm /etc/sudoers.d/jupyterhub-admins - -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. - - -Removing TLJH -============= - -If trying to wipe out a fresh TLJH installation, follow the instructions on how to revert -each specific modification the TLJH installer does to the system. - -.. note:: - If using a VM, the recommended way to remove TLJH is destroying the VM and start fresh. - -.. warning:: - Completely uninstalling TLJH after it has been used is a difficult task because it's - highly coupled to how the system changed after it has been used and modified by the users. - Thus, we cannot provide instructions on how to proceed in this case. diff --git a/docs/topic/jupyterhub-configurator.md b/docs/topic/jupyterhub-configurator.md new file mode 100644 index 0000000..9adf524 --- /dev/null +++ b/docs/topic/jupyterhub-configurator.md @@ -0,0 +1,21 @@ +(topic-jupyterhub-configurator)= + +# JupyterHub Configurator + +The [JupyterHub configurator](https://github.com/yuvipanda/jupyterhub-configurator) allows admins to change a subset of hub settings via a GUI. + +## Enabling the configurator + +Because the configurator is under continue development and it might change over time, it is disabled by default in TLJH. +If you want to experiment with it, it can be enabled using `tljh-config`: + +```bash +sudo tljh-config set services.configurator.enabled True +sudo tljh-config reload +``` + +## Accessing the Configurator + +After enabling the configurator using `tljh-config`, the service will only be available to hub admins, from within the control panel. +The configurator can be accessed from under `Services` in the top navigation bar. It will ask to authenticate, so it knows the user is an admin. +Once done, the configurator interface will be available. diff --git a/docs/topic/jupyterhub-configurator.rst b/docs/topic/jupyterhub-configurator.rst deleted file mode 100644 index f319573..0000000 --- a/docs/topic/jupyterhub-configurator.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. _topic/jupyterhub-configurator: - -======================= -JupyterHub Configurator -======================= - -The `JupyterHub configurator `_ allows admins to change a subset of hub settings via a GUI. - -Enabling the configurator -========================= - -Because the configurator is under continue development and it might change over time, it is disabled by default in TLJH. -If you want to experiment with it, it can be enabled using ``tljh-config``: - -.. code-block:: bash - - sudo tljh-config set services.configurator.enabled True - sudo tljh-config reload - -Accessing the Configurator -========================== - -After enabling the configurator using ``tljh-config``, the service will only be available to hub admins, from within the control panel. -The configurator can be accessed from under ``Services`` in the top navigation bar. It will ask to authenticate, so it knows the user is an admin. -Once done, the configurator interface will be available. diff --git a/docs/topic/requirements.md b/docs/topic/requirements.md new file mode 100644 index 0000000..74cdeec --- /dev/null +++ b/docs/topic/requirements.md @@ -0,0 +1,23 @@ +(requirements)= + +# Server Requirements + +## Operating System + +We require using Ubuntu >=20.04 as the base operating system for your server. + +## Root access + +Full `root` access to this server is required. This might be via `sudo` +(recommended) or by direct access to `root` (not recommended!) + +## External IP + +An external IP allows users on the internet to reach your JupyterHub. Most +VPS / Cloud providers give you a public IP address along with your server. If +you are hosting on a physical machine somewhere, talk to your system administrators +about how to get HTTP traffic from the world into your server. + +## CPU / Memory / Disk Space + +See how to {ref}`howto/admin/resource-estimation` diff --git a/docs/topic/requirements.rst b/docs/topic/requirements.rst deleted file mode 100644 index 995733f..0000000 --- a/docs/topic/requirements.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. _requirements: - -=================== -Server Requirements -=================== - -Operating System -================ - -We require using Ubuntu >=20.04 as the base operating system for your server. - -Root access -=========== - -Full ``root`` access to this server is required. This might be via ``sudo`` -(recommended) or by direct access to ``root`` (not recommended!) - -External IP -=========== - -An external IP allows users on the internet to reach your JupyterHub. Most -VPS / Cloud providers give you a public IP address along with your server. If -you are hosting on a physical machine somewhere, talk to your system administrators -about how to get HTTP traffic from the world into your server. - -CPU / Memory / Disk Space -========================= - -See how to :ref:`howto/admin/resource-estimation` diff --git a/docs/topic/security.rst b/docs/topic/security.md similarity index 61% rename from docs/topic/security.rst rename to docs/topic/security.md index bb941ef..6e12cc2 100644 --- a/docs/topic/security.rst +++ b/docs/topic/security.md @@ -1,80 +1,69 @@ -======================= -Security Considerations -======================= +# Security Considerations 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 -==================== +## System user accounts Each JupyterHub user gets their own Unix user account created when they first start their server. This protects users from each other, gives them a home directory at a well known location, and allows sharing based on file system permissions. -#. The unix user account created for a JupyterHub user named ```` is - ``jupyter-``. This prefix helps prevent clashes with users that - already exist - otherwise a user named ``root`` can trivially gain full root - access to your server. If the username (including the ``jupyter-`` prefix) +1. The unix user account created for a JupyterHub user named `` is + `jupyter-`. This prefix helps prevent clashes with users that + already exist - otherwise a user named `root` can trivially gain full root + access to your server. If the username (including the `jupyter-` prefix) is longer than 26 characters, it is truncated at 26 characters & a 5 charcter hash is appeneded to it. This keeps usernames under the linux username limit of 32 characters while also reducing chances of collision. - -#. A home directory is created for the user under ``/home/jupyter-``. - -#. The default permission of the home directory is change with ``o-rwx`` (remove +2. A home directory is created for the user under `/home/jupyter-`. +3. The default permission of the home directory is change with `o-rwx` (remove non-group members the ability to read, write or list files and folders in the Home directory). - -#. No password is set for this unix system user by default. The password used +4. No password is set for this unix system user by default. The password used to log in to JupyterHub (if using an authenticator that requires a password) is not related to the unix user's password in any form. +5. All users created by The Littlest JupyterHub are added to the user group + `jupyterhub-users`. -#. All users created by The Littlest JupyterHub are added to the user group - ``jupyterhub-users``. +## `sudo` access for admins -``sudo`` access for admins -========================== - -JupyterHub admin users are added to the user group ``jupyterhub-admins``, -which is granted complete root access to the whole server with the ``sudo`` +JupyterHub admin users are added to the user group `jupyterhub-admins`, +which is granted complete root access to the whole server with the `sudo` command on the terminal. No password required. This is a **lot** of power, and they can do pretty much anything they want to the server - look at other people's work, modify it, break the server in cool & -funky ways, etc. This also means **if an admin's credentials are compromised +funky ways, etc. This also means **if an admin's credentials are compromised (easy to guess password, password re-use, etc) the entire JupyterHub is compromised.** -Off-boarding users securely -=========================== +## Off-boarding users securely When you delete users from the JupyterHub admin console, their unix user accounts are **not** removed. This means they might continue to have access to the server even after you remove them from JupyterHub. Admins should manually remove the user from the server & archive their home directories as needed. For example, the -following command deletes the unix user associated with the JupyterHub user ``yuvipanda``. +following command deletes the unix user associated with the JupyterHub user `yuvipanda`. -.. code-block:: bash - - sudo userdel jupyter-yuvipanda +```bash +sudo userdel jupyter-yuvipanda +``` If the user removed from the server is an admin, extra care must be taken since they could have modified the system earlier to continue giving them access. -Per-user ``/tmp`` -================= +## Per-user `/tmp` -``/tmp`` is shared by all users in most computing systems, and this has been +`/tmp` is shared by all users in most computing systems, and this has been a consistent source of security issues. The Littlest JupyterHub gives each -user their own ephemeral ``/tmp`` using the `PrivateTmp `_ +user their own ephemeral `/tmp` using the [PrivateTmp](https://www.freedesktop.org/software/systemd/man/systemd.exec.html#PrivateTmp) feature of systemd. -HTTPS -===== +## HTTPS Any internet-facing JupyterHub should use HTTPS to secure its traffic. For -information on how to use HTTPS with your JupyterHub, see :ref:`howto/admin/https`. +information on how to use HTTPS with your JupyterHub, see {ref}`howto/admin/https`. diff --git a/docs/topic/tljh-config.md b/docs/topic/tljh-config.md new file mode 100644 index 0000000..8f672d6 --- /dev/null +++ b/docs/topic/tljh-config.md @@ -0,0 +1,246 @@ +(topic-tljh-config)= + +# Configuring TLJH with `tljh-config` + +`tljh-config` is the commandline program used to make configuration +changes to TLJH. + +## Running `tljh-config` + +You can run `tljh-config` in two ways: + +1. From inside a terminal in JupyterHub while logged in as an admin user. + This method is recommended. +2. By directly calling `/opt/tljh/hub/bin/tljh-config` as root when + logged in to the server via other means (such as SSH). This is an + advanced use case, and not covered much in this guide. + +(tljh-set)= + +## 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: + +```bash +sudo tljh-config set +``` + +where: + +1. `` is a dot-separated path to the property you want + to set. +2. `` is the value you want to set the property to. + +For example, to set the password for the DummyAuthenticator, you +need to set the `auth.DummyAuthenticator.password` property. You would +do so with the following: + +```bash +sudo tljh-config set auth.DummyAuthenticator.password mypassword +``` + +This can only set string and numerical properties, not lists. + +To unset a configuration property you can use the following command: + +```bash +sudo tljh-config unset +``` + +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 `` are listed below by categories: + +(tljh-base-url)= + +### Base URL + +> Use `base_url` to determine the base URL used by JupyterHub. This parameter will +> be passed straight to `c.JupyterHub.base_url`. + +(tljh-set-auth)= + +### 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. +> +> ```bash +> sudo tljh-config set http.port 8080 +> sudo tljh-config set https.port 8443 +> sudo tljh-config reload proxy +> ``` + +(tljh-set-user-lists)= + +### User Lists + +- `users.allowed` takes in usernames to whitelist + +- `users.banned` takes in usernames to blacklist + +- `users.admin` takes in usernames to designate as admins + + ```bash + sudo tljh-config add-item users.allowed good-user_1 + sudo tljh-config add-item users.allowed good-user_2 + sudo tljh-config add-item users.banned bad-user_6 + sudo tljh-config add-item users.admin admin-user_0 + sudo tljh-config remove-item users.allowed good-user_2 + ``` + +(tljh-set-user-limits)= + +### User Server Limits + +- `limits.memory` Specifies the maximum memory that can be used by each + individual user. By default there is no memory limit. The limit can be + specified as an absolute byte value. You can use + the suffixes K, M, G or T to mean Kilobyte, Megabyte, Gigabyte or Terabyte + respectively. Setting it to `None` disables memory limits. + + ```bash + sudo tljh-config set limits.memory 4G + ``` + + Even if you want individual users to use as much memory as possible, + it is still good practice to set a memory limit of 80-90% of total + physical memory. This prevents one user from being able to single + handedly take down the machine accidentally by OOMing it. + +- `limits.cpu` A float representing the total CPU-cores each user can use. + By default there is no CPU limit. + 1 represents one full CPU, 4 represents 4 full CPUs, 0.5 represents + half of one CPU, etc. This value is ultimately converted to a percentage and + rounded down to the nearest integer percentage, + i.e. 1.5 is converted to 150%, 0.125 is converted to 12%, etc. + Setting it to `None` disables CPU limits. + + ```bash + sudo tljh-config set limits.cpu 2 + ``` + +(tljh-set-user-env)= + +### User Environment + +> `user_environment.default_app` Set default application users are +> launched into. Currently can be set to the following values +> `jupyterlab` or `nteract` +> +> ```bash +> 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: + +```bash +tljh-config set users.extra_user_groups.group1 user1 +``` + +- `tljh-config add-item`, if multiple users are to be added to + the group: + +```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 + +To see the current configuration, you can run the following command: + +```bash +sudo tljh-config show +``` + +This will print the current configuration of your TLJH. This is very +useful when asking for support! + +(tljh-reload-hub)= + +## Reloading JupyterHub to apply configuration + +After modifying the configuration, you need to reload JupyterHub for +it to take effect. You can do so with: + +```bash +sudo tljh-config reload +``` + +This should not affect any running users. The JupyterHub will be +restarted and loaded with the new configuration. + +(tljh-edit-yaml)= + +## Advanced: `config.yaml` + +`tljh-config` is a simple program that modifies the contents of the +`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. + +To learn more about the `tljh-config` usage, you can use the `--help` flag. +The `--help` flag can be used either directly, to get information about the +general usage of the command or after a positional argument. For example, using +it after an argument like `remove-item` gives information about this specific command. + +```bash +sudo tljh-config --help + +usage: tljh-config [-h] [--config-path CONFIG_PATH] {show,unset,set,add-item,remove-item,reload} ... + +positional arguments: + {show,unset,set,add-item,remove-item,reload} + show Show current configuration + unset Unset a configuration property + set Set a configuration property + add-item Add a value to a list for a configuration property + remove-item Remove a value from a list for a configuration property + reload Reload a component to apply configuration change + +optional arguments: + -h, --help show this help message and exit + --config-path CONFIG_PATH + Path to TLJH config.yaml file +``` + +```bash +sudo tljh-config remove-item --help + +usage: tljh-config remove-item [-h] key_path value + +positional arguments: + key_path Dot separated path to configuration key to remove value from + value Value to remove from key_path + +optional arguments: + -h, --help show this help message and exit +``` diff --git a/docs/topic/tljh-config.rst b/docs/topic/tljh-config.rst deleted file mode 100644 index 9b3b04c..0000000 --- a/docs/topic/tljh-config.rst +++ /dev/null @@ -1,271 +0,0 @@ -.. _topic/tljh-config: - -===================================== -Configuring TLJH with ``tljh-config`` -===================================== - -``tljh-config`` is the commandline program used to make configuration -changes to TLJH. - -Running ``tljh-config`` -======================= - -You can run ``tljh-config`` in two ways: - -#. From inside a terminal in JupyterHub while logged in as an admin user. - This method is recommended. - -#. By directly calling ``/opt/tljh/hub/bin/tljh-config`` as root when - logged in to the server via other means (such as SSH). This is an - advanced use case, and not covered much in this guide. - -.. _tljh-set: - - -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: - -.. code-block:: bash - - sudo tljh-config set - - -where: - -#. ```` is a dot-separated path to the property you want - to set. -#. ```` is the value you want to set the property to. - -For example, to set the password for the DummyAuthenticator, you -need to set the ``auth.DummyAuthenticator.password`` property. You would -do so with the following: - -.. code-block:: bash - - sudo tljh-config set auth.DummyAuthenticator.password mypassword - - -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 - -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 ```` are listed below by categories: - -.. _tljh-base_url: - -Base URL --------- - - Use ``base_url`` to determine the base URL used by JupyterHub. This parameter will - be passed straight to ``c.JupyterHub.base_url``. - -.. _tljh-set-auth: - -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: - -User Lists ----------- - - -* ``users.allowed`` takes in usernames to whitelist - -* ``users.banned`` takes in usernames to blacklist - -* ``users.admin`` takes in usernames to designate as admins - - .. code-block:: bash - - sudo tljh-config add-item users.allowed good-user_1 - sudo tljh-config add-item users.allowed good-user_2 - sudo tljh-config add-item users.banned bad-user_6 - sudo tljh-config add-item users.admin admin-user_0 - sudo tljh-config remove-item users.allowed good-user_2 - -.. _tljh-set-user-limits: - -User Server Limits ------------------- - - -* ``limits.memory`` Specifies the maximum memory that can be used by each - individual user. By default there is no memory limit. The limit can be - specified as an absolute byte value. You can use - the suffixes K, M, G or T to mean Kilobyte, Megabyte, Gigabyte or Terabyte - respectively. Setting it to ``None`` disables memory limits. - - .. code-block:: bash - - sudo tljh-config set limits.memory 4G - - Even if you want individual users to use as much memory as possible, - it is still good practice to set a memory limit of 80-90% of total - physical memory. This prevents one user from being able to single - handedly take down the machine accidentally by OOMing it. - -* ``limits.cpu`` A float representing the total CPU-cores each user can use. - By default there is no CPU limit. - 1 represents one full CPU, 4 represents 4 full CPUs, 0.5 represents - half of one CPU, etc. This value is ultimately converted to a percentage and - rounded down to the nearest integer percentage, - i.e. 1.5 is converted to 150%, 0.125 is converted to 12%, etc. - Setting it to ``None`` disables CPU limits. - - .. code-block:: bash - - sudo tljh-config set limits.cpu 2 - -.. _tljh-set-user-env: - -User Environment ----------------- - - - ``user_environment.default_app`` Set default application users are - launched into. Currently can be set to the following values - ``jupyterlab`` or ``nteract`` - - .. code-block:: bash - - 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 -========================== - -To see the current configuration, you can run the following command: - -.. code-block:: bash - - sudo tljh-config show - -This will print the current configuration of your TLJH. This is very -useful when asking for support! - -.. _tljh-reload-hub: - - -Reloading JupyterHub to apply configuration -=========================================== - -After modifying the configuration, you need to reload JupyterHub for -it to take effect. You can do so with: - -.. code-block:: bash - - sudo tljh-config reload - -This should not affect any running users. The JupyterHub will be -restarted and loaded with the new configuration. - -.. _tljh-edit-yaml: - -Advanced: ``config.yaml`` -========================= - -``tljh-config`` is a simple program that modifies the contents of the -``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. - -To learn more about the ``tljh-config`` usage, you can use the ``--help`` flag. -The ``--help`` flag can be used either directly, to get information about the -general usage of the command or after a positional argument. For example, using -it after an argument like ``remove-item`` gives information about this specific command. - -.. code-block:: bash - - sudo tljh-config --help - - usage: tljh-config [-h] [--config-path CONFIG_PATH] {show,unset,set,add-item,remove-item,reload} ... - - positional arguments: - {show,unset,set,add-item,remove-item,reload} - show Show current configuration - unset Unset a configuration property - set Set a configuration property - add-item Add a value to a list for a configuration property - remove-item Remove a value from a list for a configuration property - reload Reload a component to apply configuration change - - optional arguments: - -h, --help show this help message and exit - --config-path CONFIG_PATH - Path to TLJH config.yaml file - -.. code-block:: bash - - sudo tljh-config remove-item --help - - usage: tljh-config remove-item [-h] key_path value - - positional arguments: - key_path Dot separated path to configuration key to remove value from - value Value to remove from key_path - - optional arguments: - -h, --help show this help message and exit diff --git a/docs/topic/whentouse.rst b/docs/topic/whentouse.md similarity index 51% rename from docs/topic/whentouse.rst rename to docs/topic/whentouse.md index fb4a17f..d404da3 100644 --- a/docs/topic/whentouse.rst +++ b/docs/topic/whentouse.md @@ -1,34 +1,32 @@ -.. _topic/whentouse: +(topic-whentouse)= -=================================== -When to use The Littlest JupyterHub -=================================== +# When to use The Littlest JupyterHub This page is a brief guide to determining whether to use The Littlest JupyterHub -(TLJH) or `Zero to JupyterHub for Kubernetes `_ (Z2JH). +(TLJH) or [Zero to JupyterHub for Kubernetes](https://zero-to-jupyterhub.readthedocs.io/en/latest/) (Z2JH). Many of these ideas were first laid out in a -`blog post announcing TLJH `_. +[blog post announcing TLJH](http://words.yuvi.in/post/the-littlest-jupyterhub/). -`**The Littlest JupyterHub (TLJH)** `_ is an opinionated and pre-configured distribution +[\*\*The Littlest JupyterHub (TLJH)\*\*](https://the-littlest-jupyterhub.readthedocs.io/en/latest/) is an opinionated and pre-configured distribution to deploy a JupyterHub on a **single machine** (in the cloud or on your own hardware). It is designed to be a more lightweight and maintainable solution for use-cases where size, scalability, and cost-savings are not a huge concern. -`**Zero to JupyterHub on Kubernetes** `_ allows you +[\*\*Zero to JupyterHub on Kubernetes\*\*](https://zero-to-jupyterhub.readthedocs.io/en/latest/) allows you to deploy JupyterHub on **Kubernetes**. This allows JupyterHub to scale to many thousands of users, to flexibly grow/shrink the size of resources it needs, and to use container technology in administering user sessions. -When to use TLJH vs. Z2JH -========================= +## When to use TLJH vs. Z2JH The choice between TLJH and Z2JH ultimately comes down to only a few questions: 1. Do you want your hub and all users to live on a **single, larger machine** vs. spreading users on a **cluster of smaller machines** that are scaled up or down? - * If you can use a single machine, we recommend **The Littlest JupyterHub**. - * If you wish to use multiple machines, we recommend **Zero to JupyterHub for Kubernetes**. + - If you can use a single machine, we recommend **The Littlest JupyterHub**. + - If you wish to use multiple machines, we recommend **Zero to JupyterHub for Kubernetes**. + 2. Do you **need to use container technology**? - * If no, we recommend **The Littlest JupyterHub**. - * If yes, we recommend **Zero to JupyterHub for Kubernetes**. + - If no, we recommend **The Littlest JupyterHub**. + - If yes, we recommend **Zero to JupyterHub for Kubernetes**. diff --git a/docs/troubleshooting/index.rst b/docs/troubleshooting/index.md similarity index 66% rename from docs/troubleshooting/index.rst rename to docs/troubleshooting/index.md index 12361a0..178a64b 100644 --- a/docs/troubleshooting/index.rst +++ b/docs/troubleshooting/index.md @@ -1,25 +1,25 @@ -=============== -Troubleshooting -=============== +# Troubleshooting In time, all systems have issues that need to be debugged. Troubleshooting guides help you find what is broken & hopefully fix it. -.. toctree:: - :titlesonly: - :caption: Troubleshooting +```{toctree} +:caption: Troubleshooting +:titlesonly: true - logs - restart +logs +restart +``` Often, your issues are not related to TLJH itself but to the cloud provider your server is running on. We have some documentation on common issues you might run into with various providers and how to fix them. We welcome contributions here to better support your favorite provider! -.. toctree:: - :titlesonly: +```{toctree} +:titlesonly: true - providers/google - providers/amazon - providers/custom +providers/google +providers/amazon +providers/custom +``` diff --git a/docs/troubleshooting/logs.md b/docs/troubleshooting/logs.md new file mode 100644 index 0000000..06eb3ea --- /dev/null +++ b/docs/troubleshooting/logs.md @@ -0,0 +1,105 @@ +(troubleshooting-logs)= + +# Looking at Logs + +**Logs** are extremely useful in piecing together what went wrong when things go wrong. +They contain a forensic record of what individual pieces of software were doing +before things went bad, and can help us understand the problem so we can fix it. + +TLJH collects logs from JupyterHub, Traefik Proxy, & from each individual +user's notebook server. All the logs are accessible via [journalctl](https://www.freedesktop.org/software/systemd/man/journalctl.html). +The installer also writes logs to disk, to help with cases where the +installer did not succeed. + +:::{warning} +If you are providing a snippet from the logs to someone else to help debug +a problem you might have, be careful to redact any private information (such +as usernames) from the snippet first! +::: + +(troubleshooting-logs-installer)= + +## Installer Logs + +The JupyterHub installer writes log messages to `/opt/tljh/installer.log`. +This is very useful if the installation fails for any reason. + +(troubleshoot-logs-jupyterhub)= + +## JupyterHub Logs + +JupyterHub is responsible for user authentication, & starting / stopping user +notebook servers. When there is a general systemic issue with JupyterHub (rather +than a specific issue with a particular user's notebook), looking at the JupyterHub +logs is a great first step. + +```bash +sudo journalctl -u jupyterhub +``` + +This command displays logs from JupyterHub itself. See {ref}`journalctl_tips` +for tips on navigating the logs. + +(troubleshooting-logs-traefik)= + +## 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. + +```bash +sudo journalctl -u traefik +``` + +This command displays logs from Traefik. See {ref}`journalctl_tips` +for tips on navigating the logs. + +## User Server Logs + +Each user gets their own notebook server, and this server also produces logs. +Looking at these can be useful when a user can launch their server but run into +problems after that. + +```bash +sudo journalctl -u jupyter- +``` + +This command displays logs from the given user's notebook server. You can get a +list of all users from the "users" button at the top-right of the Admin page. +See {ref}`journalctl_tips` for tips on navigating the logs. + +(journalctl-tips)= + +## journalctl tips + +`journalctl` has a lot of options to make your life as an administrator +easier. Here are some very basic tips on effective `journalctl` usage. + +1. When looking at full logs (via `sudo journalctl -u `), the output + usually does not fit into one screen. Hence, it is _paginated_ with + [less](). This allows you to + scroll up / down, search for specific words, etc. Some common keyboard shortcuts + are: + + - Arrow keys to move up / down / left / right + - `G` to navigate to the end of the logs + - `g` to navigate to the start of the logs + - `/` followed by a string to search for & `enter` key to search the logs + from current position on screen to the end of the logs. If there are multiple + results, you can use `n` key to jump to the next search result. Use `?` + instead of `/` to search backwards from current position + - `q` or `Ctrl + C` to exit + + There are plenty of [other commands & options](https://linux.die.net/man/1/less) + to explore if you wish. + +2. Add `-f` to any `journalctl` command to view live logging output + that updates as new log lines are written. This is extremely useful when + actively debugging an issue. + + For example, to watch live logs of JupyterHub, you can run: + + ```bash + sudo journalctl -u jupyterhub -f + ``` diff --git a/docs/troubleshooting/logs.rst b/docs/troubleshooting/logs.rst deleted file mode 100644 index 315289f..0000000 --- a/docs/troubleshooting/logs.rst +++ /dev/null @@ -1,112 +0,0 @@ -.. _troubleshooting/logs: - -=============== -Looking at Logs -=============== - -**Logs** are extremely useful in piecing together what went wrong when things go wrong. -They contain a forensic record of what individual pieces of software were doing -before things went bad, and can help us understand the problem so we can fix it. - -TLJH collects logs from JupyterHub, Traefik Proxy, & from each individual -user's notebook server. All the logs are accessible via `journalctl `_. -The installer also writes logs to disk, to help with cases where the -installer did not succeed. - -.. warning:: - - If you are providing a snippet from the logs to someone else to help debug - a problem you might have, be careful to redact any private information (such - as usernames) from the snippet first! - -.. _troubleshooting/logs#installer: - -Installer Logs -============== - -The JupyterHub installer writes log messages to ``/opt/tljh/installer.log``. -This is very useful if the installation fails for any reason. - -.. _troubleshoot_logs_jupyterhub: - -JupyterHub Logs -=============== - -JupyterHub is responsible for user authentication, & starting / stopping user -notebook servers. When there is a general systemic issue with JupyterHub (rather -than a specific issue with a particular user's notebook), looking at the JupyterHub -logs is a great first step. - -.. code-block:: bash - - sudo journalctl -u jupyterhub - -This command displays logs from JupyterHub itself. See :ref:`journalctl_tips` -for tips on navigating the logs. - -.. _troubleshooting/logs/traefik: - -Traefik Proxy Logs -================== - -`traefik `_ 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 traefik - -This command displays logs from Traefik. See :ref:`journalctl_tips` -for tips on navigating the logs. - -User Server Logs -================ - -Each user gets their own notebook server, and this server also produces logs. -Looking at these can be useful when a user can launch their server but run into -problems after that. - -.. code-block:: bash - - sudo journalctl -u jupyter- - -This command displays logs from the given user's notebook server. You can get a -list of all users from the "users" button at the top-right of the Admin page. -See :ref:`journalctl_tips` for tips on navigating the logs. - -.. _journalctl_tips: - -journalctl tips -=============== - -``journalctl`` has a lot of options to make your life as an administrator -easier. Here are some very basic tips on effective ``journalctl`` usage. - -1. When looking at full logs (via ``sudo journalctl -u ``), the output - usually does not fit into one screen. Hence, it is *paginated* with - `less `_. This allows you to - scroll up / down, search for specific words, etc. Some common keyboard shortcuts - are: - - * Arrow keys to move up / down / left / right - * ``G`` to navigate to the end of the logs - * ``g`` to navigate to the start of the logs - * ``/`` followed by a string to search for & ``enter`` key to search the logs - from current position on screen to the end of the logs. If there are multiple - results, you can use ``n`` key to jump to the next search result. Use ``?`` - instead of ``/`` to search backwards from current position - * ``q`` or ``Ctrl + C`` to exit - - There are plenty of `other commands & options `_ - to explore if you wish. - -2. Add ``-f`` to any ``journalctl`` command to view live logging output - that updates as new log lines are written. This is extremely useful when - actively debugging an issue. - - For example, to watch live logs of JupyterHub, you can run: - - .. code-block:: bash - - sudo journalctl -u jupyterhub -f diff --git a/docs/troubleshooting/providers/amazon.md b/docs/troubleshooting/providers/amazon.md new file mode 100644 index 0000000..492f416 --- /dev/null +++ b/docs/troubleshooting/providers/amazon.md @@ -0,0 +1,26 @@ +# Troubleshooting issues on Amazon Web Services + +This is an incomplete list of issues people have run into when running +TLJH on Amazon Web Services (AWS), and how they have fixed them! + +## 'Connection Refused' error after restarting server + +If you restarted your server from the EC2 Management Console & then try to access +your JupyterHub from a browser, you might get a **Connection Refused** error. +This is most likely because the **External IP** of your server has changed. + +Check the **IPv4 Public IP** dislayed in the bottom of the `EC2 Management Console` +screen for that instance matches the IP you are trying to access. If you have a +domain name pointing to the IP address, you might have to change it to point to +the new correct IP. + +You can prevent public IP changes by [associating a static IP](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html) +with your server. In the Amazon Web Services ecosystem, the public static IP +addresses are handled under `Elastic IP addresses` category of AWS; these +addresses are tied to the overall AWS account. [This guide](https://dzone.com/articles/assign-fixed-ip-aws-ec2) might be helpful. Notice +there can be a cost to this. Although [the guide](https://dzone.com/articles/assign-fixed-ip-aws-ec2) is outdated (generally +half that [price now](https://aws.amazon.com/ec2/pricing/on-demand/#Elastic_IP_Addresses)), +Amazon describes [here](https://aws.amazon.com/premiumsupport/knowledge-center/elastic-ip-charges/) +how the Elastic IP address feature is free when associated with a running +instance, but that you'll be charged by the hour for maintaining that specific +IP address when it isn't associated with a running instance. diff --git a/docs/troubleshooting/providers/amazon.rst b/docs/troubleshooting/providers/amazon.rst deleted file mode 100644 index a39f1f9..0000000 --- a/docs/troubleshooting/providers/amazon.rst +++ /dev/null @@ -1,32 +0,0 @@ -============================================= -Troubleshooting issues on Amazon Web Services -============================================= - -This is an incomplete list of issues people have run into when running -TLJH on Amazon Web Services (AWS), and how they have fixed them! - -'Connection Refused' error after restarting server -================================================== - -If you restarted your server from the EC2 Management Console & then try to access -your JupyterHub from a browser, you might get a **Connection Refused** error. -This is most likely because the **External IP** of your server has changed. - -Check the **IPv4 Public IP** dislayed in the bottom of the `EC2 Management Console` -screen for that instance matches the IP you are trying to access. If you have a -domain name pointing to the IP address, you might have to change it to point to -the new correct IP. - -You can prevent public IP changes by `associating a static IP -`_ -with your server. In the Amazon Web Services ecosystem, the public static IP -addresses are handled under `Elastic IP addresses` category of AWS; these -addresses are tied to the overall AWS account. `This guide -`_ might be helpful. Notice -there can be a cost to this. Although `the guide -`_ is outdated (generally -half that `price now `_), -Amazon describes `here `_ -how the Elastic IP address feature is free when associated with a running -instance, but that you'll be charged by the hour for maintaining that specific -IP address when it isn't associated with a running instance. diff --git a/docs/troubleshooting/providers/custom.rst b/docs/troubleshooting/providers/custom.md similarity index 55% rename from docs/troubleshooting/providers/custom.rst rename to docs/troubleshooting/providers/custom.md index 8caf112..10d4280 100644 --- a/docs/troubleshooting/providers/custom.rst +++ b/docs/troubleshooting/providers/custom.md @@ -1,8 +1,6 @@ -.. _troubleshooting/providers/custom: +(troubleshooting-providers-custom)= -========================================= -Troubleshooting issues on your own server -========================================= +# Troubleshooting issues on your own server This is an incomplete list of issues people have run into when installing TLJH on their own servers, and ways they @@ -11,23 +9,22 @@ Before trying any of them, also consider whether turning your machine on and off and/or deleting the VM and starting over could solve the problem; it has done so on a surprisingly high number of occasions! -Outgoing HTTP proxy required -============================ +## Outgoing HTTP proxy required + If your server is behind a firewall that requires a HTTP proxy to reach the internet, run these commands before running the installer -.. code-block:: bash +```bash +export http_proxy= +``` - export http_proxy= - -HTTPS certificate interception -============================== +## HTTPS certificate interception If your server is behind a firewall that intercepts HTTPS requests and re-signs them, you might have to explicitly tell TLJH which certificates to use. -.. code:: - - export REQUESTS_CA_BUNDLE= - sudo npm config set cafile= +``` +export REQUESTS_CA_BUNDLE= +sudo npm config set cafile= +``` diff --git a/docs/troubleshooting/providers/google.md b/docs/troubleshooting/providers/google.md new file mode 100644 index 0000000..714d261 --- /dev/null +++ b/docs/troubleshooting/providers/google.md @@ -0,0 +1,17 @@ +# Troubleshooting issues on Google Cloud + +This is an incomplete list of issues people have run into when running +TLJH on Google Cloud, and how they have fixed them! + +## 'Connection Refused' error after restarting server + +If you restarted your server from the Google Cloud console & then try to access +your JupyterHub from a browser, you might get a **Connection Refused** error. +This is most likely because the **External IP** of your server has changed. + +Check the **External IP** in the [Google Cloud Console -> Compute Engine -> VM instances](https://console.cloud.google.com/compute/instances) screen +matches the IP you are trying to access. If you have a domain name pointing to the +IP address, you might have to change it to point to the new correct IP. + +You can prevent External IP changes by [reserving the static IP](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip) +your server is using. diff --git a/docs/troubleshooting/providers/google.rst b/docs/troubleshooting/providers/google.rst deleted file mode 100644 index 626f120..0000000 --- a/docs/troubleshooting/providers/google.rst +++ /dev/null @@ -1,22 +0,0 @@ -====================================== -Troubleshooting issues on Google Cloud -====================================== - -This is an incomplete list of issues people have run into when running -TLJH on Google Cloud, and how they have fixed them! - -'Connection Refused' error after restarting server -================================================== - -If you restarted your server from the Google Cloud console & then try to access -your JupyterHub from a browser, you might get a **Connection Refused** error. -This is most likely because the **External IP** of your server has changed. - -Check the **External IP** in the `Google Cloud Console -> Compute Engine -> VM instances -`_ screen -matches the IP you are trying to access. If you have a domain name pointing to the -IP address, you might have to change it to point to the new correct IP. - -You can prevent External IP changes by `reserving the static IP -`_ -your server is using. diff --git a/docs/troubleshooting/restart.md b/docs/troubleshooting/restart.md new file mode 100644 index 0000000..3d4fd23 --- /dev/null +++ b/docs/troubleshooting/restart.md @@ -0,0 +1,27 @@ +# Stopping and Restarting the JupyterHub Server + +The user can **stop** the JupyterHub server using: + +```console +$ systemctl stop jupyterhub.service +``` + +:::{warning} +Keep in mind that other services that may also require stopping: + +- The user's Jupyter server: jupyter-username.service +- traefik.service + +::: + +The user may **restart** JupyterHub and Traefik services by running: + +```console +$ sudo tljh-config reload proxy +``` + +This calls systemctl and restarts Traefik. The user may call systemctl and restart only the JupyterHub using the command: + +```console +$ sudo tljh-config reload hub +``` diff --git a/docs/troubleshooting/restart.rst b/docs/troubleshooting/restart.rst deleted file mode 100644 index 7220f9c..0000000 --- a/docs/troubleshooting/restart.rst +++ /dev/null @@ -1,29 +0,0 @@ - -============================================= -Stopping and Restarting the JupyterHub Server -============================================= - -The user can **stop** the JupyterHub server using: - -.. code-block:: console - - $ systemctl stop jupyterhub.service - -.. warning:: - - Keep in mind that other services that may also require stopping: - - * The user's Jupyter server: jupyter-username.service - * Traefik.service - -The user may **restart** JupyterHub and Traefik services by running: - -.. code-block:: console - - $ sudo tljh-config reload proxy - -This calls systemctl and restarts Traefik. The user may call systemctl and restart only the JupyterHub using the command: - -.. code-block:: console - - $ sudo tljh-config reload hub From 76dadd7ef91fba1dc31fe6f7512f4d782196308e Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 27 Mar 2023 09:56:46 +0200 Subject: [PATCH 068/232] fix absolute refs myst seems to create different refs than sphinx --- docs/contributing/code-review.md | 4 ++-- docs/contributing/dev-setup.md | 2 +- docs/contributing/docs.md | 2 +- docs/contributing/plugins.md | 2 +- docs/howto/admin/admin-users.md | 2 +- docs/howto/admin/https.md | 8 ++++---- docs/howto/admin/resize.md | 4 ++-- docs/howto/admin/resource-estimation.md | 2 +- docs/howto/auth/awscognito.md | 4 ++-- docs/howto/auth/github.md | 4 ++-- docs/howto/auth/google.md | 4 ++-- docs/howto/content/add-data.md | 8 ++++---- docs/howto/content/share-data.md | 4 ++-- docs/howto/index.md | 2 +- docs/howto/providers/digitalocean.md | 2 +- docs/index.md | 2 +- docs/install/add_packages.txt | 2 +- docs/install/amazon.md | 8 ++++---- docs/install/azure.md | 4 ++-- docs/install/custom-server.md | 10 +++++----- docs/install/digitalocean.md | 6 +++--- docs/install/google.md | 8 ++++---- docs/install/jetstream.md | 6 +++--- docs/install/ovh.md | 4 ++-- docs/topic/authenticator-configuration.md | 4 ++-- docs/topic/customizing-installer.md | 2 +- docs/topic/requirements.md | 2 +- docs/topic/security.md | 2 +- docs/troubleshooting/logs.md | 6 +++--- 29 files changed, 60 insertions(+), 60 deletions(-) diff --git a/docs/contributing/code-review.md b/docs/contributing/code-review.md index ce96e3d..209cba6 100644 --- a/docs/contributing/code-review.md +++ b/docs/contributing/code-review.md @@ -24,7 +24,7 @@ not to have _perfect_ documentation before merging a pull request. If you are new and not sure how to add documentation, other contributors will be happy to guide you. -See {ref}`contributing/docs` for guidelines on writing documentation. +See [](/contributing/docs) for guidelines on writing documentation. ## Write tests @@ -43,4 +43,4 @@ add more tests. If you are unsure what kind of tests to add for your pull request, other contributors to the repo will be happy to help guide you! -See {ref}`contributing/tests` for guidelines on writing tests. +See [](/contributing/tests) for guidelines on writing tests. diff --git a/docs/contributing/dev-setup.md b/docs/contributing/dev-setup.md index 35f8016..cc6e8b1 100644 --- a/docs/contributing/dev-setup.md +++ b/docs/contributing/dev-setup.md @@ -68,5 +68,5 @@ The easiest & safest way to develop & test TLJH is with [Docker](https://www.doc restart jupyterhub for them to take effect. `tljh-config reload hub` should do that. -{ref}`troubleshooting/logs` has information on looking at various logs in the container +[](/troubleshooting/logs) has information on looking at various logs in the container to debug issues you might have. diff --git a/docs/contributing/docs.md b/docs/contributing/docs.md index be45e0a..bc4d6c0 100644 --- a/docs/contributing/docs.md +++ b/docs/contributing/docs.md @@ -59,7 +59,7 @@ If you encounter this error, it's likely that you are running inside a virtual e Error in "currentmodule" directive: ``` -To get started contributing, you'll want to read the {ref}`reStructuredText reference ` +To get started contributing, you'll want to read the `reStructuredText reference ` Your locally-built documentation will be themed differently than the documentation at [the-littlest-jupyterhub.readthedocs.io](https://the-littlest-jupyterhub.readthedocs.io). diff --git a/docs/contributing/plugins.md b/docs/contributing/plugins.md index a076485..6151b8d 100644 --- a/docs/contributing/plugins.md +++ b/docs/contributing/plugins.md @@ -20,7 +20,7 @@ stability required for a good plugin ecosystem. ## Installing Plugins -Include `--plugin ` in the Installer script. See {ref}`topic/customizing-installer` for more info. +Include `--plugin ` in the Installer script. See [](/topic/customizing-installer) for more info. ## Writing a simple plugins diff --git a/docs/howto/admin/admin-users.md b/docs/howto/admin/admin-users.md index 99b935e..1102853 100644 --- a/docs/howto/admin/admin-users.md +++ b/docs/howto/admin/admin-users.md @@ -17,7 +17,7 @@ so attackers can not easily gain control of the system. :::{important} You should make sure an admin user is present when you **install** TLJH the very first time. It is recommended that you also set a password -for the admin at this step. The {ref}`--admin ` +for the admin at this step. The [`--admin`] (/topic/customizing-installer/admin) flag passed to the installer does this. If you had forgotten to do so, the easiest way to fix this is to run the installer again. ::: diff --git a/docs/howto/admin/https.md b/docs/howto/admin/https.md index 2a4a508..0c0696d 100644 --- a/docs/howto/admin/https.md +++ b/docs/howto/admin/https.md @@ -8,9 +8,9 @@ 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 ` with your own TLS key and +[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 ` +you should use the [Let's Encrypt](#howto-admin-https-letsencrypt) method. :::{note} @@ -35,7 +35,7 @@ similar to this: 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 `. +set up a DNS entry and HTTPS [manually](#howto-admin-https-manual). ::: To enable HTTPS via letsencrypt: @@ -115,4 +115,4 @@ and now access your Hub securely at . ## Troubleshooting -If you're having trouble with HTTPS, looking at the {ref}`traefik proxy logs ` might help. +If you're having trouble with HTTPS, looking at the [traefik proxy logs](troubleshooting-logs-traefik) might help. diff --git a/docs/howto/admin/resize.md b/docs/howto/admin/resize.md index 0cd3f71..44d7ba2 100644 --- a/docs/howto/admin/resize.md +++ b/docs/howto/admin/resize.md @@ -11,7 +11,7 @@ the cloud provider of your choice. Currently there are instructions to resize your resources on the following providers: -- {ref}`Digital Ocean `. +- [Digital Ocean](howto-providers-digitalocean-resize) Once resources have been reallocated, you must tell TLJH to make use of these resources, and verify that the resources have become available. @@ -20,7 +20,7 @@ and verify that the resources have become available. 1. Once you have resized your server, tell the JupyterHub to make use of these new resources. To accomplish this, follow the instructions in - {ref}`topic/tljh-config` to set new memory or CPU limits and reload the hub. This can be completed + [](/topic/tljh-config) to set new memory or CPU limits and reload the hub. This can be completed using the terminal in the JupyterHub (or via SSH-ing into your VM and using this terminal). 2. TLJH configuration options can be verified by viewing the tljh-config output. diff --git a/docs/howto/admin/resource-estimation.md b/docs/howto/admin/resource-estimation.md index 8c1585e..9bc7113 100644 --- a/docs/howto/admin/resource-estimation.md +++ b/docs/howto/admin/resource-estimation.md @@ -36,7 +36,7 @@ over time. We generally recommend between 40-60% of your total class size to sta Depending on what kind of work your users are doing, they will use different amounts of memory. The easiest way to determine this is to run through a typical user -workflow yourself, and measure how much memory is used. You can use {ref}`howto/admin/nbresuse` +workflow yourself, and measure how much memory is used. You can use [](/howto/admin/nbresuse) to determine how much memory your user is using. A good rule of thumb is to take the maximum amount of memory you used during diff --git a/docs/howto/auth/awscognito.md b/docs/howto/auth/awscognito.md index cb96100..1fed27e 100644 --- a/docs/howto/auth/awscognito.md +++ b/docs/howto/auth/awscognito.md @@ -102,7 +102,7 @@ c.GenericOAuthenticator.userdata_method = "POST" ``` We'll use the `tljh-config` tool to configure your JupyterHub's authentication. -For more information on `tljh-config`, see {ref}`topic/tljh-config`. +For more information on `tljh-config`, see [](/topic/tljh-config). 1. Tell your JupyterHub to use the GenericOAuthenticator for authentication: @@ -125,4 +125,4 @@ For more information on `tljh-config`, see {ref}`topic/tljh-config`. 4. You will likely have to create a new user (sign up) and then you should be directed to the Jupyter interface used in this JupyterHub. 5. **If this does not work** you can revert back to the default - JupyterHub authenticator by following the steps in {ref}`howto/auth/firstuse`. + JupyterHub authenticator by following the steps in [](/howto/auth/firstuse). diff --git a/docs/howto/auth/github.md b/docs/howto/auth/github.md index c9b6709..04635f8 100644 --- a/docs/howto/auth/github.md +++ b/docs/howto/auth/github.md @@ -52,7 +52,7 @@ with the new IP address. ## Configure your JupyterHub to use the GitHub 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`. +For more information on `tljh-config`, see [](/topic/tljh-config). 1. Log in as an administrator account to your JupyterHub. @@ -105,4 +105,4 @@ For more information on `tljh-config`, see {ref}`topic/tljh-config`. Jupyter interface used in this JupyterHub. 5. **If this does not work** you can revert back to the default - JupyterHub authenticator by following the steps in {ref}`howto/auth/firstuse`. + JupyterHub authenticator by following the steps in [](/howto/auth/firstuse). diff --git a/docs/howto/auth/google.md b/docs/howto/auth/google.md index 8ecb3b6..1557a4c 100644 --- a/docs/howto/auth/google.md +++ b/docs/howto/auth/google.md @@ -77,7 +77,7 @@ 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`. +For more information on `tljh-config`, see [](/topic/tljh-config). 1. Log in as an administrator account to your JupyterHub. @@ -130,4 +130,4 @@ For more information on `tljh-config`, see {ref}`topic/tljh-config`. Jupyter interface used in this JupyterHub. 5. **If this does not work** you can revert back to the default - JupyterHub authenticator by following the steps in {ref}`howto/auth/firstuse`. + JupyterHub authenticator by following the steps in [](/howto/auth/firstuse). diff --git a/docs/howto/content/add-data.md b/docs/howto/content/add-data.md index 0ff6d59..9115877 100644 --- a/docs/howto/content/add-data.md +++ b/docs/howto/content/add-data.md @@ -4,13 +4,13 @@ This section covers how to add data to your JupyterHub either from the internet or from your own machine. To learn how to **share data** that is already -on your JupyterHub, see {ref}`howto/content/share-data`. +on your JupyterHub, see [](/howto/content/share-data). :::{note} When you add data using the methods on this page, you will **only add it to your user directory**. This is not a place that is accessible to others. For information on sharing this data with users on the JupyterHub, see -{ref}`howto/content/share-data`. +[](/howto/content/share-data). ::: ## Adding data from your local machine @@ -40,7 +40,7 @@ interface. To do so, follow these steps: be on your JupyterHub, your home user's home directory. To learn how to **share** this data with new users on the JupyterHub, -see {ref}`howto/content/share-data`. +see [](/howto/content/share-data). ## Downloading data from the command line @@ -95,6 +95,6 @@ time. You can download it from your browser [at this link](https://swcarpentry.g 5. Confirm that your data was unzipped. It could be in a folder called `data/`. To learn how to **share** this data with new users on the JupyterHub, -see {ref}`howto/content/share-data`. +see [](/howto/content/share-data). % TODO: Downloading data with the "download" module in Python? https://github.com/choldgraf/download diff --git a/docs/howto/content/share-data.md b/docs/howto/content/share-data.md index 672cc75..27ffd1b 100644 --- a/docs/howto/content/share-data.md +++ b/docs/howto/content/share-data.md @@ -13,7 +13,7 @@ contained in the link's target repository is downloaded to the user's home directory. Note that a copy of the dataset will be made for each user. For information on creating and sharing `nbgitpuller` links, see -{ref}`howto/content/nbgitpuller`. +[](/howto/content/nbgitpuller). ## Option 2: Create a read-only shared folder for data @@ -37,7 +37,7 @@ steps: sudo mkdir -p /srv/data/my_shared_data_folder ``` -4. **Download the data** into this folder. See {ref}`howto/content/add-data` for +4. **Download the data** into this folder. See [](/howto/content/add-data) for details on how to do this. 5. All users now have read access to the data in this folder. diff --git a/docs/howto/index.md b/docs/howto/index.md index 2bc0273..7a73b8c 100644 --- a/docs/howto/index.md +++ b/docs/howto/index.md @@ -28,7 +28,7 @@ env/server-resources We have a special set of How-To Guides on using various forms of authentication with your JupyterHub. For more information on Authentication, see -{ref}`topic/authenticator-configuration` +[](/topic/authenticator-configuration) ```{toctree} :titlesonly: true diff --git a/docs/howto/providers/digitalocean.md b/docs/howto/providers/digitalocean.md index 4528f6e..1630096 100644 --- a/docs/howto/providers/digitalocean.md +++ b/docs/howto/providers/digitalocean.md @@ -39,4 +39,4 @@ disk space, or CPUs. Digital Ocean servers can be resized in the Now that you've resized your Droplet, you may want to change the resources available to your users. Further information on making more resources available to -users and verifying resource availability can be found in {ref}`howto/admin/resize`. +users and verifying resource availability can be found in [](/howto/admin/resize). diff --git a/docs/index.md b/docs/index.md index b542c4b..dddc39e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ A simple [JupyterHub](https://github.com/jupyterhub/jupyterhub) distribution for a small (0-100) number of users on a single server. We recommend reading -{ref}`topic/whentouse` to determine if this is the right tool for you. +[](/topic/whentouse) to determine if this is the right tool for you. ## Development Status diff --git a/docs/install/add_packages.txt b/docs/install/add_packages.txt index f21072f..56ad124 100644 --- a/docs/install/add_packages.txt +++ b/docs/install/add_packages.txt @@ -27,4 +27,4 @@ The packages ``gdal`` and ``there`` are now available to all users in JupyterHub If a user already had a python notebook running, they have to restart their notebook's kernel to make the new libraries available. -See :ref:`howto/env/user_environment` for more information. +See :ref:`howto-env-user-environment` for more information. diff --git a/docs/install/amazon.md b/docs/install/amazon.md index 0ee8915..1c45ef1 100644 --- a/docs/install/amazon.md +++ b/docs/install/amazon.md @@ -72,7 +72,7 @@ Let's create the server on which we can run JupyterHub. offers. Select the one you want and then select the button `Next: Configure Instance Details` in the lower right corner. - Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + Check out our guide on How To [](/howto/admin/resource-estimation) to help pick how much Memory / CPU your server needs. We recommend you use a server with at least 2GB of RAM, such as a **t3.small**. However, if you need to minimise costs you can use a server with **1GB** RAM such as a **t2.micro**, but performance will be limited. @@ -101,8 +101,8 @@ Let's create the server on which we can run JupyterHub. ``` :::{note} - See {ref}`topic/installer-actions` for a detailed description and - {ref}`topic/customizing-installer` for other options that can be used. + See [](/topic/installer-actions) for a detailed description and + [](/topic/customizing-installer) for other options that can be used. ::: 8. Under **Step 4: Add Storage**, you can change the **size** and **type of your @@ -112,7 +112,7 @@ Let's create the server on which we can run JupyterHub. :alt: Selecting disk size and type ``` - Check out {ref}`howto/admin/resource-estimation` to help pick + Check out [](/howto/admin/resource-estimation) to help pick how much Disk space your server needs. Hover over the encircled `i` next to **Volume Type** for an explanation of diff --git a/docs/install/azure.md b/docs/install/azure.md index 9b66ebb..e46d83d 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -161,8 +161,8 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ``` :::{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. + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. ::: 8. Check the summary and confirm the creation of your Virtual Machine. diff --git a/docs/install/custom-server.md b/docs/install/custom-server.md index df64e65..abb21cd 100644 --- a/docs/install/custom-server.md +++ b/docs/install/custom-server.md @@ -14,7 +14,7 @@ on your personal computer. :::{note} 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`. +[](/contributing/dev-setup). ::: ## Goal @@ -31,7 +31,7 @@ a server you have access to. 4. Ability to `ssh` into the server & run commands from the prompt. 5. An **IP address** where the server can be reached from the browsers of your target audience. -If you run into issues, look at the specific {ref}`troubleshooting guide ` +If you run into issues, look at the specific [troubleshooting guide](/troubleshooting/providers/custom) for custom server installations. ## Step 1: Installing The Littlest JupyterHub @@ -56,8 +56,8 @@ for custom server installations. ``` :::{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. + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. ::: 4. Press `Enter` to start the installation process. This will take 5-10 minutes, @@ -92,4 +92,4 @@ for custom server installations. ## Step 4: Setup HTTPS Once you are ready to run your server for real, and have a domain, it's a good -idea to proceed directly to {ref}`howto/admin/https`. +idea to proceed directly to [](/howto/admin/https). diff --git a/docs/install/digitalocean.md b/docs/install/digitalocean.md index 03b49e2..78f9ec4 100644 --- a/docs/install/digitalocean.md +++ b/docs/install/digitalocean.md @@ -40,7 +40,7 @@ Let's create the server on which we can run JupyterHub. (4GB RAM, 2CPUs, 20 USD / month) is not a bad start. You can resize your server later if you need. - Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + Check out our guide on How To [](/howto/admin/resource-estimation) to help pick how much Memory, CPU & disk space your server needs. 5. Scroll down to **Select additional options**, and select **User data**. @@ -66,8 +66,8 @@ Let's create the server on which we can run JupyterHub. ``` :::{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. + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. ::: 7. Under the **Finalize and create** section, enter a `hostname` that descriptively diff --git a/docs/install/google.md b/docs/install/google.md index 60c68a7..b84bda1 100644 --- a/docs/install/google.md +++ b/docs/install/google.md @@ -84,7 +84,7 @@ Let's create the server on which we can run JupyterHub. :alt: Select a customized VM size ``` - Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + Check out our guide on How To [](/howto/admin/resource-estimation) to help pick how much Memory / CPU your server needs. 11. Under **Boot Disk**, click the **Change** button. This lets us change the @@ -113,7 +113,7 @@ Let's create the server on which we can run JupyterHub. to a hard drive. **SSD persistent disk** gives you a faster but more expensive disk, similar to an SSD. - Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + Check out our guide on How To [](/howto/admin/resource-estimation) to help pick how much Disk space your server needs. 14. Click the **Select** button to dismiss the Boot disk popup and go back to the @@ -160,8 +160,8 @@ Let's create the server on which we can run JupyterHub. ``` :::{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. + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. ::: 19. Click the **Create** button at the bottom to start your server! diff --git a/docs/install/jetstream.md b/docs/install/jetstream.md index d0ad7e3..680a43c 100644 --- a/docs/install/jetstream.md +++ b/docs/install/jetstream.md @@ -55,7 +55,7 @@ Let's create the server on which we can run JupyterHub. 2. Select an appropriate **Instance Size**. We suggest m1.medium or larger. Make sure your instance has at least **1GB** of RAM. - Check out our guide on How To {ref}`howto/admin/resource-estimation` to help pick + Check out our guide on How To [](/howto/admin/resource-estimation) to help pick how much Memory, CPU & disk space your server needs. 3. If you have multiple allocations, make sure you are 'charging' this server @@ -93,8 +93,8 @@ Let's create the server on which we can run JupyterHub. ``` :::{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. + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. ::: 9. Under **Execution Strategy Type**, select **Run script on first boot**. diff --git a/docs/install/ovh.md b/docs/install/ovh.md index 74d8dc6..a979dba 100644 --- a/docs/install/ovh.md +++ b/docs/install/ovh.md @@ -77,8 +77,8 @@ Let's create the server on which we can run JupyterHub. ``` :::{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. + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. ::: ```{image} ../images/providers/ovh/configuration.png diff --git a/docs/topic/authenticator-configuration.md b/docs/topic/authenticator-configuration.md index 3a64a11..2b9f2b9 100644 --- a/docs/topic/authenticator-configuration.md +++ b/docs/topic/authenticator-configuration.md @@ -10,7 +10,7 @@ can be used with TLJH. A number of them ship by default with TLJH: OAuth based authentication methods. 2. [LDAPAuthenticator](https://github.com/jupyterhub/ldapauthenticator) - LDAP & Active Directory. 3. [DummyAuthenticator](https://github.com/yuvipanda/jupyterhub-dummy-authenticator) - Any username, - one shared password. A {ref}`how-to guide on using DummyAuthenticator ` is also + one shared password. A [how-to guide on using DummyAuthenticator](howto-auth-dummy) is also available. 4. [FirstUseAuthenticator](https://github.com/yuvipanda/jupyterhub-firstuseauthenticator) - Users set their password when they log in for the first time. Default authenticator used in TLJH. @@ -75,7 +75,7 @@ sudo tljh-config reload Try logging in a separate incognito window to check if your configuration works. This lets you preserve your terminal in case there were errors. If there are -errors, {ref}`troubleshooting/logs` should help you debug them. +errors, [](/troubleshooting/logs) should help you debug them. ### Example diff --git a/docs/topic/customizing-installer.md b/docs/topic/customizing-installer.md index cfb4ec1..151fe7f 100644 --- a/docs/topic/customizing-installer.md +++ b/docs/topic/customizing-installer.md @@ -110,7 +110,7 @@ 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 particular class, etc. You can find more information about -writing plugins and a list of existing plugins at {ref}`contributing/plugins`. +writing plugins and a list of existing plugins at [](/contributing/plugins). `--plugin ` installs and activates a plugin. You can pass it however many times you want. Since plugins are distributed as python packages, diff --git a/docs/topic/requirements.md b/docs/topic/requirements.md index 74cdeec..3d2df1e 100644 --- a/docs/topic/requirements.md +++ b/docs/topic/requirements.md @@ -20,4 +20,4 @@ about how to get HTTP traffic from the world into your server. ## CPU / Memory / Disk Space -See how to {ref}`howto/admin/resource-estimation` +See how to [](/howto/admin/resource-estimation) diff --git a/docs/topic/security.md b/docs/topic/security.md index 6e12cc2..23d94bc 100644 --- a/docs/topic/security.md +++ b/docs/topic/security.md @@ -66,4 +66,4 @@ feature of systemd. ## HTTPS Any internet-facing JupyterHub should use HTTPS to secure its traffic. For -information on how to use HTTPS with your JupyterHub, see {ref}`howto/admin/https`. +information on how to use HTTPS with your JupyterHub, see [](/howto/admin/https). diff --git a/docs/troubleshooting/logs.md b/docs/troubleshooting/logs.md index 06eb3ea..b488494 100644 --- a/docs/troubleshooting/logs.md +++ b/docs/troubleshooting/logs.md @@ -37,7 +37,7 @@ logs is a great first step. sudo journalctl -u jupyterhub ``` -This command displays logs from JupyterHub itself. See {ref}`journalctl_tips` +This command displays logs from JupyterHub itself. See [](#journalctl-tips) for tips on navigating the logs. (troubleshooting-logs-traefik)= @@ -52,7 +52,7 @@ is one line cryptic error messages, or if you are having trouble with HTTPS. sudo journalctl -u traefik ``` -This command displays logs from Traefik. See {ref}`journalctl_tips` +This command displays logs from Traefik. See [](#journalctl-tips) for tips on navigating the logs. ## User Server Logs @@ -67,7 +67,7 @@ sudo journalctl -u jupyter- This command displays logs from the given user's notebook server. You can get a list of all users from the "users" button at the top-right of the Admin page. -See {ref}`journalctl_tips` for tips on navigating the logs. +See [](#journalctl-tips) for tips on navigating the logs. (journalctl-tips)= From 1ac6f9983db2fd6c8905282981aa904455c0c3ba Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 27 Mar 2023 10:22:35 +0200 Subject: [PATCH 069/232] markdownify add_users/packages included files not caught in initial rst2myst --- docs/install/add_packages.txt | 60 ++++++++++++------------- docs/install/add_users.txt | 82 ++++++++++++++++++----------------- docs/install/amazon.md | 8 ++-- docs/install/azure.md | 8 ++-- docs/install/custom-server.md | 8 ++-- docs/install/digitalocean.md | 8 ++-- docs/install/google.md | 8 ++-- docs/install/jetstream.md | 8 ++-- docs/install/ovh.md | 8 ++-- 9 files changed, 101 insertions(+), 97 deletions(-) diff --git a/docs/install/add_packages.txt b/docs/install/add_packages.txt index 56ad124..10f8972 100644 --- a/docs/install/add_packages.txt +++ b/docs/install/add_packages.txt @@ -1,30 +1,30 @@ -The **User Environment** is a conda environment that is shared by all users -in the JupyterHub. Libraries installed in this environment are immediately -available to all users. Admin users can install packages in this environment -with ``sudo -E``. - -#. Log in as an admin user and open a Terminal in your Jupyter Notebook. - - .. image:: ../images/notebook/new-terminal-button.png - :alt: New Terminal button under New menu - -#. Install `gdal `_ from `conda-forge `_. - - .. code-block:: bash - - sudo -E conda install -c conda-forge gdal - - The ``sudo -E`` is very important! - -#. Install `there `_ with ``pip`` - - - .. code-block:: bash - - sudo -E pip install there - -The packages ``gdal`` and ``there`` are now available to all users in JupyterHub. -If a user already had a python notebook running, they have to restart their notebook's -kernel to make the new libraries available. - -See :ref:`howto-env-user-environment` for more information. +The **User Environment** is a conda environment that is shared by all users +in the JupyterHub. Libraries installed in this environment are immediately +available to all users. Admin users can install packages in this environment +with `sudo -E`. + +1. Log in as an admin user and open a Terminal in your Jupyter Notebook. + + ```{image} ../images/notebook/new-terminal-button.png + :alt: New Terminal button under New menu + ``` + +2. Install [gdal](https://anaconda.org/conda-forge/gdal) from [conda-forge](https://conda-forge.org/). + + ```bash + sudo -E conda install -c conda-forge gdal + ``` + + The `sudo -E` is very important! + +3. Install [there](https://pypi.org/project/there) with `pip` + + ```bash + sudo -E pip install there + ``` + +The packages `gdal` and `there` are now available to all users in JupyterHub. +If a user already had a python notebook running, they have to restart their notebook's +kernel to make the new libraries available. + +See {ref}`howto-env-user-environment` for more information. diff --git a/docs/install/add_users.txt b/docs/install/add_users.txt index 8d4066f..53b137f 100644 --- a/docs/install/add_users.txt +++ b/docs/install/add_users.txt @@ -1,39 +1,43 @@ -Most administration & configuration of the JupyterHub can be done from the -web UI directly. Let's add a few users who can log in! - -#. Open the **Control Panel** by clicking the control panel button on the top - right of your JupyterHub. - - .. image:: ../images/control-panel-button.png - :alt: Control panel button in notebook, top right - -#. In the control panel, open the **Admin** link in the top left. - - .. image:: ../images/admin/admin-access-button.png - :alt: Admin button in control panel, top left - - This opens up the JupyterHub admin page, where you can add / delete users, - start / stop peoples' servers and see who is online. - -#. Click the **Add Users** button. - - .. image:: ../images/admin/add-users-button.png - :alt: Add Users button in the admin page - - A **Add Users** dialog box opens up. - -#. Type the names of users you want to add to this JupyterHub in the dialog box, - one per line. - - .. image:: ../images/admin/add-users-dialog.png - :alt: Adding users with add users dialog - - You can tick the **Admin** checkbox if you want to give admin rights to all - these users too. - -#. Click the **Add Users** button in the dialog box. Your users are now added - to the JupyterHub! When they log in for the first time, they can set their - password - and use it to log in again in the future. - -Congratulations, you now have a multi user JupyterHub that you can add arbitrary -users to! +Most administration & configuration of the JupyterHub can be done from the +web UI directly. Let's add a few users who can log in! + +1. Open the **Control Panel** by clicking the control panel button on the top + right of your JupyterHub. + + ```{image} ../images/control-panel-button.png + :alt: Control panel button in notebook, top right + ``` + +2. In the control panel, open the **Admin** link in the top left. + + ```{image} ../images/admin/admin-access-button.png + :alt: Admin button in control panel, top left + ``` + + This opens up the JupyterHub admin page, where you can add / delete users, + start / stop peoples' servers and see who is online. + +3. Click the **Add Users** button. + + ```{image} ../images/admin/add-users-button.png + :alt: Add Users button in the admin page + ``` + + A **Add Users** dialog box opens up. + +4. Type the names of users you want to add to this JupyterHub in the dialog box, + one per line. + + ```{image} ../images/admin/add-users-dialog.png + :alt: Adding users with add users dialog + ``` + + You can tick the **Admin** checkbox if you want to give admin rights to all + these users too. + +5. Click the **Add Users** button in the dialog box. Your users are now added + to the JupyterHub! When they log in for the first time, they can set their + password - and use it to log in again in the future. + +Congratulations, you now have a multi user JupyterHub that you can add arbitrary +users to! diff --git a/docs/install/amazon.md b/docs/install/amazon.md index 1c45ef1..227af91 100644 --- a/docs/install/amazon.md +++ b/docs/install/amazon.md @@ -268,12 +268,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` diff --git a/docs/install/azure.md b/docs/install/azure.md index e46d83d..087e158 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -207,12 +207,12 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` diff --git a/docs/install/custom-server.md b/docs/install/custom-server.md index abb21cd..85760a9 100644 --- a/docs/install/custom-server.md +++ b/docs/install/custom-server.md @@ -79,14 +79,14 @@ for custom server installations. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` ## Step 4: Setup HTTPS diff --git a/docs/install/digitalocean.md b/docs/install/digitalocean.md index 78f9ec4..c774ebd 100644 --- a/docs/install/digitalocean.md +++ b/docs/install/digitalocean.md @@ -112,12 +112,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` diff --git a/docs/install/google.md b/docs/install/google.md index b84bda1..8fd73d6 100644 --- a/docs/install/google.md +++ b/docs/install/google.md @@ -208,12 +208,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` diff --git a/docs/install/jetstream.md b/docs/install/jetstream.md index 680a43c..7205cf8 100644 --- a/docs/install/jetstream.md +++ b/docs/install/jetstream.md @@ -141,12 +141,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` diff --git a/docs/install/ovh.md b/docs/install/ovh.md index a979dba..2a562f6 100644 --- a/docs/install/ovh.md +++ b/docs/install/ovh.md @@ -122,12 +122,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{eval-rst} -.. include:: add_users.txt +```{include} add_users.txt + ``` ## Step 3: Install conda / pip packages for all users -```{eval-rst} -.. include:: add_packages.txt +```{include} add_packages.txt + ``` From 214635bf873676746f4c68902d9ffd5ec2589e9d Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 27 Mar 2023 10:25:18 +0200 Subject: [PATCH 070/232] update doc contributing guide to point to markdown instead of rst --- docs/contributing/docs.md | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/docs/contributing/docs.md b/docs/contributing/docs.md index bc4d6c0..2b457e8 100644 --- a/docs/contributing/docs.md +++ b/docs/contributing/docs.md @@ -59,7 +59,7 @@ If you encounter this error, it's likely that you are running inside a virtual e Error in "currentmodule" directive: ``` -To get started contributing, you'll want to read the `reStructuredText reference ` +To get started contributing, you'll want to get familiar with [markdown](https://commonmark.org/help/) and [MyST](https://myst-parser.readthedocs.io). Your locally-built documentation will be themed differently than the documentation at [the-littlest-jupyterhub.readthedocs.io](https://the-littlest-jupyterhub.readthedocs.io). @@ -152,37 +152,17 @@ documentation: - **Notebook Interface** -- generic term for referring to JupyterLab, nteract, classic notebook & other user interfaces for accessing -## Guidelines for reStructuredText files +## Guidelines for markdown files -These guidelines regulate the format of our reST (reStructuredText) +These guidelines regulate the format of our markdown documentation: - In section titles, capitalize only initial words and proper nouns. -- Wrap the documentation at 120 characters wide, unless a code example +- Wrap the documentation at sentence breaks or around 120 characters wide, unless a code example is significantly less readable when split over two lines, or for another good reason. -- Use these heading styles: - - ``` - === - One - === - - Two - === - - Three - ----- - - Four - ~~~~ - - Five - ^^^^ - ``` - ## Documenting new features Our policy for new features is: From b5a6b3f590cd77dc9cdb1ce3fbfaafd3ad7a3680 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 27 Mar 2023 13:10:31 +0200 Subject: [PATCH 071/232] Avoid downgrading user-env conda/mamba - Only check lower bound on conda/mamba, upgrade unbounded if not matched (let conda apply upper bound according to existing pins, such as Python) - handle missing mamba - avoid upgrading Python by aborting the install, instead of keeping old envs - minimum supported Python for user env is 3.9 - Fix output reporting of conda install step (no need for json capture when we don't parse the output - exit codes will do) --- dev-requirements.txt | 1 + tests/test_conda.py | 7 -- tests/test_installer.py | 169 +++++++++++++++++++++++++++------------- tljh/conda.py | 81 +++++-------------- tljh/installer.py | 105 +++++++++++++++---------- 5 files changed, 200 insertions(+), 163 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 0c50f19..ec27a97 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ +packaging pytest pytest-cov pytest-mock diff --git a/tests/test_conda.py b/tests/test_conda.py index d38a85a..46b7cac 100644 --- a/tests/test_conda.py +++ b/tests/test_conda.py @@ -21,13 +21,6 @@ def prefix(): installer_url, checksum ) as installer_path: conda.install_miniconda(installer_path, tmpdir) - conda.ensure_conda_packages( - tmpdir, - [ - f"conda=={installer.MAMBAFORGE_CONDA_VERSION}", - f"mamba=={installer.MAMBAFORGE_MAMBA_VERSION}", - ], - ) yield tmpdir diff --git a/tests/test_installer.py b/tests/test_installer.py index 861c947..dcffa71 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -6,11 +6,12 @@ import os from unittest import mock from subprocess import run, PIPE +from packaging.version import parse as V +from packaging.specifiers import SpecifierSet import pytest from tljh import conda from tljh import installer -from tljh.utils import parse_version as V from tljh.yaml import yaml @@ -48,13 +49,18 @@ def setup_conda(distro, version, prefix): """Install mambaforge or miniconda in a prefix""" if distro == "mambaforge": installer_url, _ = installer._mambaforge_url(version) + elif distro == "miniforge": + installer_url, _ = installer._mambaforge_url(version) + installer_url = installer_url.replace("Mambaforge", "Miniforge3") elif distro == "miniconda": arch = os.uname().machine installer_url = ( f"https://repo.anaconda.com/miniconda/Miniconda3-{version}-Linux-{arch}.sh" ) else: - raise ValueError(f"{distro=} must be 'miniconda' or 'mambaforge'") + raise ValueError( + f"{distro=} must be 'miniconda' or 'mambaforge' or 'miniforge'" + ) with conda.download_miniconda_installer(installer_url, None) as installer_path: conda.install_miniconda(installer_path, str(prefix)) # avoid auto-updating conda when we install other packages @@ -79,77 +85,127 @@ def user_env_prefix(tmp_path): yield user_env_prefix +def _specifier(version): + """Convert version string to SpecifierSet + + If just a version number, add == to make it a specifier + + Any missing fields are replaced with .* + + If it's already a specifier string, pass it directly to SpecifierSet + + e.g. + + - 3.7 -> ==3.7.* + - 1.2.3 -> ==1.2.3 + """ + if version[0].isdigit(): + # it's a version number, not a specifier + if version.count(".") < 2: + # pad missing fields + version += ".*" + version = f"=={version}" + return SpecifierSet(version) + + @pytest.mark.parametrize( - "distro, version, conda_version, mamba_version", + "distro, distro_version, expected_versions", [ + # No previous install, start fresh ( None, None, - installer.MAMBAFORGE_CONDA_VERSION, - installer.MAMBAFORGE_MAMBA_VERSION, - ), - ( - "exists", - None, - installer.MAMBAFORGE_CONDA_VERSION, - installer.MAMBAFORGE_MAMBA_VERSION, + { + "python": "3.10.*", + "conda": "22.11.1", + "mamba": "1.1.0", + }, ), + # previous install, 1.0 ( "mambaforge", "22.11.1-4", - installer.MAMBAFORGE_CONDA_VERSION, - installer.MAMBAFORGE_MAMBA_VERSION, + { + "python": "3.10.*", + "conda": "22.11.1", + "mamba": "1.1.0", + }, ), - ("mambaforge", "4.10.3-7", "4.10.3", "0.16.0"), + # 0.2 install, no upgrade needed + ( + "mambaforge", + "4.10.3-7", + { + "conda": "4.10.3", + "mamba": "0.16.0", + "python": "3.9.*", + }, + ), + # simulate missing mamba + # will be installed but not pinned + # to avoid conflicts + ( + "miniforge", + "4.10.3-7", + { + "conda": "4.10.3", + "mamba": ">=1.1.0", + "python": "3.9.*", + }, + ), + # too-old Python (3.7), abort ( "miniconda", "4.7.10", - installer.MAMBAFORGE_CONDA_VERSION, - installer.MAMBAFORGE_MAMBA_VERSION, - ), - ( - "miniconda", - "4.5.1", - installer.MAMBAFORGE_CONDA_VERSION, - installer.MAMBAFORGE_MAMBA_VERSION, + ValueError, ), ], ) def test_ensure_user_environment( user_env_prefix, distro, - version, - conda_version, - mamba_version, + distro_version, + expected_versions, ): - if version and V(version) < V("4.10.1") and os.uname().machine == "aarch64": - pytest.skip(f"Miniconda {version} not available for aarch64") + if ( + distro_version + and V(distro_version) < V("4.10.1") + and os.uname().machine == "aarch64" + ): + pytest.skip(f"{distro} {distro_version} not available for aarch64") canary_file = user_env_prefix / "test-file.txt" canary_package = "types-backports_abc" if distro: - if distro == "exists": - user_env_prefix.mkdir() - else: - setup_conda(distro, version, user_env_prefix) - # install a noarch: python package that won't be used otherwise - # should depend on Python, so it will interact with possible upgrades - run( - [ - str(user_env_prefix / "bin/conda"), - "install", - "-y", - "-c" "conda-forge", - canary_package, - ], - input="", - check=True, - ) + setup_conda(distro, distro_version, user_env_prefix) + # install a noarch: python package that won't be used otherwise + # should depend on Python, so it will interact with possible upgrades + pkgs = [canary_package] + run( + [ + str(user_env_prefix / "bin/conda"), + "install", + "-S", + "-y", + "-c", + "conda-forge", + ] + + pkgs, + input="", + check=True, + ) # make a file not managed by conda, to check for wipeouts with canary_file.open("w") as f: f.write("I'm here\n") - installer.ensure_user_environment("") + if isinstance(expected_versions, type) and issubclass(expected_versions, Exception): + exc_class = expected_versions + with pytest.raises(exc_class): + installer.ensure_user_environment("") + return + else: + installer.ensure_user_environment("") + p = run( [str(user_env_prefix / "bin/conda"), "list", "--json"], stdout=PIPE, @@ -158,14 +214,23 @@ def test_ensure_user_environment( ) package_list = json.loads(p.stdout) packages = {package["name"]: package for package in package_list} + if distro: # make sure we didn't wipe out files assert canary_file.exists() - if distro != "exists": - # make sure we didn't delete the installed package - assert canary_package in packages + # make sure we didn't delete the installed package + assert canary_package in packages - assert "conda" in packages - assert packages["conda"]["version"] == conda_version - assert "mamba" in packages - assert packages["mamba"]["version"] == mamba_version + for pkg, version in expected_versions.items(): + assert pkg in packages + assert V(packages[pkg]["version"]) in _specifier(version) + + +def test_ensure_user_environment_no_clobber(user_env_prefix): + # don't clobber existing user-env dir if it's non-empty and not a conda install + user_env_prefix.mkdir() + canary_file = user_env_prefix / "test-file.txt" + with canary_file.open("w") as f: + pass + with pytest.raises(OSError): + installer.ensure_user_environment("") diff --git a/tljh/conda.py b/tljh/conda.py index 19dc937..4a08fce 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -13,7 +13,6 @@ import time import requests from tljh import utils -from tljh.utils import parse_version as V def sha256_file(fname): @@ -29,47 +28,20 @@ def sha256_file(fname): return hash_sha256.hexdigest() -def check_miniconda_version(prefix, version): - """ - Return true if a miniconda install with version exists at prefix - """ - versions = get_mamba_versions(prefix) - if "conda" not in versions: - return False - - return V(versions["conda"]) >= V(version) - - -def get_mamba_versions(prefix): - """Parse `mamba --version` output into a dict - - which looks like: - - mamba 1.1.0 - conda 22.11.1 - - into: - - { - "mamba": "1.1.0", - "conda": "22.11.1", - } - """ +def get_conda_package_versions(prefix): + """Get conda package versions, via `conda list --json`""" versions = {} try: - out = ( - subprocess.check_output( - [os.path.join(prefix, "bin", "mamba"), "--version"], - stderr=subprocess.STDOUT, - ) - .decode() - .strip() + out = subprocess.check_output( + [os.path.join(prefix, "bin", "conda"), "list", "--json"], + text=True, ) except (subprocess.CalledProcessError, FileNotFoundError): return versions - for line in out.strip().splitlines(): - pkg, version = line.split() - versions[pkg] = version + + packages = json.loads(out) + for package in packages: + versions[package["name"]] = package["version"] return versions @@ -133,39 +105,24 @@ def ensure_conda_packages(prefix, packages): Note that conda seem to update dependencies by default, so there is probably no need to have a update parameter exposed for this function. """ - conda_executable = [os.path.join(prefix, "bin", "mamba")] + conda_executable = os.path.join(prefix, "bin", "mamba") + if not os.path.isfile(conda_executable): + # fallback on conda if mamba is not present (e.g. for mamba to install itself) + conda_executable = os.path.join(prefix, "bin", "conda") + abspath = os.path.abspath(prefix) - # Let subprocess errors propagate - # Explicitly do *not* capture stderr, since that's not always JSON! - # Scripting conda is a PITA! - # FIXME: raise different exception when using - raw_output = subprocess.check_output( - conda_executable - + [ + + utils.run_subprocess( + [ + conda_executable, "install", "-c", "conda-forge", # Make customizable if we ever need to - "--json", "--prefix", abspath, ] - + packages - ).decode() - # `conda install` outputs JSON lines for fetch updates, - # and a undelimited output at the end. There is no reasonable way to - # parse this outside of this kludge. - filtered_output = "\n".join( - [ - l - for l in raw_output.split("\n") - # Sometimes the JSON messages start with a \x00. The lstrip removes these. - # conda messages seem to randomly throw \x00 in places for no reason - if not l.lstrip("\x00").startswith('{"fetch"') - ] + + packages, ) - output = json.loads(filtered_output.lstrip("\x00")) - if "success" in output and output["success"] == True: - return fix_permissions(prefix) diff --git a/tljh/installer.py b/tljh/installer.py index b463ff1..90232d6 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -164,11 +164,15 @@ MAMBAFORGE_CHECKSUMS = { "aarch64": "96191001f27e0cc76612d4498d34f9f656d8a7dddee44617159e42558651479c", "x86_64": "16c7d256de783ceeb39970e675efa4a8eb830dcbb83187f1197abfea0bf07d30", } -# run `mamba --version` to get the conda and mamba versions -# conda/mamba will be _upgraded_ to these versions, if they differ from what's in -# the mambaforge distribution -MAMBAFORGE_MAMBA_VERSION = "1.1.0" -MAMBAFORGE_CONDA_VERSION = "22.11.1" + +# minimum versions of packages +MINIMUM_VERSIONS = { + # if conda/mamba are lower than this, upgrade them before installing the user packages + "mamba": "0.16.0", + "conda": "4.10", + # minimum Python version (if not matched, abort to avoid big disruptive updates) + "python": "3.9", +} def _mambaforge_url(version=MAMBAFORGE_VERSION, arch=None): @@ -196,54 +200,71 @@ def ensure_user_environment(user_requirements_txt_file): Set up user conda environment with required packages """ logger.info("Setting up user environment...") - # note: these must be in descending order - conda_upgrade_versions = { - # format: "conda version": (conda_version, mamba_version), - # mambaforge 4.10.3-7 (2023-03-21) - "22.11.1": (MAMBAFORGE_CONDA_VERSION, MAMBAFORGE_MAMBA_VERSION), - # tljh up to 0.2.0 (since 2021-10-18) - "4.10.3": ("4.10.3", "0.16.0"), - } - # Check OS, set appropriate string for conda installer path if os.uname().sysname != "Linux": raise OSError("TLJH is only supported on Linux platforms.") - found_conda = False - have_versions = conda.get_mamba_versions(USER_ENV_PREFIX) - have_conda_version = have_versions.get("conda") - if have_conda_version: - logger.info( - f"Found prefix at {USER_ENV_PREFIX}, with conda/mamba({have_versions})" - ) - for check_version, conda_mamba_version in conda_upgrade_versions.items(): - if V(have_conda_version) >= V(check_version): - found_conda = True - conda_version, mamba_version = conda_mamba_version - break - if not found_conda: - if os.path.exists(USER_ENV_PREFIX): - logger.warning( - f"Found prefix at {USER_ENV_PREFIX}, but too old or missing conda/mamba ({have_versions}). Upgrading from mambaforge." - ) - # FIXME: should this fail? I'm not sure how destructive it is + # Check the existing environment for what to do + package_versions = conda.get_conda_package_versions(USER_ENV_PREFIX) + + # Case 1: no existing environment + if not package_versions: + # 1a. no environment, but prefix exists. + # Abort to avoid clobbering something we don't recognize + if os.path.exists(USER_ENV_PREFIX) and os.listdir(USER_ENV_PREFIX): + msg = f"Found non-empty directory that is not a conda install in {USER_ENV_PREFIX}. Please remove it (or rename it to preserve files) and run tljh again." + logger.error(msg) + raise OSError(msg) + + # 1b. No environment, directory empty or doesn't exist + # start fresh install logger.info("Downloading & setting up user environment...") installer_url, installer_sha256 = _mambaforge_url() with conda.download_miniconda_installer( installer_url, installer_sha256 ) as installer_path: conda.install_miniconda(installer_path, USER_ENV_PREFIX) - conda_version = MAMBAFORGE_CONDA_VERSION - mamba_version = MAMBAFORGE_MAMBA_VERSION + package_versions = conda.get_conda_package_versions(USER_ENV_PREFIX) + # quick sanity check: we should have conda and mamba! + assert "conda" in package_versions + assert "mamba" in package_versions - conda.ensure_conda_packages( - USER_ENV_PREFIX, - [ - # Conda's latest version is on conda much more so than on PyPI. - "conda==" + conda_version, - "mamba==" + mamba_version, - ], - ) + # next, check Python + python_version = package_versions["python"] + logger.debug(f"Found python={python_version} in {USER_ENV_PREFIX}") + if V(python_version) < V(MINIMUM_VERSIONS["python"]): + msg = ( + f"TLJH requires Python >={MINIMUM_VERSIONS['python']}, found python={python_version} in {USER_ENV_PREFIX}." + f"\nPlease upgrade Python (may be highly disruptive!), or remove/rename {USER_ENV_PREFIX} to allow TLJH to make a fresh install." + f"\nYou can use `{USER_ENV_PREFIX}/bin/conda list` to save your current list of packages." + ) + logger.error(msg) + raise ValueError(msg) + + # at this point, we know we have an env ready with conda and are going to start installing + # first, check if we should upgrade/install conda and/or mamba + to_upgrade = [] + for pkg in ("conda", "mamba"): + version = package_versions.get(pkg) + min_version = MINIMUM_VERSIONS[pkg] + if not version: + logger.warning(f"{USER_ENV_PREFIX} is missing {pkg}, installing it...") + to_upgrade.append(pkg) + else: + logger.debug(f"Found {pkg}=={version} in {USER_ENV_PREFIX}") + if V(version) < V(min_version): + logger.info( + f"{USER_ENV_PREFIX} has {pkg}=={version}, it will be upgraded to {pkg}>={min_version}" + ) + to_upgrade.append(pkg) + + if to_upgrade: + conda.ensure_conda_packages( + USER_ENV_PREFIX, + # we _could_ explicitly pin Python here, + # but conda already does this by default + to_upgrade, + ) conda.ensure_pip_requirements( USER_ENV_PREFIX, From 9b940c34fde0f8cf90f4455e2b7018bc33356893 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 27 Mar 2023 15:43:46 +0200 Subject: [PATCH 072/232] don't let conda install wait for input --- tljh/conda.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tljh/conda.py b/tljh/conda.py index 4a08fce..206e4df 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -116,12 +116,14 @@ def ensure_conda_packages(prefix, packages): [ conda_executable, "install", + "-y", "-c", "conda-forge", # Make customizable if we ever need to "--prefix", abspath, ] + packages, + input="", ) fix_permissions(prefix) From 2d1c584ecac962e288e4a3b9e7a5bcbdc005c9b4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 28 Mar 2023 15:06:04 +0200 Subject: [PATCH 073/232] Fix wait/fail conditions in hub culling tests - actually check for running server, matching comments, not just user existence - catch asyncio.TimeoutError, not TimeoutError - fail if condition is not met, rather than passing in both cases - update some comments for accuracy (max age and timeout aren't the same) --- integration-tests/test_hub.py | 86 ++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index a056f62..fa5415d 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -346,12 +346,12 @@ async def test_idle_server_culled(): ) ).wait() ) - # Check every 10s for idle servers to cull + # Check every 5s for idle servers to cull assert ( 0 == await ( await asyncio.create_subprocess_exec( - *TLJH_CONFIG_PATH, "set", "services.cull.every", "10" + *TLJH_CONFIG_PATH, "set", "services.cull.every", "5" ) ).wait() ) @@ -364,12 +364,12 @@ async def test_idle_server_culled(): ) ).wait() ) - # Cull servers and users after 60s of activity + # Cull servers and users after 30s, regardless of activity assert ( 0 == await ( await asyncio.create_subprocess_exec( - *TLJH_CONFIG_PATH, "set", "services.cull.max_age", "60" + *TLJH_CONFIG_PATH, "set", "services.cull.max_age", "30" ) ).wait() ) @@ -388,25 +388,50 @@ async def test_idle_server_culled(): 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/")}, - ) + user_url = u.notebook_url / "api/status" + r = await u.session.get(user_url, allow_redirects=False) 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 + # Check that we can talk to JupyterHub itself + # use this as a proxy for whether the user still exists + async def hub_api_request(): r = await u.session.get( - u.hub_url / "hub/api/users" / username, + u.hub_url / "hub/api/user", headers={"Referer": str(u.hub_url / "hub/")}, + allow_redirects=False, ) - print(r.status) + return r + + r = await hub_api_request() + assert r.status == 200 + + # Wait for culling + # step 1: check if the server is still running + timeout = 100 + + async def server_stopped(): + """Has the server been stopped?""" + r = await u.session.get(user_url, allow_redirects=False) + print(f"{r.status} {r.url}") + return r.status != 200 + + await exponential_backoff( + server_stopped, + "Server still running!", + timeout=timeout, + ) + + # step 2. wait for user to be deleted + async def user_removed(): + # Check that after 60s, the user has been culled + r = await hub_api_request() + print(f"{r.status} {r.url}") return r.status == 403 await exponential_backoff( - _check_culling_done, - "Server culling failed!", - timeout=100, + user_removed, + "User still exists!", + timeout=timeout, ) @@ -429,12 +454,12 @@ async def test_active_server_not_culled(): ) ).wait() ) - # Check every 10s for idle servers to cull + # Check every 5s for idle servers to cull assert ( 0 == await ( await asyncio.create_subprocess_exec( - *TLJH_CONFIG_PATH, "set", "services.cull.every", "10" + *TLJH_CONFIG_PATH, "set", "services.cull.every", "5" ) ).wait() ) @@ -447,7 +472,7 @@ async def test_active_server_not_culled(): ) ).wait() ) - # Cull servers and users after 60s of activity + # Cull servers and users after 30s, regardless of activity assert ( 0 == await ( @@ -471,27 +496,24 @@ async def test_active_server_not_culled(): 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/")}, - ) + user_url = u.notebook_url / "api/status" + r = await u.session.get(user_url, allow_redirects=False) assert r.status == 200 - async def _check_culling_done(): + async def server_has_stopped(): # 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) + r = await u.session.get(user_url, allow_redirects=False) + print(f"{r.status} {r.url}") return r.status != 200 try: await exponential_backoff( - _check_culling_done, - "User's server is still reacheable!", + server_has_stopped, + "User's server is still reachable (good!)", timeout=30, ) - except TimeoutError: - # During the 30s timeout the user's server wasn't culled, which is what we intended. + except asyncio.TimeoutError: + # timeout error means the test passed - the server didn't go away while we were waiting pass + else: + pytest.fail(f"Server at {user_url} got culled prematurely!") From 6b39d025bc9ae73fafa89a38d5cd4870c66b9513 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Apr 2023 06:23:11 +0000 Subject: [PATCH 074/232] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.34.0 → v3.3.1](https://github.com/asottile/pyupgrade/compare/v2.34.0...v3.3.1) - [github.com/psf/black: 22.3.0 → 23.3.0](https://github.com/psf/black/compare/22.3.0...23.3.0) - [github.com/pre-commit/mirrors-prettier: v2.7.1 → v3.0.0-alpha.6](https://github.com/pre-commit/mirrors-prettier/compare/v2.7.1...v3.0.0-alpha.6) - [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0) - [github.com/pycqa/flake8: 4.0.1 → 6.0.0](https://github.com/pycqa/flake8/compare/4.0.1...6.0.0) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e63b7cb..fa480ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v3.3.1 hooks: - id: pyupgrade args: @@ -22,19 +22,19 @@ repos: # Autoformat: Python code - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 23.3.0 hooks: - id: black # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v3.0.0-alpha.6 hooks: - id: prettier # Misc... - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available hooks: # Autoformat: Makes sure files end in a newline and only a newline. @@ -49,6 +49,6 @@ repos: # Lint: Python code - repo: https://github.com/pycqa/flake8 - rev: "4.0.1" + rev: "6.0.0" hooks: - id: flake8 From 82aac067b67205840d818890e216a731eef03c2d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 4 Apr 2023 20:51:22 +0200 Subject: [PATCH 075/232] docs: add dollarmath myst extension Enables use of `$$...$$` math blocks, see https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#math-shortcuts --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index ec5cba6..36823cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -70,6 +70,7 @@ myst_enable_extensions = [ "attrs_inline", "colon_fence", "deflist", + "dollarmath", "fieldlist", ] From 153d575ae52f0ea67acbe13e9b66968b65d0ac10 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 4 Apr 2023 21:25:55 +0200 Subject: [PATCH 076/232] docs: manual formatting fixes following rst to myst conversion --- docs/howto/auth/awscognito.md | 4 +- docs/howto/auth/firstuse.md | 2 +- docs/install/azure.md | 132 +++++++++++++++++----------------- docs/install/index.md | 22 +++--- 4 files changed, 78 insertions(+), 82 deletions(-) diff --git a/docs/howto/auth/awscognito.md b/docs/howto/auth/awscognito.md index 1fed27e..e823647 100644 --- a/docs/howto/auth/awscognito.md +++ b/docs/howto/auth/awscognito.md @@ -40,7 +40,7 @@ application to your `tljh` configuration. By adding following script to the ec2 instance user data you should be able to configure the instance automatically, replace relevant placeholders: -``` +```bash #!/bin/bash ############################################## # Ensure tljh is up to date @@ -85,7 +85,7 @@ Using your preferred editor create the config file: substituting the relevant variables: -``` +```python c.GenericOAuthenticator.client_id = "[your app ID]" c.GenericOAuthenticator.client_secret = "[your app Password]" c.GenericOAuthenticator.oauth_callback_url = "https://[your-jupyterhub-host]/hub/oauth_callback" diff --git a/docs/howto/auth/firstuse.md b/docs/howto/auth/firstuse.md index 4bb814a..edbf958 100644 --- a/docs/howto/auth/firstuse.md +++ b/docs/howto/auth/firstuse.md @@ -13,7 +13,7 @@ the default authenticator that ships with TLJH. the FirstUseAuthenticator is enabled by default in TLJH. ::: -1. Enable the authenticator and reload config to apply the configuration: +Enable the authenticator and reload config to apply the configuration: ```bash sudo tljh-config set auth.type firstuseauthenticator.FirstUseAuthenticator diff --git a/docs/install/azure.md b/docs/install/azure.md index 087e158..7d7c50e 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -29,28 +29,29 @@ your JupyterHub and configuring it, see [The Littlest JupyterHub guide](https:// We start by creating the Virtual Machine in which we can run TLJH (The Littlest JupyterHub). -1. Go to [Azure portal](https://portal.azure.com/) and login with your Azure account. -2. Expand the left-hand panel by clicking on the ">>" button on the top left corner of your dashboard. Find the Virtual Machines tab and click on it. +1. Go to [Azure portal](https://portal.azure.com/) and login with your Azure account. -```{image} ../images/providers/azure/azure-vms.png -:alt: Virtual machines on Azure portal -``` +2. Expand the left-hand panel by clicking on the ">>" button on the top left corner of your dashboard. Find the Virtual Machines tab and click on it. -1. Click **+ add** to create a new Virtual Machine + ```{image} ../images/providers/azure/azure-vms.png + :alt: Virtual machines on Azure portal + ``` - > ```{image} ../images/providers/azure/add-vm.png - > :alt: Add a new virtual machine - > ``` +3. Click **+ add** to create a new Virtual Machine -#. Select **Create VM from Marketplace** in the next screen. + ```{image} ../images/providers/azure/add-vm.png + :alt: Add a new virtual machine + ``` + +4. Select **Create VM from Marketplace** in the next screen. A new screen with all the options for Virtual Machines in Azure will displayed. -> ```{image} ../images/providers/azure/create-vm.png -> :alt: Create VM from the marketplace -> ``` + ```{image} ../images/providers/azure/create-vm.png + :alt: Create VM from the marketplace + ``` -1. **Choose an Ubuntu server for your VM**: - : - Click `Ubuntu Server 22.04 LTS.` +5. **Choose an Ubuntu server for your VM**: + - Click `Ubuntu Server 22.04 LTS.` - Make sure `Resource Manager` is selected in the next screen and click **Create** @@ -58,9 +59,8 @@ A new screen with all the options for Virtual Machines in Azure will displayed. :alt: Ubuntu VM ``` -2. 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. - +6. 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 keep your Azure tools/resources together in an availability region (e.g. WestEurope). If you already have one you'd like to use it select that resource. :::{note} @@ -70,7 +70,6 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ```{image} ../images/providers/azure/new-rg.png :alt: Create a new resource group ``` - - **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". @@ -79,30 +78,29 @@ A new screen with all the options for Virtual Machines in Azure will displayed. - **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 - ``` - + ```{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. -3. 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. +7. 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 - ``` + ```{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) - ::: + :::{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)). -4. Disks (Storage): - : - **Disk options**: select the OS disk type there are options for SDD and HDD. **SSD persistent disk** gives you a faster but more expensive disk than HDD. +8. Disks (Storage): + - **Disk options**: select 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". @@ -115,9 +113,8 @@ A new screen with all the options for Virtual Machines in Azure will displayed. :alt: Choose a disk size ``` -5. Networking - : - **Virtual network**. Leave the default values selected. - +9. 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" @@ -127,24 +124,26 @@ A new screen with all the options for Virtual Machines in Azure will displayed. :alt: Choose networking ports ``` -6. Management - : - Monitoring - : - **Boot diagnostics**. Choose "On". - **OS guest diagnostics**. Choose "Off". - **Diagnostics storage account**. Leave as the default. +10. 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". + - **Enable auto-shutdown**. Choose "Off". - Backup - : - **Backup**. Choose "Off". - - System assigned managed identity Select "Off" + - **Backup**. Choose "Off". + - System assigned managed identity. Select "Off". ```{image} ../images/providers/azure/backup-vm.png :alt: Choose VM Backup ``` -7. Advanced settings - : - **Extensions**. Make sure there are no extensions listed - +11. 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: ```bash @@ -160,14 +159,14 @@ A new screen with all the options for Virtual Machines in Azure will displayed. :alt: Install TLJH ``` - :::{note} - See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. - [](/topic/customizing-installer) documents other options that can be passed to the installer. - ::: + :::{note} + See [](/topic/installer-actions) if you want to understand exactly what the installer is doing. + [](/topic/customizing-installer) documents other options that can be passed to the installer. + ::: -8. Check the summary and confirm the creation of your Virtual Machine. +12. Check the summary and confirm the creation of your Virtual Machine. -9. Check that the creation of your Virtual Machine worked. +13. 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: @@ -176,34 +175,31 @@ A new screen with all the options for Virtual Machines in Azure will displayed. :alt: Deployed VM ``` -10. Note that the Littlest JupyterHub should be installing in the background on your new server. +14. 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. -11. Click on the **Go to resource button** +15. Click on the **Go to resource button** : ```{image} ../images/providers/azure/goto-vm.png :alt: Go to VM - ``` ``` -12. Check if the installation is completed by **copying** the **Public IP address** of your virtual machine, and trying to access it with a browser. +16. Check if the installation is completed 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. - > ```{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. +17. When the installation is complete, it should give you a JupyterHub login page. + ```{image} ../images/first-login.png + :alt: JupyterHub log-in page + ``` -13. When the installation is complete, it should give you a JupyterHub login page. +18. 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. - > ```{image} ../images/first-login.png - > :alt: JupyterHub log-in page - > ``` - -14. 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. - -15. Congratulations, you have a running working JupyterHub! 🎉 +19. Congratulations, you have a running working JupyterHub! 🎉 ## Step 2: Adding more users diff --git a/docs/install/index.md b/docs/install/index.md index 5f4ec71..041ccf6 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -10,14 +10,14 @@ 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. -> ```{toctree} -> :titlesonly: true -> -> digitalocean -> ovh -> jetstream -> google -> amazon -> azure -> custom-server -> ``` +```{toctree} +:titlesonly: true + +digitalocean +ovh +jetstream +google +amazon +azure +custom-server +``` From f7b55c43aa722ed5a97b2401fdf5bd69c1fc39c5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Apr 2023 19:26:43 +0000 Subject: [PATCH 077/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/install/azure.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/install/azure.md b/docs/install/azure.md index 7d7c50e..b5d561b 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -44,13 +44,14 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest ``` 4. Select **Create VM from Marketplace** in the next screen. -A new screen with all the options for Virtual Machines in Azure will displayed. + A new screen with all the options for Virtual Machines in Azure will displayed. - ```{image} ../images/providers/azure/create-vm.png - :alt: Create VM from the marketplace - ``` + ```{image} ../images/providers/azure/create-vm.png + :alt: Create VM from the marketplace + ``` 5. **Choose an Ubuntu server for your VM**: + - Click `Ubuntu Server 22.04 LTS.` - Make sure `Resource Manager` is selected in the next screen and click **Create** @@ -60,6 +61,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ``` 6. 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 keep your Azure tools/resources together in an availability region (e.g. WestEurope). If you already have one you'd like to use it select that resource. @@ -70,6 +72,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ```{image} ../images/providers/azure/new-rg.png :alt: Create a new resource group ``` + - **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". @@ -81,10 +84,12 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ```{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. 7. 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) @@ -100,6 +105,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. - 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)). 8. Disks (Storage): + - **Disk options**: select 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. @@ -114,6 +120,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ``` 9. 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. @@ -125,7 +132,9 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ``` 10. Management + - Monitoring + - **Boot diagnostics**. Choose "On". - **OS guest diagnostics**. Choose "Off". - **Diagnostics storage account**. Leave as the default. @@ -141,6 +150,7 @@ A new screen with all the options for Virtual Machines in Azure will displayed. ``` 11. 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. @@ -181,18 +191,21 @@ A new screen with all the options for Virtual Machines in Azure will displayed. 15. Click on the **Go to resource button** : ```{image} ../images/providers/azure/goto-vm.png :alt: Go to VM + ``` ``` 16. Check if the installation is completed 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. 17. When the installation is complete, it should give you a JupyterHub login page. + ```{image} ../images/first-login.png :alt: JupyterHub log-in page ``` From 77e8544d8093668ee7b78b2042292908fc844a09 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 4 Apr 2023 21:31:24 +0200 Subject: [PATCH 078/232] docs: additional manual fixes for rst to myst --- docs/install/azure.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/install/azure.md b/docs/install/azure.md index b5d561b..9b16f58 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -46,9 +46,9 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest 4. Select **Create VM from Marketplace** in the next screen. A new screen with all the options for Virtual Machines in Azure will displayed. - ```{image} ../images/providers/azure/create-vm.png - :alt: Create VM from the marketplace - ``` + ```{image} ../images/providers/azure/create-vm.png + :alt: Create VM from the marketplace + ``` 5. **Choose an Ubuntu server for your VM**: @@ -177,7 +177,7 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest 12. Check the summary and confirm the creation of your Virtual Machine. 13. Check that the creation of your Virtual Machine worked. - : - Wait for the virtual machine to be created. This might take about 5-10 minutes. + - 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: @@ -186,14 +186,11 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest ``` 14. 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. + It takes around 5-10 minutes for this installation to complete. 15. Click on the **Go to resource button** - : ```{image} ../images/providers/azure/goto-vm.png + ```{image} ../images/providers/azure/goto-vm.png :alt: Go to VM - - ``` - ``` 16. Check if the installation is completed by **copying** the **Public IP address** of your virtual machine, and trying to access it with a browser. From ee0a2c9b667c9d2aa28567411fb3c6371f0c249b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Apr 2023 19:31:35 +0000 Subject: [PATCH 079/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/install/azure.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install/azure.md b/docs/install/azure.md index 9b16f58..39f2cb9 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -177,6 +177,7 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest 12. Check the summary and confirm the creation of your Virtual Machine. 13. 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: @@ -189,6 +190,7 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest It takes around 5-10 minutes for this installation to complete. 15. Click on the **Go to resource button** + ```{image} ../images/providers/azure/goto-vm.png :alt: Go to VM ``` From f9b2a05e1860412381948d45c8248b5b976cc062 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 4 Apr 2023 21:44:58 +0200 Subject: [PATCH 080/232] docs: fix remaining issues following rst to myst transition --- docs/contributing/dev-setup.md | 22 ++++++++--------- docs/howto/admin/https.md | 6 ++--- docs/howto/auth/awscognito.md | 10 ++++---- docs/howto/auth/github.md | 6 ++--- docs/howto/providers/azure.md | 6 ++--- docs/topic/tljh-config.md | 45 +++++++++++++++++----------------- 6 files changed, 47 insertions(+), 48 deletions(-) diff --git a/docs/contributing/dev-setup.md b/docs/contributing/dev-setup.md index cc6e8b1..147c9dd 100644 --- a/docs/contributing/dev-setup.md +++ b/docs/contributing/dev-setup.md @@ -42,21 +42,21 @@ The easiest & safest way to develop & test TLJH is with [Docker](https://www.doc python3 /srv/src/bootstrap/bootstrap.py --admin admin ``` -> Or, if you would like to setup the admin's password during install, -> you can use this command (replace "admin" with the desired admin username -> and "password" with the desired admin password): -> -> > ```console -> > python3 /srv/src/bootstrap/bootstrap.py --admin admin:password -> > ``` -> > -> > The primary hub environment will also be in your PATH already for convenience. + Or, if you would like to setup the admin's password during install, + you can use this command (replace "admin" with the desired admin username + and "password" with the desired admin password): -1. You should be able to access the JupyterHub from your browser now at + ```console + python3 /srv/src/bootstrap/bootstrap.py --admin admin:password + ``` + + The primary hub environment will also be in your PATH already for convenience. + +7. You should be able to access the JupyterHub from your browser now at [http://localhost:12000](http://localhost:12000). Congratulations, you are set up to develop TLJH! -2. Make some changes to the repository. You can test easily depending on what +8. Make some changes to the repository. You can test easily depending on what you changed. - If you changed the `bootstrap/bootstrap.py` script or any of its dependencies, diff --git a/docs/howto/admin/https.md b/docs/howto/admin/https.md index 0c0696d..1cdb459 100644 --- a/docs/howto/admin/https.md +++ b/docs/howto/admin/https.md @@ -21,9 +21,9 @@ To do that, you would have to log in to the website of your registrar and go to the DNS records section. The interface will look like something similar to this: -> ```{image} ../../images/dns.png -> :alt: Adding an entry to the DNS records -> ``` +```{image} ../../images/dns.png +:alt: Adding an entry to the DNS records +``` ::: diff --git a/docs/howto/auth/awscognito.md b/docs/howto/auth/awscognito.md index e823647..ccb8893 100644 --- a/docs/howto/auth/awscognito.md +++ b/docs/howto/auth/awscognito.md @@ -29,11 +29,11 @@ application to your `tljh` configuration. http(s):// ``` - > - **Auth Domain** Create an auth domain e.g. \: - > - > ``` - > https://<.auth.eu-west-1.amazoncognito.com - > ``` + - **Auth Domain** Create an auth domain e.g. \: + + ``` + https://<.auth.eu-west-1.amazoncognito.com + ``` ## Install and configure an AWS EC2 Instance with userdata diff --git a/docs/howto/auth/github.md b/docs/howto/auth/github.md index 04635f8..a1ce701 100644 --- a/docs/howto/auth/github.md +++ b/docs/howto/auth/github.md @@ -29,9 +29,9 @@ You'll need a GitHub account in order to complete these steps. - When you're done filling in the page, it should look something like this: - > ```{image} ../../images/auth/github/create_application.png - > :alt: Create a GitHub OAuth application - > ``` + ```{image} ../../images/auth/github/create_application.png + :alt: Create a GitHub OAuth application + ``` 2. Click "Register application". You'll be taken to a page with the registered application details. diff --git a/docs/howto/providers/azure.md b/docs/howto/providers/azure.md index b61b75d..fd43015 100644 --- a/docs/howto/providers/azure.md +++ b/docs/howto/providers/azure.md @@ -23,9 +23,9 @@ To do either of this: - Click on "Stop" to stop the machine temporarily, or "Delete" to delete it permanently. - > ```{image} ../../images/providers/azure/delete-vm.png - > :alt: Delete vm - > ``` + ```{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. diff --git a/docs/topic/tljh-config.md b/docs/topic/tljh-config.md index 8f672d6..ced4838 100644 --- a/docs/topic/tljh-config.md +++ b/docs/topic/tljh-config.md @@ -58,32 +58,32 @@ Some of the existing `` are listed below by categories: ### Base URL -> Use `base_url` to determine the base URL used by JupyterHub. This parameter will -> be passed straight to `c.JupyterHub.base_url`. +Use `base_url` to determine the base URL used by JupyterHub. This parameter will +be passed straight to `c.JupyterHub.base_url`. (tljh-set-auth)= ### 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. +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. -> -> ```bash -> sudo tljh-config set http.port 8080 -> sudo tljh-config set https.port 8443 -> sudo tljh-config reload proxy -> ``` +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. + +```bash +sudo tljh-config set http.port 8080 +sudo tljh-config set https.port 8443 +sudo tljh-config reload proxy +``` (tljh-set-user-lists)= @@ -138,13 +138,12 @@ Some of the existing `` are listed below by categories: ### User Environment -> `user_environment.default_app` Set default application users are -> launched into. Currently can be set to the following values -> `jupyterlab` or `nteract` -> -> ```bash -> sudo tljh-config set user_environment.default_app jupyterlab -> ``` +`user_environment.default_app` Set default application users are +launched into. Currently this can only be set to: `jupyterlab` + +```bash +sudo tljh-config set user_environment.default_app jupyterlab +``` (tljh-set-extra-user-groups)= From 9e1bc61519824fa5ac3a791d13d236e02d249052 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 4 Apr 2023 21:44:22 +0200 Subject: [PATCH 081/232] breaking, maint: remove deprecated use of nteract-on-jupyter --- docs/contributing/docs.md | 2 +- docs/contributing/packages.md | 2 +- docs/topic/tljh-config.md | 4 ++-- integration-tests/test_extensions.py | 1 - tests/test_configurer.py | 8 -------- tljh/configurer.py | 2 -- tljh/requirements-base.txt | 1 - 7 files changed, 4 insertions(+), 16 deletions(-) diff --git a/docs/contributing/docs.md b/docs/contributing/docs.md index 2b457e8..aba56ae 100644 --- a/docs/contributing/docs.md +++ b/docs/contributing/docs.md @@ -150,7 +150,7 @@ documentation: capitalized except when used in code / the commandline. - **Python** -- when referring to the language, capitalize Python. - **Notebook Interface** -- generic term for referring to JupyterLab, - nteract, classic notebook & other user interfaces for accessing + classic notebook & other user interfaces for accessing. ## Guidelines for markdown files diff --git a/docs/contributing/packages.md b/docs/contributing/packages.md index 84267be..448ab10 100644 --- a/docs/contributing/packages.md +++ b/docs/contributing/packages.md @@ -16,7 +16,7 @@ TLJH sets up two python environments during installation. primarily since conda does not support ARM CPUs and we'd like to support the RaspberryPI someday. Admins generally do not install custom packages in this environment. -2. **User Environment**. Jupyter Notebook, JupyterLab, nteract, kernels, +2. **User Environment**. Jupyter Notebook, JupyterLab, kernels, and packages the users wanna use (such as numpy, scipy, etc) are installed here. A [conda](https://conda.io) environment is used here, since a lot of scientific packages are available from Conda. `pip` is still diff --git a/docs/topic/tljh-config.md b/docs/topic/tljh-config.md index ced4838..f994213 100644 --- a/docs/topic/tljh-config.md +++ b/docs/topic/tljh-config.md @@ -138,8 +138,8 @@ sudo tljh-config reload proxy ### User Environment -`user_environment.default_app` Set default application users are -launched into. Currently this can only be set to: `jupyterlab` +`user_environment.default_app` Set default application users are launched into. +Currently this can only be set to: `classic` and `jupyterlab`. ```bash sudo tljh-config set user_environment.default_app jupyterlab diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py index 96c5ca8..6ed0a9d 100644 --- a/integration-tests/test_extensions.py +++ b/integration-tests/test_extensions.py @@ -14,7 +14,6 @@ def test_serverextensions(): extensions = [ "jupyterlab 3.", "nbgitpuller 1.", - "nteract_on_jupyter 2.1.", "jupyter_resource_usage", ] diff --git a/tests/test_configurer.py b/tests/test_configurer.py index 342fc8b..18586bf 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -69,14 +69,6 @@ def test_app_jupyterlab(): assert c.Spawner.default_url == "/lab" -def test_app_nteract(): - """ - Test setting nteract as default application - """ - c = apply_mock_config({"user_environment": {"default_app": "nteract"}}) - assert c.Spawner.default_url == "/nteract" - - def test_auth_default(): """ Test default authentication settings with no overrides diff --git a/tljh/configurer.py b/tljh/configurer.py index eb32121..e753afa 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -228,8 +228,6 @@ def update_user_environment(c, config): # Set default application users are launched into if user_env["default_app"] == "jupyterlab": c.Spawner.default_url = "/lab" - elif user_env["default_app"] == "nteract": - c.Spawner.default_url = "/nteract" def update_user_account_config(c, config): diff --git a/tljh/requirements-base.txt b/tljh/requirements-base.txt index ba9ff85..1f59947 100644 --- a/tljh/requirements-base.txt +++ b/tljh/requirements-base.txt @@ -10,7 +10,6 @@ jupyterhub==3.* notebook==6.* # Install additional notebook frontends! jupyterlab==3.* -nteract-on-jupyter==2.* # nbgitpuller for easily pulling in Git repositories nbgitpuller==1.* # jupyter-resource-usage to show people how much RAM they are using From 9144828e5b1c3ba7f1ff40bf7b20b63da547233d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 4 Apr 2023 22:58:47 +0200 Subject: [PATCH 082/232] dependabot: monthly updates of github actions --- .github/dependabot.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 28f1c9b..b34f1bd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,8 +9,9 @@ version: 2 updates: # Maintain dependencies in our GitHub Workflows - package-ecosystem: github-actions - directory: "/" # This should be / rather than .github/workflows + directory: / + labels: [ci] schedule: - interval: weekly + interval: monthly time: "05:00" - timezone: "Etc/UTC" + timezone: Etc/UTC From 0324d88da23d7e96c895d0fe2b205c84b77a2871 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 5 Apr 2023 10:19:04 +0200 Subject: [PATCH 083/232] dependabot: rename to .yaml --- .github/{dependabot.yml => dependabot.yaml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/{dependabot.yml => dependabot.yaml} (73%) diff --git a/.github/dependabot.yml b/.github/dependabot.yaml similarity index 73% rename from .github/dependabot.yml rename to .github/dependabot.yaml index b34f1bd..19d8f46 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yaml @@ -1,4 +1,4 @@ -# dependabot.yml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# dependabot.yaml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # # Notes: # - Status and logs from dependabot are provided at From ed334ac050fe3673a09696a96d288497628d2409 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 08:39:52 +0200 Subject: [PATCH 084/232] ci/docs: finalize transition to main branch --- .circleci/config.yml | 2 +- .github/workflows/integration-test.yaml | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 622622e..d344bc3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -113,7 +113,7 @@ jobs: - run: name: Check upgrade testing command: | - if [ "$CIRCLE_BRANCH" == "master" ]; then + if [ "$CIRCLE_BRANCH" == "main" ]; then echo "On master, no upgrade to test..." circleci-agent step halt else diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 940340d..b0ba46a 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -71,7 +71,7 @@ jobs: - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade" distro_image: "ubuntu:22.04" extra_flags: --upgrade - dont_run_on_ref: refs/heads/master + dont_run_on_ref: refs/heads/main integration-tests: needs: decide-on-test-jobs-to-run diff --git a/README.md b/README.md index 46f2d19..d16de87 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Documentation build status](https://img.shields.io/readthedocs/the-littlest-jupyterhub?logo=read-the-docs)](https://tljh.jupyter.org/en/latest/?badge=latest) [![GitHub Workflow Status - Test](https://img.shields.io/github/workflow/status/jupyterhub/the-littlest-jupyterhub/Unit%20tests?logo=github&label=tests)](https://github.com/jupyterhub/the-littlest-jupyterhub/actions) -[![Test coverage of code](https://codecov.io/gh/jupyterhub/the-littlest-jupyterhub/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyterhub/the-littlest-jupyterhub) +[![Test coverage of code](https://codecov.io/gh/jupyterhub/the-littlest-jupyterhub/branch/main/graph/badge.svg)](https://codecov.io/gh/jupyterhub/the-littlest-jupyterhub) [![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/the-littlest-jupyterhub/issues) [![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub/tljh) [![Gitter](https://img.shields.io/badge/social_chat-gitter-blue?logo=gitter)](https://gitter.im/jupyterhub/jupyterhub) From 7859e7f01b1bd30d7a358ae4c316469df03b08dc Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 08:50:20 +0200 Subject: [PATCH 085/232] refactor: name pip_bin etc to clarify the python environment --- bootstrap/bootstrap.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 67cf46c..29f7d56 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -356,10 +356,10 @@ def main(): # Various related constants install_prefix = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") - hub_prefix = os.path.join(install_prefix, "hub") - python_bin = os.path.join(hub_prefix, "bin", "python3") - pip_bin = os.path.join(hub_prefix, "bin", "pip") - initial_setup = not os.path.exists(python_bin) + hub_env_prefix = os.path.join(install_prefix, "hub") + hub_env_python = os.path.join(hub_env_prefix, "bin", "python3") + hub_env_pip = os.path.join(hub_env_prefix, "bin", "pip") + initial_setup = not os.path.exists(hub_env_python) # Attempt to start a web server to serve a progress page reporting # installation progress. @@ -451,18 +451,18 @@ def main(): env=apt_get_adjusted_env, ) - logger.info("Setting up virtual environment at {}".format(hub_prefix)) - os.makedirs(hub_prefix, exist_ok=True) - run_subprocess(["python3", "-m", "venv", hub_prefix]) + logger.info("Setting up virtual environment at {}".format(hub_env_prefix)) + os.makedirs(hub_env_prefix, exist_ok=True) + run_subprocess(["python3", "-m", "venv", hub_env_prefix]) # Upgrade pip # Keep pip version pinning in sync with the one in unit-test.yml! # See changelog at https://pip.pypa.io/en/latest/news/#changelog logger.info("Upgrading pip...") - run_subprocess([pip_bin, "install", "--upgrade", "pip==21.3.*"]) + run_subprocess([hub_env_pip, "install", "--upgrade", "pip==21.3.*"]) # Install/upgrade TLJH installer - tljh_install_cmd = [pip_bin, "install", "--upgrade"] + tljh_install_cmd = [hub_env_pip, "install", "--upgrade"] if os.environ.get("TLJH_BOOTSTRAP_DEV", "no") == "yes": logger.info("Selected TLJH_BOOTSTRAP_DEV=yes...") tljh_install_cmd.append("--editable") @@ -484,7 +484,9 @@ def main(): # Run TLJH installer logger.info("Running TLJH installer...") - os.execv(python_bin, [python_bin, "-m", "tljh.installer"] + tljh_installer_flags) + os.execv( + hub_env_python, [hub_env_python, "-m", "tljh.installer"] + tljh_installer_flags + ) if __name__ == "__main__": From 17bbd6116b9fe83618030a6560b1c4c35b3d12e2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 10:27:39 +0200 Subject: [PATCH 086/232] bootstrap.py: update documentation for consistency --- .github/workflows/integration-test.yaml | 1 - bootstrap/bootstrap.py | 26 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index b0ba46a..8be0a08 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -71,7 +71,6 @@ jobs: - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade" distro_image: "ubuntu:22.04" extra_flags: --upgrade - dont_run_on_ref: refs/heads/main integration-tests: needs: decide-on-test-jobs-to-run diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 29f7d56..ef9f602 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -26,7 +26,7 @@ Environment variables: installing the tljh installer. Pass the values yes or no. -Command line flags: +Command line flags, from "bootstrap.py --help": The bootstrap.py script accept the following command line flags. All other flags are passed through to the tljh installer without interception by this @@ -36,6 +36,11 @@ Command line flags: logs can be accessed during installation. If this is passed, it will pass --progress-page-server-pid= to the tljh installer for later termination. + --version TLJH version or Git reference. Default 'latest' is + the most recent release. Partial versions can be + specified, for example '1', '1.0' or '1.0.0'. You + can also pass a branch name such as 'main' or a + commit hash. """ from argparse import ArgumentParser import os @@ -340,8 +345,23 @@ def main(): """ distro, version = ensure_host_system_can_install_tljh() - parser = ArgumentParser() - parser.add_argument("--show-progress-page", action="store_true") + parser = ArgumentParser( + description=( + "The bootstrap.py script accept the following command line flags. " + "All other flags are passed through to the tljh installer without " + "interception by this script." + ) + ) + parser.add_argument( + "--show-progress-page", + action="store_true", + help=( + "Starts a local web server listening on port 80 where logs can be " + "accessed during installation. If this is passed, it will pass " + "--progress-page-server-pid= to the tljh installer for later " + "termination." + ), + ) parser.add_argument( "--version", default="latest", From 8e94bbe468407890dc2ee9f337bfbae38e41e550 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 10:28:38 +0200 Subject: [PATCH 087/232] ci: add upgrade tests from: main, latest, and 0.2.0 --- .github/integration-test.py | 42 +++++++++++++++++++------ .github/workflows/integration-test.yaml | 12 +++++-- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index 25af580..f3c42b7 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -122,10 +122,16 @@ def copy_to_container(container_name, src_path, dest_path): def run_test( - image_name, test_name, bootstrap_pip_spec, test_files, upgrade, installer_args + image_name, + test_name, + bootstrap_pip_spec, + test_files, + upgrade_from, + installer_args, ): """ - Wrapper that sets up tljh with installer_args & runs test_name + Starts a new container based on image_name, runs the bootstrap script to + setup tljh with installer_args, and runs test_name. """ stop_container(test_name) run_systemd_image(image_name, test_name, bootstrap_pip_spec) @@ -144,12 +150,26 @@ def run_test( print(container_check_output(["logs", test_name]).decode()) print(f"--- End of logs from the container: {test_name}") - # Install TLJH from the default branch first to test upgrades - if upgrade: + # To test upgrades, we run a bootstrap.py script two times instead of one, + # where the initial run first installs some older version. + # + # We want to support testing a PR by upgrading from "main", "latest" (latest + # released version), and from a previous major-like version. + # + # FIXME: We currently always rely on the main branch's bootstrap.py script. + # Realistically, we should run previous versions of the bootstrap + # script which also installs previous versions of TLJH. + # + # 2023-04-15 Erik observed that https://tljh.jupyter.org/bootstrap.py + # is referencing to the master (now main) branch which didn't seem + # obvious, thinking it could have been the latest released version + # also. + # + if upgrade_from: run_container_command( - test_name, "curl -L https://tljh.jupyter.org/bootstrap.py | python3 -" + test_name, + f"curl -L https://tljh.jupyter.org/bootstrap.py | python3 - --version={upgrade_from}", ) - run_container_command(test_name, f"python3 /srv/src/bootstrap.py {installer_args}") # Install pkgs from requirements in hub's pip, where @@ -192,9 +212,11 @@ def main(): dest="build_args", ) - subparsers.add_parser("stop-container").add_argument("container_name") + stop_container_parser = subparsers.add_parser("stop-container") + stop_container_parser.add_argument("container_name") - subparsers.add_parser("start-container").add_argument("container_name") + start_container_parser = subparsers.add_parser("start-container") + start_container_parser.add_argument("container_name") run_parser = subparsers.add_parser("run") run_parser.add_argument("container_name") @@ -207,7 +229,7 @@ def main(): run_test_parser = subparsers.add_parser("run-test") run_test_parser.add_argument("--installer-args", default="") - run_test_parser.add_argument("--upgrade", action="store_true") + run_test_parser.add_argument("--upgrade-from", default="") run_test_parser.add_argument( "--bootstrap-pip-spec", nargs="?", default="", type=str ) @@ -227,7 +249,7 @@ def main(): args.test_name, args.bootstrap_pip_spec, args.test_files, - args.upgrade, + args.upgrade_from, args.installer_args, ) elif args.action == "show-logs": diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 8be0a08..cf8613c 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -68,9 +68,15 @@ jobs: - name: "Int. tests: Ubuntu 22.04 Py 3.10" distro_image: "ubuntu:22.04" extra_flags: "" - - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade" + - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade-from=main" distro_image: "ubuntu:22.04" - extra_flags: --upgrade + extra_flags: --upgrade-from=main + - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade-from=latest" + distro_image: "ubuntu:22.04" + extra_flags: --upgrade-from=latest + - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade-from=0.2.0" + distro_image: "ubuntu:22.04" + extra_flags: --upgrade-from=0.2.0 integration-tests: needs: decide-on-test-jobs-to-run @@ -106,7 +112,7 @@ jobs: # integration-tests/test_bootstrap.py will build and start containers # based on this environment variable. This is similar to how # .github/integration-test.py build-image can take a --build-arg - # setting the base image. + # setting the base image via a Dockerfile ARG. BASE_IMAGE: ${{ matrix.distro_image }} # We build a docker image from wherein we will work From d531a127d841f726afd04c7e03c0728dea6014b4 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 10:44:14 +0200 Subject: [PATCH 088/232] ci: shorten job names --- .github/workflows/integration-test.yaml | 12 ++++++------ .github/workflows/unit-test.yaml | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index cf8613c..eb318a4 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -58,23 +58,23 @@ jobs: echo $matrix_post_filter | jq -C '.' env: matrix_include_pre_filter: | - - name: "Int. tests: Debian 11, Py 3.9" + - name: "Debian 11, Py 3.9" distro_image: "debian:11" runs_on: "ubuntu-22.04" extra_flags: "" - - name: "Int. tests: Ubuntu 20.04, Py 3.8" + - name: "Ubuntu 20.04, Py 3.8" distro_image: "ubuntu:20.04" extra_flags: "" - - name: "Int. tests: Ubuntu 22.04 Py 3.10" + - name: "Ubuntu 22.04 Py 3.10" distro_image: "ubuntu:22.04" extra_flags: "" - - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade-from=main" + - name: "Ubuntu 22.04, Py 3.10, from main" distro_image: "ubuntu:22.04" extra_flags: --upgrade-from=main - - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade-from=latest" + - name: "Ubuntu 22.04, Py 3.10, from latest" distro_image: "ubuntu:22.04" extra_flags: --upgrade-from=latest - - name: "Int. tests: Ubuntu 22.04, Py 3.10, --upgrade-from=0.2.0" + - name: "Ubuntu 22.04, Py 3.10, from 0.2.0" distro_image: "ubuntu:22.04" extra_flags: --upgrade-from=0.2.0 diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index a0acde8..f0ac849 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -42,10 +42,10 @@ jobs: fail-fast: false matrix: include: - - name: "Unit tests: Ubuntu 20.04, Py 3.9" + - name: "Ubuntu 20.04, Py 3.9" ubuntu_version: "20.04" python_version: "3.9" - - name: "Unit tests: Ubuntu 22.04, Py 3.10" + - name: "Ubuntu 22.04, Py 3.10" ubuntu_version: "22.04" python_version: "3.10" From 6ecbe083d537465547b60eb8b10851881bd84738 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 10:37:58 +0200 Subject: [PATCH 089/232] ci: use non-deprecated codecov action --- .github/workflows/unit-test.yaml | 3 +-- dev-requirements.txt | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index f0ac849..f082164 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -97,5 +97,4 @@ jobs: run: pytest --verbose --maxfail=2 --color=yes --durations=10 --cov=tljh tests/ timeout-minutes: 15 - - name: Upload code coverage stats - run: codecov + - uses: codecov/codecov-action@v3 diff --git a/dev-requirements.txt b/dev-requirements.txt index 0c50f19..e1e3068 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,3 @@ pytest pytest-cov pytest-mock -codecov From f219acd041a9081ed604ebb4ef2e880c26fde954 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 10:56:44 +0200 Subject: [PATCH 090/232] ci: remove unimportant and complicated job filtering --- .github/workflows/integration-test.yaml | 78 +++++++------------------ 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index eb318a4..3cc8672 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -24,63 +24,7 @@ on: workflow_dispatch: jobs: - # This job is used as a workaround to a limitation when using a matrix of - # variations that a job should be executed against. The limitation is that a - # matrix once defined can't include any conditions. - # - # What this job does before our real test job with a matrix of variations run, - # is to decide on that matrix of variations a conditional logic of our choice. - # - # For more details, see this excellent stack overflow answer: - # https://stackoverflow.com/a/65434401/2220152 - # - decide-on-test-jobs-to-run: - name: Decide on test jobs to run - runs-on: ubuntu-latest - - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - - steps: - # Currently, this logic filters out a matrix entry equaling a specific git - # reference identified by "dont_run_on_ref". - - name: Decide on test jobs to run - id: set-matrix - run: | - matrix_post_filter=$( - echo "$matrix_include_pre_filter" \ - | yq e --output-format=json '.' - \ - | jq -c '{"include": map( . | select(.dont_run_on_ref != "${{ github.ref }}" ))}' - ) - echo "matrix=$matrix_post_filter" >> $GITHUB_OUTPUT - - echo "The subsequent job's matrix are:" - echo $matrix_post_filter | jq -C '.' - env: - matrix_include_pre_filter: | - - name: "Debian 11, Py 3.9" - distro_image: "debian:11" - runs_on: "ubuntu-22.04" - extra_flags: "" - - name: "Ubuntu 20.04, Py 3.8" - distro_image: "ubuntu:20.04" - extra_flags: "" - - name: "Ubuntu 22.04 Py 3.10" - distro_image: "ubuntu:22.04" - extra_flags: "" - - name: "Ubuntu 22.04, Py 3.10, from main" - distro_image: "ubuntu:22.04" - extra_flags: --upgrade-from=main - - name: "Ubuntu 22.04, Py 3.10, from latest" - distro_image: "ubuntu:22.04" - extra_flags: --upgrade-from=latest - - name: "Ubuntu 22.04, Py 3.10, from 0.2.0" - distro_image: "ubuntu:22.04" - extra_flags: --upgrade-from=0.2.0 - integration-tests: - needs: decide-on-test-jobs-to-run - # integration tests run in a container, # not in the worker, so this version is not relevant to the tests # and can be the same for all tested versions @@ -89,7 +33,27 @@ jobs: name: ${{ matrix.name }} strategy: fail-fast: false - matrix: ${{ fromJson(needs.decide-on-test-jobs-to-run.outputs.matrix) }} + matrix: + include: + - name: "Debian 11, Py 3.9" + distro_image: "debian:11" + runs_on: "ubuntu-22.04" + extra_flags: "" + - name: "Ubuntu 20.04, Py 3.8" + distro_image: "ubuntu:20.04" + extra_flags: "" + - name: "Ubuntu 22.04 Py 3.10" + distro_image: "ubuntu:22.04" + extra_flags: "" + - name: "Ubuntu 22.04, Py 3.10, from main" + distro_image: "ubuntu:22.04" + extra_flags: --upgrade-from=main + - name: "Ubuntu 22.04, Py 3.10, from latest" + distro_image: "ubuntu:22.04" + extra_flags: --upgrade-from=latest + - name: "Ubuntu 22.04, Py 3.10, from 0.2.0" + distro_image: "ubuntu:22.04" + extra_flags: --upgrade-from=0.2.0 steps: - uses: actions/checkout@v3 From 6883316c363fe55fef0606dfe6ec924e5b21bb5c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 11:06:29 +0200 Subject: [PATCH 091/232] Update mambaforge from 22.11.1-4 to 23.1.0-1 --- tests/test_installer.py | 10 +++++----- tljh/installer.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index dcffa71..a99db48 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -117,8 +117,8 @@ def _specifier(version): None, { "python": "3.10.*", - "conda": "22.11.1", - "mamba": "1.1.0", + "conda": "23.1.0", + "mamba": "1.4.1", }, ), # previous install, 1.0 @@ -127,8 +127,8 @@ def _specifier(version): "22.11.1-4", { "python": "3.10.*", - "conda": "22.11.1", - "mamba": "1.1.0", + "conda": "23.1.0", + "mamba": "1.4.1", }, ), # 0.2 install, no upgrade needed @@ -149,7 +149,7 @@ def _specifier(version): "4.10.3-7", { "conda": "4.10.3", - "mamba": ">=1.1.0", + "mamba": ">=1.4.1", "python": "3.9.*", }, ), diff --git a/tljh/installer.py b/tljh/installer.py index 90232d6..bbd0753 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -158,11 +158,11 @@ def ensure_usergroups(): # Install mambaforge using an installer from # https://github.com/conda-forge/miniforge/releases -MAMBAFORGE_VERSION = "22.11.1-4" +MAMBAFORGE_VERSION = "23.1.0-1" # sha256 checksums MAMBAFORGE_CHECKSUMS = { - "aarch64": "96191001f27e0cc76612d4498d34f9f656d8a7dddee44617159e42558651479c", - "x86_64": "16c7d256de783ceeb39970e675efa4a8eb830dcbb83187f1197abfea0bf07d30", + "aarch64": "d9d89c9e349369702171008d9ee7c5ce80ed420e5af60bd150a3db4bf674443a", + "x86_64": "cfb16c47dc2d115c8b114280aa605e322173f029fdb847a45348bf4bd23c62ab", } # minimum versions of packages From acd420765a000574471f50eb2b6cf47bb35b460f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 15 Apr 2023 11:45:44 +0200 Subject: [PATCH 092/232] ci: cleanup no longer needed .rst file type reference --- .github/workflows/integration-test.yaml | 2 -- .github/workflows/unit-test.yaml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 3cc8672..4c04c11 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -8,14 +8,12 @@ on: paths-ignore: - "docs/**" - "**.md" - - "**.rst" - ".github/workflows/*" - "!.github/workflows/integration-test.yaml" push: paths-ignore: - "docs/**" - "**.md" - - "**.rst" - ".github/workflows/*" - "!.github/workflows/integration-test.yaml" branches-ignore: diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index f082164..e93c0ce 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -8,14 +8,12 @@ on: paths-ignore: - "docs/**" - "**.md" - - "**.rst" - ".github/workflows/*" - "!.github/workflows/unit-test.yaml" push: paths-ignore: - "docs/**" - "**.md" - - "**.rst" - ".github/workflows/*" - "!.github/workflows/unit-test.yaml" branches-ignore: From 438df10e1a19dca7a855aea75064b3cd86bded4e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 17 Apr 2023 10:26:38 +0200 Subject: [PATCH 093/232] Fix tests for update of mambaforge from 22.11.1-4 to 23.1.0-1 --- tests/test_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index a99db48..e7af2ca 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -124,7 +124,7 @@ def _specifier(version): # previous install, 1.0 ( "mambaforge", - "22.11.1-4", + "23.1.0-1", { "python": "3.10.*", "conda": "23.1.0", From f13a31c20c0e8e4b5e9faea9b4c7a238e2b8362b Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 17 Apr 2023 10:56:24 +0200 Subject: [PATCH 094/232] update upgrade expectation for test_installer it may not upgrade all the way to 1.4 if other packages are pinned in the base env --- tests/test_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index e7af2ca..7675bd0 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -149,7 +149,7 @@ def _specifier(version): "4.10.3-7", { "conda": "4.10.3", - "mamba": ">=1.4.1", + "mamba": ">=1.1.0", "python": "3.9.*", }, ), From 2fcd8efde3b983959b501bd5ff9bcc3183a46284 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 17 Apr 2023 11:00:18 +0200 Subject: [PATCH 095/232] increase test timeout 15 minutes isn't always enough for integration tests --- .github/workflows/integration-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 4c04c11..1b7939e 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -69,7 +69,7 @@ jobs: run: | pytest --verbose --maxfail=2 --color=yes --durations=10 --capture=no \ integration-tests/test_bootstrap.py - timeout-minutes: 15 + timeout-minutes: 20 env: # integration-tests/test_bootstrap.py will build and start containers # based on this environment variable. This is similar to how From 7cc4ebc7b21ad64ac60e92b579bc5c7aa7610977 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 17 Apr 2023 15:08:07 +0200 Subject: [PATCH 096/232] docs: fix readme badge for tests --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d16de87..47c8a0f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # The Littlest JupyterHub [![Documentation build status](https://img.shields.io/readthedocs/the-littlest-jupyterhub?logo=read-the-docs)](https://tljh.jupyter.org/en/latest/?badge=latest) -[![GitHub Workflow Status - Test](https://img.shields.io/github/workflow/status/jupyterhub/the-littlest-jupyterhub/Unit%20tests?logo=github&label=tests)](https://github.com/jupyterhub/the-littlest-jupyterhub/actions) +[![GitHub Workflow Status - Test](https://img.shields.io/github/actions/workflow/status/jupyterhub/the-littlest-jupyterhub/integration-test.yaml?logo=github&label=tests)](https://github.com/jupyterhub/the-littlest-jupyterhub/actions) [![Test coverage of code](https://codecov.io/gh/jupyterhub/the-littlest-jupyterhub/branch/main/graph/badge.svg)](https://codecov.io/gh/jupyterhub/the-littlest-jupyterhub) [![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/the-littlest-jupyterhub/issues) [![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub/tljh) From ad5743c837351eb4e31cdbe05da0b80f22953df7 Mon Sep 17 00:00:00 2001 From: Romain Heller Date: Sun, 23 Apr 2023 21:52:47 +0200 Subject: [PATCH 097/232] Typo : username -> admin-user-name --- docs/install/azure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install/azure.md b/docs/install/azure.md index 39f2cb9..90f9c98 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -163,7 +163,7 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest --admin ``` - where the `username` is the root username you chose for your Virtual Machine. + where the `admin-user-name` is the root username you chose for your Virtual Machine. ```{image} ../images/providers/azure/cloudinit-vm.png :alt: Install TLJH From fc34bc74aacc802af746c1298bf1f88ce1b83880 Mon Sep 17 00:00:00 2001 From: stevejpurves Date: Mon, 13 Feb 2023 11:59:30 +0000 Subject: [PATCH 098/232] added `remove_named_servers` setting --- docs/topic/idle-culler.md | 16 ++++++++++++++++ tljh/configurer.py | 3 +++ 2 files changed, 19 insertions(+) diff --git a/docs/topic/idle-culler.md b/docs/topic/idle-culler.md index ecb0243..89e400a 100644 --- a/docs/topic/idle-culler.md +++ b/docs/topic/idle-culler.md @@ -40,6 +40,12 @@ the users will not be culled alongside their notebooks and will continue to exis services.cull.users = False ``` +If named servers are in use, they are not removed after being culled. + +```python +services.cull.remove_named_servers = False +``` + ## Configuring the idle culler The available configuration options are: @@ -76,6 +82,16 @@ sudo tljh-config set services.cull.max_age sudo tljh-config reload ``` +### Remove Named Servers + +Remove named servers after they are shutdown. Only applies if named servers are +enabled on the hub installation: + +```bash +sudo tljh-config set services.cull.remove_named_servers True +sudo tljh-config reload +``` + ### User culling In addition to servers, it is also possible to cull the users. This is usually diff --git a/tljh/configurer.py b/tljh/configurer.py index e753afa..f731ee7 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -59,6 +59,7 @@ default = { "concurrency": 5, "users": False, "max_age": 0, + "remove_named_servers": False }, "configurator": {"enabled": False}, }, @@ -256,6 +257,8 @@ def set_cull_idle_service(config): cull_cmd += ["--max-age=%d" % cull_config["max_age"]] if cull_config["users"]: cull_cmd += ["--cull-users"] + if cull_config["remove_named_servers"]: + cull_cmd += ["--remove-named-servers"] cull_service = { "name": "cull-idle", From 3f180a939fd1fc675198d0d95db76e333d3a0f2b Mon Sep 17 00:00:00 2001 From: stevejpurves Date: Mon, 20 Mar 2023 17:32:24 +0000 Subject: [PATCH 099/232] =?UTF-8?q?=F0=9F=A7=AA=20add=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_configurer.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_configurer.py b/tests/test_configurer.py index 18586bf..e2f5179 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -193,6 +193,32 @@ def test_cull_service_default(): } ] +def test_cull_service_named(): + """ + Test default cull service settings with named server removal + """ + c = apply_mock_config( + {"services": {"cull": {"every": 10, "remove_named_servers": True, "max_age": 60}}} + ) + + cull_cmd = [ + sys.executable, + "-m", + "jupyterhub_idle_culler", + "--timeout=600", + "--cull-every=10", + "--concurrency=5", + "--max-age=60", + "--remove-named-servers", + ] + assert c.JupyterHub.services == [ + { + "name": "cull-idle", + "admin": True, + "command": cull_cmd, + } + ] + def test_set_cull_service(): """ From 836056f404fcd9f35e69267718db9d17c7144453 Mon Sep 17 00:00:00 2001 From: stevejpurves Date: Mon, 20 Mar 2023 17:53:31 +0000 Subject: [PATCH 100/232] =?UTF-8?q?=F0=9F=92=9A=20green=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_configurer.py | 53 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/tests/test_configurer.py b/tests/test_configurer.py index e2f5179..e05d051 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -193,33 +193,6 @@ def test_cull_service_default(): } ] -def test_cull_service_named(): - """ - Test default cull service settings with named server removal - """ - c = apply_mock_config( - {"services": {"cull": {"every": 10, "remove_named_servers": True, "max_age": 60}}} - ) - - cull_cmd = [ - sys.executable, - "-m", - "jupyterhub_idle_culler", - "--timeout=600", - "--cull-every=10", - "--concurrency=5", - "--max-age=60", - "--remove-named-servers", - ] - assert c.JupyterHub.services == [ - { - "name": "cull-idle", - "admin": True, - "command": cull_cmd, - } - ] - - def test_set_cull_service(): """ Test setting cull service options @@ -245,6 +218,32 @@ def test_set_cull_service(): } ] +def test_cull_service_named(): + """ + Test default cull service settings with named server removal + """ + c = apply_mock_config( + {"services": {"cull": {"every": 10, "cull_users": True, "remove_named_servers": True, "max_age": 60}}} + ) + + cull_cmd = [ + sys.executable, + "-m", + "jupyterhub_idle_culler", + "--timeout=600", + "--cull-every=10", + "--concurrency=5", + "--max-age=60", + "--cull-users", + "--remove-named-servers", + ] + assert c.JupyterHub.services == [ + { + "name": "cull-idle", + "admin": True, + "command": cull_cmd, + } + ] def test_load_secrets(tljh_dir): """ From e6994aab2da54b427328ec8168af933791f28002 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Apr 2023 00:10:25 +0000 Subject: [PATCH 101/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_configurer.py | 14 +++++++++++++- tljh/configurer.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_configurer.py b/tests/test_configurer.py index e05d051..bbef8c4 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -193,6 +193,7 @@ def test_cull_service_default(): } ] + def test_set_cull_service(): """ Test setting cull service options @@ -218,12 +219,22 @@ def test_set_cull_service(): } ] + def test_cull_service_named(): """ Test default cull service settings with named server removal """ c = apply_mock_config( - {"services": {"cull": {"every": 10, "cull_users": True, "remove_named_servers": True, "max_age": 60}}} + { + "services": { + "cull": { + "every": 10, + "cull_users": True, + "remove_named_servers": True, + "max_age": 60, + } + } + } ) cull_cmd = [ @@ -245,6 +256,7 @@ def test_cull_service_named(): } ] + def test_load_secrets(tljh_dir): """ Test loading secret files diff --git a/tljh/configurer.py b/tljh/configurer.py index f731ee7..e5c2ea2 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -59,7 +59,7 @@ default = { "concurrency": 5, "users": False, "max_age": 0, - "remove_named_servers": False + "remove_named_servers": False, }, "configurator": {"enabled": False}, }, From b9e3cfe86765f0318dd748629670c29d32b4be2b Mon Sep 17 00:00:00 2001 From: Travis Briggs Date: Mon, 24 Apr 2023 19:26:56 -0700 Subject: [PATCH 102/232] Update cost of Digital Ocean server and add new screenshot of Advanced Options -> User Data --- .../digitalocean/additional-options.png | Bin 12484 -> 156361 bytes docs/install/digitalocean.md | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/images/providers/digitalocean/additional-options.png b/docs/images/providers/digitalocean/additional-options.png index 70660a27db9750976dbf7a75b736efb0a2cea3c5..a0610f7a179542641a240c7b022566ad0d7f50ad 100644 GIT binary patch literal 156361 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 literal 12484 zcmc(FcT|&IvoC_YDhNnXq-#KtCLoVNz480jy60W%tb5N{=Z|~y$KLyyXJ+Y9rx1i)w1yr z?N|(-0GxHssv3&k)wsl|HZH*@3atv+4LoAS9#|<9p&>;PT%JE^dxh||-{`MBaeWAE za^n5Pzd1XDNP9s=R_MqwEslG58%_CS;ZvxNV@hyMe@zoh@E1e;=c&%16egSiIh-mWrc3)0XGFq~v z_jk|lp@LW6p0~tZon{D>9+TPv9gd{@?yq~O@2yUfOn&ZMiM=z{UL&)+x|YDh!_gu> zFe;yP%UG(M?ggLI%p)(Zga)V0xo4Xqt4>y%ADpcgk$={Qpx&dkRD3dq``75ip@lK* zyO46)!C8mXaZLk!+B@>$fX1=5nO!Tx&8 zfBctU`^oksqahCanHy=9 ze!)XiLYXMXm6fRLff(~Qf9(m(<(IbDF1jrV+ofRKQ^t`BEuWLiG*Yf9((`} zl>z4BDyo%|l?xr|#%x-ZGUrWc{_kHr>B!XgI*_iS|9h2|^Dn3!uLB5lfbui%Kg^4$ ze3asN)|_H#(e$~}V?PdY1y8Z5kY_TXZ#|a?aE=@|4fwJJKoi7w)I(dowSOuOUUH|$ z;C)K!CT!$$()>RB{LrlR8)bQYxA$EfQYJT-hww7+TfA4+y3Ga8Iw)rYv%6AgtB<{ zFJFymP(o#{TyJC}%L%Iak)L|T%QL9fKJ^!TJDfP^)o9(EGDw2K-iYn_p_Y^V_Y6aN zDNr550?{lK$xWKUjsi%HkoFH8ztFkAhQ2-`)AH9zF;I^csBv`+{)DzP7}gzH4=BdK zK+YC^XHZ2xnV4~U6qsK-YhZvaxnWaYgb+Jnt$ngEy^v5I8hF`@icc}`8MbW`=)-x! znTtwINbo|SlsnI6uMd3I;m!6dt9Hbkr1$qGHB+07TrRusI?o(kLVE4E^{#lAKTXZ^ z2i3K;U;PS3BtV&cTI2WDcWzRf`@OD4L%mJALyV5bQSEc+r%yF_npM)fyc&g<7pC8J z{MD|Ao)vU1Y$)>m2^nmGm>U!wxD2+(e28B6LH5*!oy2PHh4Gk|YS$G6+E=5DNMYH_Kq3s|Qm(#pa6+l_lTAk)|rO00Qy01}zh@vf=9 z{AQG*oRSn(FmAcl%1X6UB7C}>;sS@Mm{2@jHMLVxmVZcm>UjE0Eo|6kU5AraZ0B}= z>D%OY8Qp~+C)bPQ&dP9DVs?penobFXE&6`@t-eB5eMJb?ol*rQ7<5@t#nV@dT}GLo z_NvJ>*+sgnNmA#?Svlk?B1OF}We$3&+})|D@!3f4H|yt(+wfKk--t|lEUuVJn--Wk zVDQuFsZ_iw`5jYaN+3)s;*)5@cdm(?v`1!4$eL4*WZS$aAougR@L=OS#SD*l+x~&x z@j}x_ygfoC9Ip{wn|pgjLiO|RJ4=lhJSEf{_Qir;(@RX#HBG6%ZF?*4Q?Kll$QT!m zf=>!LGQ`W0akwjX${NoO;vbp_3;KjZrIb22(Y9aJSFw=tM%9i2--_&U2L@m(UT zI6F1ncg+dyI^X4$Bw-*p6p*#?R4QEOrQ8n*`&L9*m#+Q0vuP!USmsy0P4v z$lV{IY~codo7!Nx9;C7omnI4qnv1ER;V*K&kPhf)29Hf)1dzHD-nxa_@7rK?@9l4r z)c)*>oKJo6^{2J(7qh>0JKzG;oT*JiMQ;bE6hV(@#;gFD5KK@D^+A()A_WfrqZoL9!Mx*Jzxt2$HqEd8YS7;5C(mpE!R%7oI=zd9LCMfELl|I zi{9F*Ew{R*KPfS7`8CAw;BLRMl#|{!Ddh@t;#Pz1s6{=+!B@0yWZVQcna!7+iMDV zIUrMhw^a#3(Jv(t42ji;yrOgFWw6K$2#^YLIC-`S>q`#XhHPM zQYsUm1!>~LYhO!c`L&(cSV5eW8+}UKlZv5+ynxL%8%jw2wDEt{L%~j}^7->;NmC-1 zMBp{3)53rSj^U5`(16!-u}f)w z;ASxQB;)9JG4u?Vw1j+d?O~fEDd(@o9ilD8j+W&;$XsEw`@s|&RqsJY>%V+eX@eHS z0m1$QD~xWf=zXiSNrCgv_Ya$$r!nDWaY*jPQ4!l{##ec8cv0mGhe^MdF6m45r~ z1w0sHqr7b2l*8Q|;)+0EMgqU_EoBgCB*>FA2+P z63`i>QNn5|Akdioo^x3p;)lB_vc?UtgpIgw*v}Uukh(3i7R`G^Z{-=UMs8Y@+28uu zW^N&GdFeZF+KgbHr;SgNhk7_G^~IY0`#SFgRt-bO=ch68#T zgl%Yq=hu_eHdZE+MH%Fi#+B-3b`qr;37Ii4@5X6a6QaC*f4A$rkZt@m;W~psvUI3T z(Obs(kFiWHdcO9J6dk(%7H@)fRzUJf;dN?Aj2S%|V$9*2TsMgwX<)mae z%}~WA#mc|uUisXz{Z6E;d*fGpa@b2)LQBdxx-0a#Et2ZeLuu%$XU@9*X49W1A~%Ae z{S((x;PVd%49_o-6qNp4;A^d~=3iyYU!E;A)?dvubHqPW2&|q&tsj#kPpT)5t0&ZL zIYhiKf4sgtzPj_d;+$bf62EY*ry`i4yr)HU@D0XqF?Jx3=9AgJ@QG_ont-YDex{ER z?*rEPfr$2Tg--l%+YMJ>bGG)1f|va=ANdE+0h-unzw~QbIXTT(5>20z&T>HqCA-?M zru%ZZQk^mL82W53&30*otqn&N{K2d;Cn+hTT(n3CNw+f~XoQ$!Tf!X|RuUmqu{SPqt=R6jt2hEtl(E4N1a$7x)C^Yr zQq3S1J?*2H(EDRof%6vr*wLpzp(?=_+IgfRF)00jzzMX`CNn@@9%8Tc#oRDHJIYc} z3vzMLi#_CrP(|+Zoszp+Dk0#l>Jib*(|cMM2S3rOD*8UB`-?{$y9>Icoo=Jl9@#N6 zXU7MTkEo^0$Ve}s;>{B@Ay*Wnly8PsO|fTm-Wem`ZzHu&+dBFDmnS~s)$|8QN|NFc zT{3!?HuRu|5SZ&fi7+gN7nz3$x6fIp*95d|7{NR;Y`Fr&E9*A`f1RMrf%v4mvF4-GN&Gg%Fbl7fExBHc1jxASH z6gfCU3TT=trpG(wc{Ns{oqLfr?NTcB=x)Dzd)jHObCr1Sv8Th5dRPoBaYfYAF^hf@ zP=bLSN>>H`N>ZjP%J=2P9J>Jr69NZ`V|0&$mCKg~aYY+$ma)Oc-ivp%T-?GIvl{e9 zKO_7$<6XpE&hX5{$kge+!totWhy}}pzReqtGrheaHG!+^PRICU5_Uey=;-lY0!Tas z*eBexQz*^F&S}P|W>-{bRb|HJcC&J>BX2A9MsK`ZU%>QvumdlFbS1lCBm$i_I7b{) zKfcLaso#Hdz930YNdM)ljgPj4)SSNKVPFZAr<%H2rzCRd#d;MlHBF-+yvMu4ria$I z=?8n#t4eZb4Ndh&kF6YPbjb9D>=Hc>J*4JnglhW-ZhZRvjq@%eb~r}-;b#=IPU2hD zW4etKQv%C^>5=XWZu<81L0MQPyfPY|cU9O)UcTg?_PO{JRBDrAnB?n+4uksEAFLHLH=vvc=+iXzx$?uopP#x1X!LGw^vuta?afyF z;efYM55MViPFlBkAGxr701-%4yuCbX-leGoF?V}8ias;IDL!_Si2X6`y1A@qe6q@_ zmYxnf_wh{Vn&Ue$JqT{XTjja2Qu$VTywpYX)VxS1tqD=RCTTqoOUWgNe*7VvL3aZj z4$}Yhha-@Nbx^(9L1farbem^V38k1js|kVd9t?NcC}*6a9y}92f!e{VzYAZv`7JKY zAkDB>4sp*HTKeOCUrNTAp+!mOOhGR;FlR@ggI@O!ePikIlY-`R`$j_KmxlR1p4Ms2 z)pkpBbZ>yAb|NAQAeb3iyE`97oX*s+OewDZ-A6G5F{T0CdVS|PGVznq^nvk_*Vr2? zV5UIG)=>1j2wKht?={7N$Iy50FfE6p(ln4IZP_8Zdac3K`j3;7_Vy+9?=X0B2fdsO zoCf<51Orr@EfD#xaFU2sCX&=`74!v&Dt!DZi&b)+6J|>;#n>32H zC}g;KqFM8SfgM~HH_2>Swg(`ihypL3GP@$ zS<_oyD|Ac}xJ5%2N9!mj{8apVDcT-#nl`q)0yQ;G(?Vsn;<9qlMDP6Bwawq~cb_!@7{gW!O>cqsU>Otk`rNpj!I#mX3CGyb{BysI)__N$THTzgnvo@Q^!9D z<+Pm~?Vk7jB+4d%yu8ONy)h4^hEUa#86N>r5AWlq@@T;`?bj`~3^$#IH-+D!Hx53o z_!m>Eh6gJ*D_S~?ZERiEEh1{BCPG6)*cKp(({AEV;z0Zgw{8%-?9!3d8n+ciV%M+iIW#+JW^o_9Y6WfVQ$;i zO<0SjV_>*j3gMJ77;C-VZoRzmi8uX%`cxle2>hUC0Q)A7l}@>wianL18P_Rr5#Mqi z_nz6;hKhu=oc`Q9>n=owMMN-AQ~wn5Gkp_)p;hvJd7HszHvJ7oz6Hs1EV+=R>pr;`My4iCM6~c>k8lz<2<-J>E zo?YHa(I*U8S?2H5tTK2PeIDJJWde&FR9J_m@%^&nzvH{%(amn6JvCHeqcR#fWIhvJ z9~=kDvZG@Q69@xmh@omHVB1oBN*p$ew(Qz%3v2r3AWqfq?c-xBGb;Ishm-Hk)$;74 zJo~jy)MgHZ2@$?0xAkjCYjkCVnS%M;_vxyuvKvj@9UQdTWI3 zVRx^wWhGI6HEKL{bO~}$!6W&FmWYJt2=sgE^6v_FV6&Un7^=eFA0J5I9j&) zcWQs|JwnAGD1F~2Z^6@*1?MKQMjy-FEB)(1? znnXetj=cqACLk^az>|$6vhk@?IMP?=lnSx^8w$36by!nmx-P6na#PF{=dR=9oif0! z(_&`cK9~m-G3HZl1-R7e3Av8H@|K6ClEO;;Vq`anUjXzY-T4Q`ns2vL;eK+1^A9A}gaHV=MSP&DEvy=$W85 z!*fUxS>|*4nI+=Aho*;;N2!i@pSXN>0hC zZeI1m&~O^q{IB&w82|wMVLYdZ|KPME&}GnJ&u7>8v6F$QW6l7h>L!9nz5qErtL`37 zdNnlQKYvKRdQ`RbK-n&lf>nrUN~{d)V?a?6M$) zD#)PiRkVNdV}zigZP<0BKv&K{xZsfTILOVRW+S)67rb5b@xa9cDvNO~IGh}#eBmfo z!pM3HMQvr?v}TmJs~2X69bO3B17r%C*62@MyCa<6=aGahUHiQzq|AF~k<*bD!&&@n z$air1DlElTF-U@vu1ML<`4^$s!k4Pq2Gq_=ZJ*QcPn+!+Jbzq&-U`*U%Bd(@C>7KQg2;oIqOFNup{qTl-;{0~Tkh1WP0}RO0u9ti88vd`{A-Xf(xQ=( zUn6OwS|`(Zo29OEC~lOK>gciXcvt%E*4BE#Xv%1Qh9D-xwNh$Rs!T)m0!dvw3r{O{ zy2Z`g^OG+k)3Jdt+p0bxC31qMI+VokRk@Jt^eAIvfsQSGhE+oJOMrubUh}wAaZ1o3adt32{vLdS-x)uGizt7;$Sf0?TtO!<6${gx~t!m8!YSVRd;GA zcZXv>&`P|@yf54t^$*(FDENuiHN8mw1)CvcX;25!Ttp}2r^2U@9HA^X#w&{tL z=ke=%6JU#tpaT4WSV1uQyrg@&Fe>l;S=pdQbyUsD=iXEf&r)M2onm@bY652E$q@BR zbr#`zS)E(2lH`{Fs9K8KEx3aKrMqlEpQsQ|nU+y)jTgzmp6~sT{nH;26 zsxG{`D+|Cd5E8r;VgcQAKvobie^Y|Igd~)NI`Z-f1;KFBlxC%jY-_CKt)ZXpgg)#E zlx;L@Xu19@ItnQWmF??$+NWgW)>@U{GZ-!KJcR?VVFv_gY3yAhw??Q*gPB`|t?j%g zCYGs!$>p^*CU)sEB%CJ*sUw)*OT{Y$nrT@)TR$B7W@rDd6QuFu4WH6eSH4*J2OjJs z*_MqI1oAOHbJIMLCE#NxsNR=Cx(wr$SvwLrM4TG6N+zp@gs5F@#2W2#piS+dp5+=D zB0PR0+t6iTxx2@629Ig{P1I|n@xyhSaia>DY^t7WX3FPLjjDq3kE0OFY7{Q;IMdWI zRfuymVt1I+M+qNPt)1og94P`Vrm^l=_XT+~jFPZ!SR3_COX6=Ho3WNKX5xrG9%|Dg zn@h7)Hu`#l1NNrB9A!rd&i7kc7gVMB2!Mjf`g$`*@MCFDUl&EG$CNZUkB=Px*yk(A zsrQtvVT5)m?%pzPi!-149HkfMH*6$gSMn^RfK*!jhB)rPN-YKG$xO3hvqftyl~L3J zO<+YYRh8YWU@Msm+uX&*A~A zYQi62os9r6lKt9e&X90JgTIK!Zge+xqdRhO*FY}=1bOYG9jXKj-)-wFHyu4(w;ETi zXAjrC-jAgZCsmLJef|0}bCsO)O%$s_&|m~}C@>_QV+4Hgh>k*p#~S4G<7-Eq9GVy*>*|T5Rj989Me-+sUTm26oS1Y$ob|)LhH5k7({v;5cM=MF;nBQ z_(qj`14no#1?Dv%QwbFd3d}(pZ@Y074}~jbakI0%is;}rRTkO*ftHhue~L`wBy+y< zIUF1&E9rqauEujbS)}@uZ37^xqs6lbRTwIi%a%t|tZ#279_BRStkeGxtWdApA@*lg z*!=LW@QyDhT`$-@1aMq44&zUz*jElfCSy%X1B3iy$1yq=&Y8PTtEHT7>;!rQ#?~$4 zJI~{-yVpl4E^SOxvu+{X-zR7`N((Sl@mmOWN0;wklJ&}RU#S}D^v-!rjDB>kX8dWa zZlRXlQFDpfeRR+C2O?wC?^pf9l4P>#3riy~x+MA0*UvwW8Y;}S5(~jkIXe8P6rMvS zqi4ts3!DhQV2*Q5Rex?+XWh7NT0`^QY!Wwj;Jl}KwlCv1xWORxROujEOnQ^mN`)V) z<06#8K1+NqtG1EZ=dj>#cdV}bhdB>%VC$nNG08b`#Ez-V%5gbqo4+I|Zq_BcW4qi! zdVq_9yun<^TvjNjb!n>xSQ6u-wDdhUt|Y;k-=Ivj#Y#3N#4Q4WXrJP`Sc7S?hSBVn zww}~~=tNuq)ue+iqsh?qh1+|r_Wko28nE8wDjjN6>Q2!f`^wi~9;XuQeaw~tCYxLs z4+}F(Z6KNX)Tt zF_b~cl|pcaacT0sv09}&-5A_YALz7sw=;64Uacp@^JBhLEeJPUIb>W(QUvZns>4Sb zq-wx13@qinS!58WDkWKgirRSQp@xY!^k7mW=Q)A}|(%6yi8J{fD5 zrOHtON`nZ`D?@$Xui@Y6%MJFck4S0W!RdVxg5!>-q^lv9s=S+cb>eAOtv`%j#aUa3 zg3}#RtGB{0-UAA!fzvl68R#ygi80eg`w*w1r9c4x@kEU9sDbl#i zIDdlerl;kpvEN3*QliJ!A4n+QcoS*tr3*VA6jlJaeYXapK;9CD!Bevk+0&I_@jFe{`0&=X!7nJztWz_Oqjhy%1v{ZxmYmcIQT8dR zH1X^bG1gX@uGU-~hPYei2J#8}j8`Mf%$t3#n_o!cZUd}D)tdsv+$|mMyT|Q%rOyz> z`XsyPX91TiU4_Xun{kbVJQ~A)&-vTp#)!*|Xpc&VULxzeV-x=@@_uquRFrgyed8=V zHML1Z!EYUZUhgDEf%s;M#9s`L+dj!+%Mt1rDf51o}0Gsf|v zfU$M68~o%;GEdqcAdzJcji2VW2f~Y#ef|BE&f?$N7kYYnCRa=B3H&94K;L9=`s@2cD$}wG0#ms9!c1z0+Y+KEqwB&Q)Ya|Y*JwHa zQ2-D87c5sq5MT8NANof{*?+Sd>_3V8yAticS&!C676SctW^vAOlW@m9vZ+J9pt6WH z14gN0-SDBYXrm)Ds`d6X2K17Z76)hHqSnmB#6)HO#kOD6-T7?Pq!VMlH5OUaFdClJ zpK&g|*tTbcV#QzRcMd)QFFEI%V;}wndHuV6{~s9Ge@*x|^FKuX3xd{5k%4ZMS(|;4?1C{ zP+sbsL@ICpqUOPOJ|SJ>`rFi3cg1U~POo?}xYD=b18YkGxFXzu3`KY*>Su70oc+0z zjsc^?@$rnKJ1z*2p_S_)$4Kd4kKEiDt@IYn{fkDaex3aD8TS2`798;7j)z@jkOCAS zy-7-;3JC(;izw;7jHFo#B9&idK{KgQ^6iV&b}TN5%w@dvv2j%=KXd!KZZzlclj1Yf z+>VC3w8K9Z4~zd+IJ}ctFubA^(+9+`^{%_r>rI%EtyxO>2{2Aa!&{ihVF*@J6?^{=6#nO#YJ{Ct#%gLc2*L1uww6j2TMuj4_qT&ahYQ9 zQ{K7j9=m_vbl1*!$tZ2d#|Nwnq($~o0RU04L??GrqANz7?}UII4nP0S+Aa-A$Tcx7 z`1O}xEXN;O!JU-bd*J(mfhp!%wssAvNnDV_--u~*FgSs& zO;*hTA=AG)`%JAd#9|5Xd@=r5B+TvSRj$pKDghlj(Qpp!KL7L({8;h;A+*fzEv_a9 z92~eK+hRyT6B3i$$HRNU2gtFyc2H+c;O@Ngl3EOvAfdC{q%o zV`Q8?9O-0nkzo%$p*a&pZ!dU+t0 z&RHxKp&@is8>TgGShtFU^XYU+o4lZAeL$d@{Ta9kX7)S)cFrYyGW@lFSbsgyklxS& zX3_VaJ-+wZMSO$oU@#qjZFR*gV?wWqYK5CTwN{_^nzz(FK!w|gFm46t=TPjU$l-AS zye+%W{QUESDXlmjFdC*o7obOs4N_eMZ6MDVB1rNmt)$W@FvElA8E%*9-$TS^Ih1?* z$WD(11rr7^<=E_bbYE?AwxP5_D>CEED6wE%%#@Z6%8@yiE!!y>0JG?Eo?Lp!L(y57 zdV!WH-r}0UuHX|+i2o2D2RESLSepo+XO@7Jjc-#Pu;fiY=v>yl=rUtVxo$@?8;dm= z?k;B0=X*(iTIVhUhXV~~nJ((e=h-2v?2GOOe_mz>0?SRV z+KFa5o2Z{quIfC{G(qS(K? z$sZo+e%Ds6>V6s{th;pjufvr3(?8M>b~bSC{pdg@C-&uh^V{x~F25$^>S3pcslG+| z>6e*N(Suop(v4}TC9sc#h=@FDf0H9PXU4oj_bmY4xbD^fZbGj;RBZiz=I*s@Pxtv` z=IFck+n+4VTg$;JbOD;-WqBE;*k6WucwAwH2N6yiqFk~yQ9e{FDKk2tUN=0dFDDf3 zT{io!IF=v?M%uqJGVHktSXH1#Qa($lZeNM)*CLhjYFl5N5z{a1jZrXed@wH~yUfq*r(e5PHx`Ex z(F?I<{vEg+@SL;*-1A_BgC?hxescDgVZAP`$G)GrR$nl#omjCWsj`qt2-aD&2f2_( zoxin=)-!ZGq{>W1QmK~b%-^yQ}_E3^xj zI|`L$iZ7_i$u5s^F?)2DWLP~vKz63Z;P93vN~_Ro^Hoq$h4<32pKKhw>reEpx3Mz< z8EMNm$gEkrASnpfgeeze$^HH&%@Wq_!|rnV?-D91D(x8oLD`Ok7D;J59Gqf$Yn4A$ z-~WT;*?$uGNAm0+@BR-MK$we8s5#F3L$ L29-*F`u4v7=xq7i diff --git a/docs/install/digitalocean.md b/docs/install/digitalocean.md index c774ebd..b6c3411 100644 --- a/docs/install/digitalocean.md +++ b/docs/install/digitalocean.md @@ -37,16 +37,16 @@ Let's create the server on which we can run JupyterHub. ``` 4. Under **Choose a size**, select the size of the server you want. The default - (4GB RAM, 2CPUs, 20 USD / month) is not a bad start. You can resize your server + (4GB RAM, 2CPUs, 24 USD / month) is not a bad start. You can resize your server later if you need. Check out our guide on How To [](/howto/admin/resource-estimation) to help pick how much Memory, CPU & disk space your server needs. -5. Scroll down to **Select additional options**, and select **User data**. +5. Open the **Advanced Options**, and check the box for **Add Initialization scripts**. ```{image} ../images/providers/digitalocean/additional-options.png - :alt: Turn on User Data in additional options + :alt: Turn on User Data in advanced options ``` This opens up a textbox where you can enter a script that will be run From 5cbd7533e8a6abdb09bb747307cc43124a84561c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 May 2023 05:05:28 +0000 Subject: [PATCH 103/232] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.3.1 → v3.4.0](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.4.0) - [github.com/pre-commit/mirrors-prettier: v3.0.0-alpha.6 → v3.0.0-alpha.9-for-vscode](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.0-alpha.6...v3.0.0-alpha.9-for-vscode) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa480ca..a3a28ee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.4.0 hooks: - id: pyupgrade args: @@ -28,7 +28,7 @@ repos: # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.6 + rev: v3.0.0-alpha.9-for-vscode hooks: - id: prettier From 1dd25392a26af4c1c7841a5e61b60457a86e3687 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 11 May 2023 10:47:49 +0200 Subject: [PATCH 104/232] add integration test for hub version make sure the hub has the version we expect --- integration-tests/test_hub.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index fa5415d..c2b1dec 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -11,26 +11,34 @@ import grp import subprocess from os import system from tljh.normalize import generate_system_username - +from packaging.version import Version as V # Use sudo to invoke it, since this is how users invoke it. # This catches issues with PATH TLJH_CONFIG_PATH = ["sudo", "tljh-config"] +# This *must* be localhost, not an IP +# aiohttp throws away cookies if we are connecting to an IP! +hub_url = "http://localhost" + def test_hub_up(): - r = requests.get("http://127.0.0.1") + r = requests.get(hub_url) r.raise_for_status() +def test_hub_version(): + r = requests.get(hub_url + "/hub/api") + r.raise_for_status() + info = r.json() + assert V("3.0") <= V(info["version"]) <= V("4.0") + + @pytest.mark.asyncio async def test_user_code_execute(): """ User logs in, starts a server & executes code """ - # 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 ( From c1df7e973599528a2512d0e072f6e809affaff11 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 11 May 2023 10:55:35 +0200 Subject: [PATCH 105/232] integration test: include pip freeze in output --- .github/integration-test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/integration-test.py b/.github/integration-test.py index f3c42b7..90875d1 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -178,6 +178,13 @@ def run_test( test_name, "/opt/tljh/hub/bin/python3 -m pip install -r /srv/src/integration-tests/requirements.txt", ) + + # show environment + run_container_command( + test_name, + "/opt/tljh/hub/bin/python3 -m pip freeze", + ) + run_container_command( test_name, # We abort pytest after two failures as a compromise between wanting to From b3365fbe45e41c7713eafa84bfc3c9cd489e1ea8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 24 Apr 2023 01:55:34 +0200 Subject: [PATCH 106/232] update: jupyterhub 4 --- integration-tests/test_hub.py | 2 +- tljh/installer.py | 2 +- tljh/requirements-base.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index c2b1dec..26bb745 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -31,7 +31,7 @@ def test_hub_version(): r = requests.get(hub_url + "/hub/api") r.raise_for_status() info = r.json() - assert V("3.0") <= V(info["version"]) <= V("4.0") + assert V("4") <= V(info["version"]) <= V("5") @pytest.mark.asyncio diff --git a/tljh/installer.py b/tljh/installer.py index 61d4d9c..4ca2470 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -123,7 +123,7 @@ def ensure_jupyterhub_package(prefix): conda.ensure_pip_packages( prefix, [ - "jupyterhub==3.*", + "jupyterhub==4.*", "jupyterhub-systemdspawner==0.17.*", "jupyterhub-firstuseauthenticator==1.*", "jupyterhub-nativeauthenticator==1.*", diff --git a/tljh/requirements-base.txt b/tljh/requirements-base.txt index 1f59947..f926a5a 100644 --- a/tljh/requirements-base.txt +++ b/tljh/requirements-base.txt @@ -6,7 +6,7 @@ # our integration tests fail. # # JupyterHub + notebook package are base requirements for user environment -jupyterhub==3.* +jupyterhub==4.* notebook==6.* # Install additional notebook frontends! jupyterlab==3.* From c09e83a6c8340bc9205c11f284d469d45f524da9 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 11 May 2023 23:13:59 +0200 Subject: [PATCH 107/232] Pass xsrf token in tests to /hub/api requests --- integration-tests/test_hub.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 26bb745..0b282ed 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -389,7 +389,9 @@ async def test_idle_server_culled(): ) async with User(username, hub_url, partial(login_dummy, password="")) as u: + # Login the user await u.login() + # Start user's server await u.ensure_server_simulate() # Assert that the user exists @@ -400,12 +402,23 @@ async def test_idle_server_culled(): r = await u.session.get(user_url, allow_redirects=False) assert r.status == 200 + # Extract the xsrf token from the _xsrf cookie set after visiting + # /hub/login with the u.session + hub_cookie = u.session.cookie_jar.filter_cookies(str(u.hub_url / "hub/api/user")) + assert "_xsrf" in hub_cookie + hub_xsrf_token = hub_cookie["_xsrf"].value + # Check that we can talk to JupyterHub itself # use this as a proxy for whether the user still exists async def hub_api_request(): r = await u.session.get( u.hub_url / "hub/api/user", - headers={"Referer": str(u.hub_url / "hub/")}, + headers={ + # Referer is needed for JupyterHub <=3 + "Referer": str(u.hub_url / "hub/"), + # X-XSRFToken is needed for JupyterHub >=4 + "X-XSRFToken": hub_xsrf_token, + }, allow_redirects=False, ) return r From 922db1ae8ea2b272eb0b31f0b8e00101082e49c1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 11 May 2023 21:47:14 +0000 Subject: [PATCH 108/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- integration-tests/test_hub.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 0b282ed..9991e0c 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -404,7 +404,9 @@ async def test_idle_server_culled(): # Extract the xsrf token from the _xsrf cookie set after visiting # /hub/login with the u.session - hub_cookie = u.session.cookie_jar.filter_cookies(str(u.hub_url / "hub/api/user")) + hub_cookie = u.session.cookie_jar.filter_cookies( + str(u.hub_url / "hub/api/user") + ) assert "_xsrf" in hub_cookie hub_xsrf_token = hub_cookie["_xsrf"].value From 8dba109c43d842f270351b6e8eb3f304e29feff0 Mon Sep 17 00:00:00 2001 From: Fabian Fischer Date: Mon, 27 Feb 2023 06:32:55 +0100 Subject: [PATCH 109/232] docs(awscognito): add custom claims example After struggling with my custom department claim and getting helped in https://discourse.jupyter.org/t/genericauthenticator-with-cognito-how-to-check-for-department-match/18105/1 I wanted to give back to the community. Rebased by Erik Sundell, originally committed in .rst, now transferred to .md --- docs/howto/auth/awscognito.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/howto/auth/awscognito.md b/docs/howto/auth/awscognito.md index ccb8893..bf83116 100644 --- a/docs/howto/auth/awscognito.md +++ b/docs/howto/auth/awscognito.md @@ -126,3 +126,20 @@ For more information on `tljh-config`, see [](/topic/tljh-config). Jupyter interface used in this JupyterHub. 5. **If this does not work** you can revert back to the default JupyterHub authenticator by following the steps in [](/howto/auth/firstuse). + +## Optionally using custom claims for group mapping + +If you use AWS Cognito to federate with an OIDC provider and you want to +authorize your users based on e.g. their department claim, you have to make sure +that the custom claim is provided as array. + +If it is not provided as array, there is an easy fix. Just add these lines to +your `awscognito.py`: + +```python +def claim_groups_key_func(user_data_resp_json): + return [user_data_resp_json['custom:department']] + +c.GenericOAuthenticator.claim_groups_key = claim_groups_key_func +c.GenericOAuthenticator.allowed_groups = ["AA BB CC", "AA BB DD"] +``` From 7b8d6dffcc096346fbd8611d18e8d0d847c823a9 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 13 May 2023 23:26:47 +0200 Subject: [PATCH 110/232] pre-commit.ci configured to update pre-commit hooks on a monthly basis --- .pre-commit-config.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3a28ee..bbda53c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,3 +52,7 @@ repos: rev: "6.0.0" hooks: - id: flake8 + +# pre-commit.ci config reference: https://pre-commit.ci/#configuration +ci: + autoupdate_schedule: monthly From 4c6d54c79d004b2aa2a59f4e335060e1fcc86c01 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 13 May 2023 23:30:26 +0200 Subject: [PATCH 111/232] Upgrade pip in hub env from 21.3 to to 23.1 when bootstrap script runs --- .github/workflows/unit-test.yaml | 2 +- bootstrap/bootstrap.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 338cc9c..4dd1e4e 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -84,7 +84,7 @@ jobs: # Keep pip version pinning in sync with the one in bootstrap.py! # See changelog at https://pip.pypa.io/en/latest/news/#changelog run: | - python3 -m pip install -U "pip==21.3.*" + python3 -m pip install -U "pip==23.1.*" python3 -m pip install -r dev-requirements.txt python3 -m pip install -e . pip freeze diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index ef9f602..c43b59f 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -479,7 +479,7 @@ def main(): # Keep pip version pinning in sync with the one in unit-test.yml! # See changelog at https://pip.pypa.io/en/latest/news/#changelog logger.info("Upgrading pip...") - run_subprocess([hub_env_pip, "install", "--upgrade", "pip==21.3.*"]) + run_subprocess([hub_env_pip, "install", "--upgrade", "pip==23.1.*"]) # Install/upgrade TLJH installer tljh_install_cmd = [hub_env_pip, "install", "--upgrade"] From c093aa0e50024fca45ab9db44354a807574bd49a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 15 May 2023 10:43:59 +0200 Subject: [PATCH 112/232] pre-commit: add autoflake and isort, include autoformatter config --- .pre-commit-config.yaml | 15 +++++++++++++++ pyproject.toml | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbda53c..f62bd40 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,21 @@ repos: # exclude it from the pyupgrade hook that will apply f-strings etc. exclude: bootstrap/bootstrap.py + # Autoformat: Python code + - repo: https://github.com/PyCQA/autoflake + rev: v2.1.1 + hooks: + - id: autoflake + # args ref: https://github.com/PyCQA/autoflake#advanced-usage + args: + - --in-place + + # Autoformat: Python code + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + # Autoformat: Python code - repo: https://github.com/psf/black rev: 23.3.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..170be20 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +# autoflake is used for autoformatting Python code +# +# ref: https://github.com/PyCQA/autoflake#readme +# +[tool.autoflake] +ignore-init-module-imports = true +remove-all-unused-imports = true +remove-duplicate-keys = true +remove-unused-variables = true + + +# isort is used for autoformatting Python code +# +# ref: https://pycqa.github.io/isort/ +# +[tool.isort] +profile = "black" + + +# black is used for autoformatting Python code +# +# ref: https://black.readthedocs.io/en/stable/ +# +[tool.black] +# target-version should be all supported versions, see +# https://github.com/psf/black/issues/751#issuecomment-473066811 +target_version = [ + "py36", + "py37", + "py38", + "py39", + "py310", + "py311", +] From b366d6b63f76262b4dd5e295ca4434f325d688d4 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 15 May 2023 10:49:56 +0200 Subject: [PATCH 113/232] Remove unused copy-paste remnants in tests --- integration-tests/test_install.py | 2 -- tests/test_conda.py | 1 - 2 files changed, 3 deletions(-) diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 1391ddd..252060b 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -93,7 +93,6 @@ def permissions_test(group, path, *, readable=None, writable=None, dirs_only=Fal # check if the path should be writable if writable is not None: if access(path, os.W_OK) != writable: - stat = os.stat(path) info = pool.submit(debug_uid_gid).result() failures.append( "{} {} should {}be writable by {} [{}]".format( @@ -104,7 +103,6 @@ def permissions_test(group, path, *, readable=None, writable=None, dirs_only=Fal # check if the path should be readable if readable is not None: if access(path, os.R_OK) != readable: - stat = os.stat(path) info = pool.submit(debug_uid_gid).result() failures.append( "{} {} should {}be readable by {} [{}]".format( diff --git a/tests/test_conda.py b/tests/test_conda.py index 46b7cac..8c990ea 100644 --- a/tests/test_conda.py +++ b/tests/test_conda.py @@ -14,7 +14,6 @@ def prefix(): """ Provide a temporary directory with a mambaforge conda environment """ - machine = os.uname().machine installer_url, checksum = installer._mambaforge_url() with tempfile.TemporaryDirectory() as tmpdir: with conda.download_miniconda_installer( From e95942d61b0b6193e835de3b0125ba15479a2730 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 May 2023 08:51:35 +0000 Subject: [PATCH 114/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .github/integration-test.py | 4 ++-- bootstrap/bootstrap.py | 10 +++++----- docs/conf.py | 1 - integration-tests/test_admin_installer.py | 7 ++++--- integration-tests/test_bootstrap.py | 2 +- integration-tests/test_hub.py | 20 +++++++++++--------- integration-tests/test_install.py | 7 +++---- integration-tests/test_proxy.py | 15 +++++++-------- integration-tests/test_simplest_plugin.py | 7 ++++--- setup.py | 2 +- tests/conftest.py | 2 +- tests/test_bootstrap_functions.py | 3 ++- tests/test_conda.py | 7 ++++--- tests/test_config.py | 2 +- tests/test_configurer.py | 3 ++- tests/test_installer.py | 9 ++++----- tests/test_migrator.py | 2 +- tests/test_traefik.py | 5 ++--- tests/test_user.py | 8 +++++--- tests/test_utils.py | 8 +++++--- tljh/apt.py | 1 + tljh/conda.py | 8 ++++---- tljh/config.py | 5 ++--- tljh/hooks.py | 8 -------- tljh/installer.py | 11 +---------- tljh/jupyterhub_config.py | 9 +++++---- tljh/log.py | 2 +- tljh/migrator.py | 11 +++-------- tljh/systemd.py | 2 +- tljh/traefik.py | 7 ++++--- tljh/user_creating_spawner.py | 10 +++++----- tljh/yaml.py | 2 +- 32 files changed, 93 insertions(+), 107 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index 90875d1..b3fa090 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 import argparse -from shutil import which +import os import subprocess import time -import os +from shutil import which def container_runtime(): diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index ef9f602..59e2daf 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -42,16 +42,16 @@ Command line flags, from "bootstrap.py --help": can also pass a branch name such as 'main' or a commit hash. """ -from argparse import ArgumentParser -import os -from http.server import SimpleHTTPRequestHandler, HTTPServer +import logging import multiprocessing +import os import re +import shutil import subprocess import sys -import logging -import shutil import urllib.request +from argparse import ArgumentParser +from http.server import HTTPServer, SimpleHTTPRequestHandler progress_page_favicon_url = "https://raw.githubusercontent.com/jupyterhub/jupyterhub/main/share/jupyterhub/static/favicon.ico" progress_page_html = """ diff --git a/docs/conf.py b/docs/conf.py index 36823cc..77036f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,7 +4,6 @@ # import datetime - # -- Project information ----------------------------------------------------- # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index f039bfc..c968978 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -1,8 +1,9 @@ -from hubtraf.user import User -from hubtraf.auth.dummy import login_dummy -import pytest from functools import partial +import pytest +from hubtraf.auth.dummy import login_dummy +from hubtraf.user import User + @pytest.mark.asyncio async def test_admin_login(): diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index 8a97762..2d4e806 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -162,7 +162,7 @@ def verify_progress_page(expected_status_code, timeout): if b"HTTP/1.0 200 OK" in resp: progress_page_status = True break - except Exception as e: + except Exception: time.sleep(2) continue diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 9991e0c..faba9ea 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -1,18 +1,20 @@ -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 import asyncio -import pwd import grp +import pwd +import secrets import subprocess +from functools import partial from os import system -from tljh.normalize import generate_system_username + +import pytest +import requests +from hubtraf.auth.dummy import login_dummy +from hubtraf.user import User +from jupyterhub.utils import exponential_backoff from packaging.version import Version as V +from tljh.normalize import generate_system_username + # Use sudo to invoke it, since this is how users invoke it. # This catches issues with PATH TLJH_CONFIG_PATH = ["sudo", "tljh-config"] diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py index 252060b..06840f6 100644 --- a/integration-tests/test_install.py +++ b/integration-tests/test_install.py @@ -1,15 +1,14 @@ -from contextlib import contextmanager -from concurrent.futures import ProcessPoolExecutor -from functools import partial import grp import os import pwd import subprocess import sys +from concurrent.futures import ProcessPoolExecutor +from contextlib import contextmanager +from functools import partial import pytest - ADMIN_GROUP = "jupyterhub-admins" USER_GROUP = "jupyterhub-users" INSTALL_PREFIX = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") diff --git a/integration-tests/test_proxy.py b/integration-tests/test_proxy.py index 2cb78f7..6aca51e 100644 --- a/integration-tests/test_proxy.py +++ b/integration-tests/test_proxy.py @@ -2,19 +2,19 @@ import os import shutil import ssl -from subprocess import check_call import time +from subprocess import check_call -import toml -from tornado.httpclient import HTTPClient, HTTPRequest, HTTPClientError import pytest +import toml +from tornado.httpclient import HTTPClient, HTTPClientError, HTTPRequest from tljh.config import ( + CONFIG_DIR, + CONFIG_FILE, + STATE_DIR, reload_component, set_config_value, - CONFIG_FILE, - CONFIG_DIR, - STATE_DIR, ) @@ -36,7 +36,6 @@ def send_request(url, max_sleep, validate_cert=True, username=None, password=Non break except Exception as e: print(e) - pass return resp @@ -134,7 +133,7 @@ def test_extra_traefik_config(): HTTPClient().fetch(req) success = True break - except Exception as e: + except Exception: pass assert success == True diff --git a/integration-tests/test_simplest_plugin.py b/integration-tests/test_simplest_plugin.py index eebff27..171578c 100644 --- a/integration-tests/test_simplest_plugin.py +++ b/integration-tests/test_simplest_plugin.py @@ -1,13 +1,14 @@ """ Test simplest plugin """ -from ruamel.yaml import YAML -import requests import os import subprocess -from tljh.config import CONFIG_FILE, USER_ENV_PREFIX, HUB_ENV_PREFIX +import requests +from ruamel.yaml import YAML + from tljh import user +from tljh.config import CONFIG_FILE, HUB_ENV_PREFIX, USER_ENV_PREFIX yaml = YAML(typ="rt") diff --git a/setup.py b/setup.py index 7a179c6..12d7240 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup setup( name="the-littlest-jupyterhub", diff --git a/tests/conftest.py b/tests/conftest.py index e92884a..cf765b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """pytest fixtures""" -from importlib import reload import os import types +from importlib import reload from unittest import mock import pytest diff --git a/tests/test_bootstrap_functions.py b/tests/test_bootstrap_functions.py index c579ac8..799dc3c 100644 --- a/tests/test_bootstrap_functions.py +++ b/tests/test_bootstrap_functions.py @@ -5,9 +5,10 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from bootstrap import bootstrap import pytest +from bootstrap import bootstrap + @pytest.mark.parametrize( "requested,expected", diff --git a/tests/test_conda.py b/tests/test_conda.py index 8c990ea..6d44595 100644 --- a/tests/test_conda.py +++ b/tests/test_conda.py @@ -1,13 +1,14 @@ """ Test conda commandline wrappers """ -from tljh import conda -from tljh import installer import os -import pytest import subprocess import tempfile +import pytest + +from tljh import conda, installer + @pytest.fixture(scope="module") def prefix(): diff --git a/tests/test_config.py b/tests/test_config.py index 5d227da..c091060 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -56,7 +56,7 @@ def test_set_overwrite(): def test_unset_no_mutate(): conf = {"a": "b"} - new_conf = config.unset_item_from_config(conf, "a") + config.unset_item_from_config(conf, "a") assert conf == {"a": "b"} diff --git a/tests/test_configurer.py b/tests/test_configurer.py index bbef8c4..c348236 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -2,10 +2,11 @@ Test configurer """ -from traitlets.config import Config import os import sys +from traitlets.config import Config + from tljh import configurer diff --git a/tests/test_installer.py b/tests/test_installer.py index 7675bd0..1d0699c 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -3,15 +3,14 @@ Unit test functions in installer.py """ import json import os +from subprocess import PIPE, run from unittest import mock -from subprocess import run, PIPE -from packaging.version import parse as V -from packaging.specifiers import SpecifierSet import pytest +from packaging.specifiers import SpecifierSet +from packaging.version import parse as V -from tljh import conda -from tljh import installer +from tljh import conda, installer from tljh.yaml import yaml diff --git a/tests/test_migrator.py b/tests/test_migrator.py index 24e67a8..87275f6 100644 --- a/tests/test_migrator.py +++ b/tests/test_migrator.py @@ -4,7 +4,7 @@ Unit test functions in installer.py import os from datetime import date -from tljh import migrator, config +from tljh import config, migrator def test_migrate_config(tljh_dir): diff --git a/tests/test_traefik.py b/tests/test_traefik.py index ecda0ce..0cb406b 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -1,11 +1,10 @@ """Test traefik configuration""" import os -import toml import pytest +import toml -from tljh import config -from tljh import traefik +from tljh import config, traefik def test_download_traefik(tmpdir): diff --git a/tests/test_user.py b/tests/test_user.py index aa5accd..5bda68d 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,15 +1,17 @@ """ Test wrappers in tljw.user module """ -from tljh import user +import grp import os import os.path +import pwd import stat import uuid -import pwd -import grp + import pytest +from tljh import user + def test_ensure_user(): """ diff --git a/tests/test_utils.py b/tests/test_utils.py index 449ecbe..e54047a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,9 @@ -import pytest -from tljh import utils -import subprocess import logging +import subprocess + +import pytest + +from tljh import utils def test_run_subprocess_exception(mocker): diff --git a/tljh/apt.py b/tljh/apt.py index bf20e67..b5b0845 100644 --- a/tljh/apt.py +++ b/tljh/apt.py @@ -3,6 +3,7 @@ Utilities for working with the apt package manager """ import os import subprocess + from tljh import utils diff --git a/tljh/conda.py b/tljh/conda.py index 206e4df..df9a027 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -1,12 +1,12 @@ """ Wrap conda commandline program """ +import contextlib +import hashlib +import json +import logging import os import subprocess -import json -import hashlib -import contextlib -import logging import tempfile import time diff --git a/tljh/config.py b/tljh/config.py index e705c10..4459621 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -13,18 +13,17 @@ tljh-config show firstlevel.second_level """ import argparse -from collections.abc import Sequence, Mapping -from copy import deepcopy import os import re import sys import time +from collections.abc import Mapping, Sequence +from copy import deepcopy import requests from .yaml import yaml - INSTALL_PREFIX = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, "hub") USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, "user") diff --git a/tljh/hooks.py b/tljh/hooks.py index ddb1f3f..ddaa2a3 100644 --- a/tljh/hooks.py +++ b/tljh/hooks.py @@ -12,7 +12,6 @@ def tljh_extra_user_conda_packages(): """ Return list of extra conda packages to install in user environment. """ - pass @hookspec @@ -20,7 +19,6 @@ def tljh_extra_user_pip_packages(): """ Return list of extra pip packages to install in user environment. """ - pass @hookspec @@ -28,7 +26,6 @@ def tljh_extra_hub_pip_packages(): """ Return list of extra pip packages to install in the hub environment. """ - pass @hookspec @@ -38,7 +35,6 @@ def tljh_extra_apt_packages(): These will be installed before additional pip or conda packages. """ - pass @hookspec @@ -49,7 +45,6 @@ def tljh_custom_jupyterhub_config(c): Anything you can put in `jupyterhub_config.py` can be here. """ - pass @hookspec @@ -62,7 +57,6 @@ 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 @@ -73,7 +67,6 @@ def tljh_post_install(): This can be arbitrary Python code. """ - pass @hookspec @@ -82,4 +75,3 @@ def tljh_new_user_create(username): Script to be executed after a new user has been added. This can be arbitrary Python code. """ - pass diff --git a/tljh/installer.py b/tljh/installer.py index 4ca2470..755326e 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -17,15 +17,7 @@ import pluggy import requests from requests.packages.urllib3.exceptions import InsecureRequestWarning -from tljh import ( - apt, - conda, - hooks, - migrator, - systemd, - traefik, - user, -) +from tljh import apt, conda, hooks, migrator, systemd, traefik, user from .config import ( CONFIG_DIR, @@ -518,7 +510,6 @@ def main(): 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() diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index a553f2c..c61c06c 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -2,14 +2,15 @@ JupyterHub config for the littlest jupyterhub. """ -from glob import glob import os +from glob import glob + +from jupyterhub_traefik_proxy import TraefikTomlProxy from tljh import configurer -from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR -from tljh.utils import get_plugin_manager +from tljh.config import CONFIG_DIR, INSTALL_PREFIX, USER_ENV_PREFIX from tljh.user_creating_spawner import UserCreatingSpawner -from jupyterhub_traefik_proxy import TraefikTomlProxy +from tljh.utils import get_plugin_manager c.JupyterHub.spawner_class = UserCreatingSpawner diff --git a/tljh/log.py b/tljh/log.py index e615f7b..ed7eca4 100644 --- a/tljh/log.py +++ b/tljh/log.py @@ -1,6 +1,6 @@ """Setup tljh logging""" -import os import logging +import os from .config import INSTALL_PREFIX diff --git a/tljh/migrator.py b/tljh/migrator.py index 75336ad..9f00d83 100644 --- a/tljh/migrator.py +++ b/tljh/migrator.py @@ -1,16 +1,11 @@ """Migration utilities for upgrading tljh""" -import os -from datetime import date import logging +import os import shutil +from datetime import date -from tljh.config import ( - CONFIG_DIR, - CONFIG_FILE, - INSTALL_PREFIX, -) - +from tljh.config import CONFIG_DIR, CONFIG_FILE, INSTALL_PREFIX logger = logging.getLogger("tljh") diff --git a/tljh/systemd.py b/tljh/systemd.py index d4fcf6c..f274fab 100644 --- a/tljh/systemd.py +++ b/tljh/systemd.py @@ -3,8 +3,8 @@ Wraps systemctl to install, uninstall, start & stop systemd services. If we use a debian package instead, we can get rid of all this code. """ -import subprocess import os +import subprocess def reload_daemon(): diff --git a/tljh/traefik.py b/tljh/traefik.py index e815efb..c93a9fa 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -3,14 +3,15 @@ import hashlib import os from glob import glob -from jinja2 import Template -from passlib.apache import HtpasswdFile import backoff import requests import toml +from jinja2 import Template +from passlib.apache import HtpasswdFile + +from tljh.configurer import _merge_dictionaries, load_config from .config import CONFIG_DIR -from tljh.configurer import load_config, _merge_dictionaries # traefik 2.7.x is not supported yet, use v1.7.x for now # see: https://github.com/jupyterhub/traefik-proxy/issues/97 diff --git a/tljh/user_creating_spawner.py b/tljh/user_creating_spawner.py index c6f07f3..aa455e3 100644 --- a/tljh/user_creating_spawner.py +++ b/tljh/user_creating_spawner.py @@ -1,9 +1,9 @@ -from tljh.normalize import generate_system_username -from tljh import user -from tljh import configurer -from systemdspawner import SystemdSpawner -from traitlets import Dict, Unicode, List from jupyterhub_configurator.mixins import ConfiguratorSpawnerMixin +from systemdspawner import SystemdSpawner +from traitlets import Dict, List, Unicode + +from tljh import configurer, user +from tljh.normalize import generate_system_username class CustomSpawner(SystemdSpawner): diff --git a/tljh/yaml.py b/tljh/yaml.py index 3ff8a8d..e51381e 100644 --- a/tljh/yaml.py +++ b/tljh/yaml.py @@ -3,8 +3,8 @@ ensures the same yaml settings for reading/writing throughout tljh """ -from ruamel.yaml.composer import Composer from ruamel.yaml import YAML +from ruamel.yaml.composer import Composer class _NoEmptyFlowComposer(Composer): From e353ab80c3847ccf4f0feed4585dbbbe3eba65ac Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 21 Mar 2023 16:23:41 +0100 Subject: [PATCH 115/232] traefik 2.9.9 - traefik releases are tarballs now - move traefik platform check to download function for easier unit testing on unsupported platforms --- tljh/traefik.py | 84 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/tljh/traefik.py b/tljh/traefik.py index c93a9fa..e5aae6e 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -1,7 +1,11 @@ """Traefik installation and setup""" import hashlib +import io +import logging import os +import tarfile from glob import glob +from subprocess import run import backoff import requests @@ -13,28 +17,33 @@ from tljh.configurer import _merge_dictionaries, load_config from .config import CONFIG_DIR -# traefik 2.7.x is not supported yet, use v1.7.x for now -# see: https://github.com/jupyterhub/traefik-proxy/issues/97 +logger = logging.getLogger("tljh") + machine = os.uname().machine if machine == "aarch64": - plat = "linux-arm64" + plat = "linux_arm64" elif machine == "x86_64": - plat = "linux-amd64" + plat = "linux_amd64" else: - raise OSError(f"Error. Platform: {os.uname().sysname} / {machine} Not supported.") -traefik_version = "1.7.33" + plat = None + +traefik_version = "2.9.9" # record sha256 hashes for supported platforms here checksums = { - "linux-amd64": "314ffeaa4cd8ed6ab7b779e9b6773987819f79b23c28d7ab60ace4d3683c5935", - "linux-arm64": "0640fa665125efa6b598fc08c100178e24de66c5c6035ce5d75668d3dc3706e1", + "linux_amd64": "141db1434ae76890915486a4bc5ecf3dbafc8ece78984ce1a8db07737c42db88", + "linux_arm64": "0a65ead411307669916ba629fa13f698acda0b2c5387abe0309b43e168e4e57f", } -def checksum_file(path): +def checksum_file(path_or_file): """Compute the sha256 checksum of a path""" hasher = hashlib.sha256() - with open(path, "rb") as f: + if hasattr(path_or_file, "read"): + f = path_or_file + else: + f = open(path_or_file, "rb") + with f: for chunk in iter(lambda: f.read(4096), b""): hasher.update(chunk) return hasher.hexdigest() @@ -45,39 +54,68 @@ def fatal_error(e): return str(e) != "ContentTooShort" and not isinstance(e, ConnectionResetError) +def check_traefik_version(traefik_bin): + """Check the traefik version from `traefik version` output""" + + try: + version_out = run([traefik_bin, "version"], capture=True, text=True) + except (FileNotFoundError, OSError) as e: + logger.debug(f"Failed to get traefik version: {e}") + return False + versions = {} + for line in version_out.splitlines(): + before, _, after = line.partition(":") + key = before.strip() + if key.lower() == "version": + version = after.strip() + if version == traefik_version: + logger.debug(f"Found {traefik_bin} {version}") + return True + else: + logger.info( + f"Found {traefik_bin} version {version} != {traefik_version}" + ) + return False + + logger.debug(f"Failed to extract traefik version from: {version_out}") + return False + + @backoff.on_exception(backoff.expo, Exception, max_tries=2, giveup=fatal_error) def ensure_traefik_binary(prefix): """Download and install the traefik binary to a location identified by a prefix path such as '/opt/tljh/hub/'""" + if plat is None: + raise OSError( + f"Error. Platform: {os.uname().sysname} / {machine} Not supported." + ) + traefik_bin_dir = os.path.join(prefix, "bin") traefik_bin = os.path.join(prefix, "bin", "traefik") if os.path.exists(traefik_bin): - checksum = checksum_file(traefik_bin) - if checksum == checksums[plat]: - # already have the right binary - # ensure permissions and we're done - os.chmod(traefik_bin, 0o755) + if check_traefik_version(traefik_bin): return else: - print(f"checksum mismatch on {traefik_bin}") os.remove(traefik_bin) traefik_url = ( "https://github.com/containous/traefik/releases" - f"/download/v{traefik_version}/traefik_{plat}" + f"/download/v{traefik_version}/traefik_v{traefik_version}_{plat}.tar.gz" ) - print(f"Downloading traefik {traefik_version}...") + logger.info(f"Downloading traefik {traefik_version} from {traefik_url}...") # download the file response = requests.get(traefik_url) + response.raise_for_status() 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 - checksum = checksum_file(traefik_bin) + checksum = checksum_file(io.BytesIO(response.content)) if checksum != checksums[plat]: - raise OSError(f"Checksum failed {traefik_bin}: {checksum} != {checksums[plat]}") + raise OSError(f"Checksum failed {traefik_url}: {checksum} != {checksums[plat]}") + + with tarfile.open(fileobj=io.BytesIO(response.content)) as tf: + tf.extract("traefik", path=traefik_bin_dir) + os.chmod(traefik_bin, 0o755) def compute_basic_auth(username, password): From a58956f14b9361c020d94567a6fb42c6127071db Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 15 May 2023 10:53:53 +0200 Subject: [PATCH 116/232] update for traefik v2, treafik-proxy v1 - tls config is no longer allowed in static config file, add separate dynamic config - no longer need to persist auth config ourselves (TraefikProxy handles this) - make sure to reload proxy before reloading hub in tests --- integration-tests/conftest.py | 2 +- integration-tests/test_proxy.py | 63 +++++++---- setup.py | 3 +- tests/test_configurer.py | 10 +- tests/test_traefik.py | 194 +++++++++++++++++++++----------- tljh/config.py | 5 +- tljh/configurer.py | 9 +- tljh/jupyterhub_config.py | 9 +- tljh/traefik-dynamic.toml.tpl | 25 ++++ tljh/traefik.py | 47 ++++---- tljh/traefik.toml.tpl | 83 ++++++-------- 11 files changed, 272 insertions(+), 178 deletions(-) create mode 100644 tljh/traefik-dynamic.toml.tpl diff --git a/integration-tests/conftest.py b/integration-tests/conftest.py index 123be8d..b48e032 100644 --- a/integration-tests/conftest.py +++ b/integration-tests/conftest.py @@ -25,5 +25,5 @@ def preserve_config(request): f.write(save_config) elif os.path.exists(CONFIG_FILE): os.remove(CONFIG_FILE) - reload_component("hub") reload_component("proxy") + reload_component("hub") diff --git a/integration-tests/test_proxy.py b/integration-tests/test_proxy.py index 6aca51e..cc9d766 100644 --- a/integration-tests/test_proxy.py +++ b/integration-tests/test_proxy.py @@ -19,9 +19,7 @@ from tljh.config import ( def send_request(url, max_sleep, validate_cert=True, username=None, password=None): - resp = None for i in range(max_sleep): - time.sleep(i) try: req = HTTPRequest( url, @@ -32,12 +30,12 @@ def send_request(url, max_sleep, validate_cert=True, username=None, password=Non follow_redirects=True, max_redirects=15, ) - resp = HTTPClient().fetch(req) - break + return HTTPClient().fetch(req) except Exception as e: + if i + 1 == max_sleep: + raise print(e) - - return resp + time.sleep(i) def test_manual_https(preserve_config): @@ -104,37 +102,51 @@ def test_extra_traefik_config(): os.makedirs(dynamic_config_dir, exist_ok=True) extra_static_config = { - "entryPoints": {"no_auth_api": {"address": "127.0.0.1:9999"}}, - "api": {"dashboard": True, "entrypoint": "no_auth_api"}, + "entryPoints": {"alsoHub": {"address": "127.0.0.1:9999"}}, } extra_dynamic_config = { - "frontends": { - "test": { - "backend": "test", - "routes": { - "rule1": {"rule": "PathPrefixStrip: /the/hub/runs/here/too"} + "http": { + "middlewares": { + "testHubStripPrefix": { + "stripPrefix": {"prefixes": ["/the/hub/runs/here/too"]} + } + }, + "routers": { + "test1": { + "rule": "PathPrefix(`/hub`)", + "entryPoints": ["alsoHub"], + "service": "test", }, - } - }, - "backends": { - # redirect to hub - "test": {"servers": {"server1": {"url": "http://127.0.0.1:15001"}}} + "test2": { + "rule": "PathPrefix(`/the/hub/runs/here/too`)", + "middlewares": ["testHubStripPrefix"], + "entryPoints": ["http"], + "service": "test", + }, + }, + "services": { + "test": { + "loadBalancer": { + # forward requests to the hub + "servers": [{"url": "http://127.0.0.1:15001"}] + } + } + }, }, } success = False for i in range(5): - time.sleep(i) try: with pytest.raises(HTTPClientError, match="HTTP 401: Unauthorized"): - # The default dashboard entrypoint requires authentication, so it should fail - req = HTTPRequest("http://127.0.0.1:8099/dashboard/", method="GET") - HTTPClient().fetch(req) + # The default api entrypoint requires authentication, so it should fail + HTTPClient().fetch("http://localhost:8099/api") success = True break - except Exception: - pass + except Exception as e: + print(e) + time.sleep(i) assert success == True @@ -153,8 +165,9 @@ def test_extra_traefik_config(): # load the extra config reload_component("proxy") + # check hub page # the new dashboard entrypoint shouldn't require authentication anymore - resp = send_request(url="http://127.0.0.1:9999/dashboard/", max_sleep=5) + resp = send_request(url="http://127.0.0.1:9999/hub/login", max_sleep=5) assert resp.code == 200 # test extra dynamic config diff --git a/setup.py b/setup.py index 12d7240..255ab75 100644 --- a/setup.py +++ b/setup.py @@ -14,11 +14,10 @@ setup( "ruamel.yaml==0.17.*", "jinja2", "pluggy==1.*", - "passlib", "backoff", "requests", "bcrypt", - "jupyterhub-traefik-proxy==0.3.*", + "jupyterhub-traefik-proxy==1.0.0b3", ], entry_points={ "console_scripts": [ diff --git a/tests/test_configurer.py b/tests/test_configurer.py index c348236..29c073a 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -156,8 +156,8 @@ def test_traefik_api_default(): """ c = apply_mock_config({}) - assert c.TraefikTomlProxy.traefik_api_username == "api_admin" - assert len(c.TraefikTomlProxy.traefik_api_password) == 0 + assert c.TraefikProxy.traefik_api_username == "api_admin" + assert len(c.TraefikProxy.traefik_api_password) == 0 def test_set_traefik_api(): @@ -167,8 +167,8 @@ def test_set_traefik_api(): c = apply_mock_config( {"traefik_api": {"username": "some_user", "password": "1234"}} ) - assert c.TraefikTomlProxy.traefik_api_username == "some_user" - assert c.TraefikTomlProxy.traefik_api_password == "1234" + assert c.TraefikProxy.traefik_api_username == "some_user" + assert c.TraefikProxy.traefik_api_password == "1234" def test_cull_service_default(): @@ -268,7 +268,7 @@ def test_load_secrets(tljh_dir): tljh_config = configurer.load_config() assert tljh_config["traefik_api"]["password"] == "traefik-password" c = apply_mock_config(tljh_config) - assert c.TraefikTomlProxy.traefik_api_password == "traefik-password" + assert c.TraefikProxy.traefik_api_password == "traefik-password" def test_auth_native(): diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 0cb406b..f78898f 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -15,30 +15,51 @@ def test_download_traefik(tmpdir): assert (traefik_bin.stat().mode & 0o777) == 0o755 +def _read_toml(path): + """Read a toml file + + print config for debugging on failure + """ + print(path) + with open(path) as f: + toml_cfg = f.read() + print(toml_cfg) + return toml.loads(toml_cfg) + + +def _read_static_config(state_dir): + return _read_toml(os.path.join(state_dir, "traefik.toml")) + + +def _read_dynamic_config(state_dir): + return _read_toml(os.path.join(state_dir, "rules", "dynamic.toml")) + + def test_default_config(tmpdir, tljh_dir): state_dir = tmpdir.mkdir("state") traefik.ensure_traefik_config(str(state_dir)) assert state_dir.join("traefik.toml").exists() - traefik_toml = os.path.join(state_dir, "traefik.toml") - with open(traefik_toml) as f: - toml_cfg = f.read() - # print config for debugging on failure - print(config.CONFIG_FILE) - print(toml_cfg) - cfg = toml.loads(toml_cfg) - assert cfg["defaultEntryPoints"] == ["http"] - assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - # runtime generated entry, value not testable - cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + os.path.join(state_dir, "traefik.toml") + rules_dir = os.path.join(state_dir, "rules") + cfg = _read_static_config(state_dir) + assert cfg["api"] == {} assert cfg["entryPoints"] == { - "http": {"address": ":80"}, + "http": { + "address": ":80", + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, "auth_api": { - "address": "127.0.0.1:8099", - "auth": {"basic": {"users": [""]}}, - "whiteList": {"sourceRange": ["127.0.0.1"]}, + "address": "localhost:8099", }, } + assert cfg["providers"] == { + "providersThrottleDuration": "0s", + "file": {"directory": rules_dir, "watch": True}, + } + + dynamic_config = _read_dynamic_config(state_dir) + assert dynamic_config == {} def test_letsencrypt_config(tljh_dir): @@ -51,34 +72,55 @@ def test_letsencrypt_config(tljh_dir): config.CONFIG_FILE, "https.letsencrypt.domains", ["testing.jovyan.org"] ) traefik.ensure_traefik_config(str(state_dir)) - traefik_toml = os.path.join(state_dir, "traefik.toml") - with open(traefik_toml) as f: - toml_cfg = f.read() - # print config for debugging on failure - print(config.CONFIG_FILE) - print(toml_cfg) - cfg = toml.loads(toml_cfg) - assert cfg["defaultEntryPoints"] == ["http", "https"] - assert "acme" in cfg - assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - # runtime generated entry, value not testable - cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + cfg = _read_static_config(state_dir) assert cfg["entryPoints"] == { - "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, - "https": {"address": ":443", "tls": {"minVersion": "VersionTLS12"}}, + "http": { + "address": ":80", + "http": { + "redirections": { + "entryPoint": { + "scheme": "https", + "to": "https", + }, + }, + }, + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, + "https": { + "address": ":443", + "http": {"tls": {"options": "default"}}, + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, "auth_api": { - "address": "127.0.0.1:8099", - "auth": {"basic": {"users": [""]}}, - "whiteList": {"sourceRange": ["127.0.0.1"]}, + "address": "localhost:8099", }, } - assert cfg["acme"] == { + assert "tls" not in cfg + + dynamic_config = _read_dynamic_config(state_dir) + + assert dynamic_config["tls"] == { + "options": {"default": {"minVersion": "VersionTLS12"}}, + "stores": { + "default": { + "defaultGeneratedCert": { + "resolver": "letsencrypt", + "domain": { + "main": "testing.jovyan.org", + "sans": [], + }, + } + } + }, + } + assert "certificateResolvers" in cfg + assert "letsencrypt" in cfg["certificateResolvers"] + + assert cfg["certificateResolvers"]["letsencrypt"]["acme"] == { "email": "fake@jupyter.org", "storage": "acme.json", - "entryPoint": "https", "httpChallenge": {"entryPoint": "http"}, - "domains": [{"main": "testing.jovyan.org"}], } @@ -88,33 +130,50 @@ def test_manual_ssl_config(tljh_dir): config.set_config_value(config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key") config.set_config_value(config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert") traefik.ensure_traefik_config(str(state_dir)) - traefik_toml = os.path.join(state_dir, "traefik.toml") - with open(traefik_toml) as f: - toml_cfg = f.read() - # print config for debugging on failure - print(config.CONFIG_FILE) - print(toml_cfg) - cfg = toml.loads(toml_cfg) - assert cfg["defaultEntryPoints"] == ["http", "https"] - assert "acme" not in cfg - assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - # runtime generated entry, value not testable - cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + + cfg = _read_static_config(state_dir) + assert cfg["entryPoints"] == { - "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, - "https": { - "address": ":443", - "tls": { - "minVersion": "VersionTLS12", - "certificates": [ - {"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"} - ], + "http": { + "address": ":80", + "http": { + "redirections": { + "entryPoint": { + "scheme": "https", + "to": "https", + }, + }, + }, + "transport": { + "respondingTimeouts": { + "idleTimeout": "10m", + } }, }, + "https": { + "address": ":443", + "http": {"tls": {"options": "default"}}, + "transport": {"respondingTimeouts": {"idleTimeout": "10m"}}, + }, "auth_api": { - "address": "127.0.0.1:8099", - "auth": {"basic": {"users": [""]}}, - "whiteList": {"sourceRange": ["127.0.0.1"]}, + "address": "localhost:8099", + }, + } + assert "tls" not in cfg + + dynamic_config = _read_dynamic_config(state_dir) + + assert "tls" in dynamic_config + + assert dynamic_config["tls"] == { + "options": {"default": {"minVersion": "VersionTLS12"}}, + "stores": { + "default": { + "defaultCertificate": { + "certFile": "/path/to/ssl.cert", + "keyFile": "/path/to/ssl.key", + } + } }, } @@ -131,18 +190,18 @@ def test_extra_config(tmpdir, tljh_dir): toml_cfg = toml.load(traefik_toml) # Make sure the defaults are what we expect - assert toml_cfg["logLevel"] == "INFO" + assert toml_cfg["log"]["level"] == "INFO" with pytest.raises(KeyError): - toml_cfg["checkNewVersion"] - assert toml_cfg["entryPoints"]["auth_api"]["address"] == "127.0.0.1:8099" + toml_cfg["api"]["dashboard"] + assert toml_cfg["entryPoints"]["auth_api"]["address"] == "localhost:8099" extra_config = { # modify existing value - "logLevel": "ERROR", - # modify existing value with multiple levels - "entryPoints": {"auth_api": {"address": "127.0.0.1:9999"}}, + "log": { + "level": "ERROR", + }, # add new setting - "checkNewVersion": False, + "api": {"dashboard": True}, } with open(os.path.join(extra_config_dir, "extra.toml"), "w+") as extra_config_file: @@ -155,6 +214,5 @@ def test_extra_config(tmpdir, tljh_dir): toml_cfg = toml.load(traefik_toml) # Check that the defaults were updated by the extra config - assert toml_cfg["logLevel"] == "ERROR" - assert toml_cfg["checkNewVersion"] == False - assert toml_cfg["entryPoints"]["auth_api"]["address"] == "127.0.0.1:9999" + assert toml_cfg["log"]["level"] == "ERROR" + assert toml_cfg["api"]["dashboard"] == True diff --git a/tljh/config.py b/tljh/config.py index 4459621..60d5cc6 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -249,8 +249,11 @@ def check_hub_ready(): r = requests.get( "http://127.0.0.1:%d%s/hub/api" % (http_port, base_url), verify=False ) + if r.status_code != 200: + print(f"Hub not ready: (HTTP status {r.status_code})") return r.status_code == 200 - except: + except Exception as e: + print(f"Hub not ready: {e}") return False diff --git a/tljh/configurer.py b/tljh/configurer.py index e5c2ea2..c5ddf18 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -239,8 +239,13 @@ def update_traefik_api(c, config): """ Set traefik api endpoint credentials """ - c.TraefikTomlProxy.traefik_api_username = config["traefik_api"]["username"] - c.TraefikTomlProxy.traefik_api_password = config["traefik_api"]["password"] + c.TraefikProxy.traefik_api_username = config["traefik_api"]["username"] + c.TraefikProxy.traefik_api_password = config["traefik_api"]["password"] + https = config["https"] + if https["enabled"]: + c.TraefikProxy.traefik_entrypoint = "https" + else: + c.TraefikProxy.traefik_entrypoint = "http" def set_cull_idle_service(config): diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index c61c06c..7abb617 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -5,13 +5,12 @@ JupyterHub config for the littlest jupyterhub. import os from glob import glob -from jupyterhub_traefik_proxy import TraefikTomlProxy - from tljh import configurer from tljh.config import CONFIG_DIR, INSTALL_PREFIX, USER_ENV_PREFIX from tljh.user_creating_spawner import UserCreatingSpawner from tljh.utils import get_plugin_manager +c = get_config() # noqa c.JupyterHub.spawner_class = UserCreatingSpawner # leave users running when the Hub restarts @@ -20,11 +19,11 @@ c.JupyterHub.cleanup_servers = False # Use a high port so users can try this on machines with a JupyterHub already present c.JupyterHub.hub_port = 15001 -c.TraefikTomlProxy.should_start = False +c.TraefikProxy.should_start = False dynamic_conf_file_path = os.path.join(INSTALL_PREFIX, "state", "rules", "rules.toml") -c.TraefikTomlProxy.toml_dynamic_config_file = dynamic_conf_file_path -c.JupyterHub.proxy_class = TraefikTomlProxy +c.TraefikFileProviderProxy.dynamic_config_file = dynamic_conf_file_path +c.JupyterHub.proxy_class = "traefik_file" c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, "bin")] c.SystemdSpawner.default_shell = "/bin/bash" diff --git a/tljh/traefik-dynamic.toml.tpl b/tljh/traefik-dynamic.toml.tpl new file mode 100644 index 0000000..b7e96d2 --- /dev/null +++ b/tljh/traefik-dynamic.toml.tpl @@ -0,0 +1,25 @@ +# traefik.toml dynamic config (mostly TLS) +# dynamic config in the static config file will be ignored +{% if https['enabled'] %} +[tls] + [tls.options.default] + minVersion = "VersionTLS12" + + {% if https['tls']['cert'] %} + [tls.stores.default.defaultCertificate] + certFile = "{{ https['tls']['cert'] }}" + keyFile = "{{ https['tls']['key'] }}" + {% endif %} + + {% if https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} + [tls.stores.default.defaultGeneratedCert] + resolver = "letsencrypt" + [tls.stores.default.defaultGeneratedCert.domain] + main = "{{ https['letsencrypt']['domains'][0] }}" + sans = [ + {% for domain in https['letsencrypt']['domains'][1:] %} + "{{ domain }}", + {% endfor %} + ] + {% endif %} +{% endif %} diff --git a/tljh/traefik.py b/tljh/traefik.py index e5aae6e..ca2b2b8 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -5,13 +5,13 @@ import logging import os import tarfile from glob import glob +from pathlib import Path from subprocess import run import backoff import requests import toml from jinja2 import Template -from passlib.apache import HtpasswdFile from tljh.configurer import _merge_dictionaries, load_config @@ -35,6 +35,8 @@ checksums = { "linux_arm64": "0a65ead411307669916ba629fa13f698acda0b2c5387abe0309b43e168e4e57f", } +_tljh_path = Path(__file__).parent.resolve() + def checksum_file(path_or_file): """Compute the sha256 checksum of a path""" @@ -58,11 +60,14 @@ def check_traefik_version(traefik_bin): """Check the traefik version from `traefik version` output""" try: - version_out = run([traefik_bin, "version"], capture=True, text=True) + version_out = run( + [traefik_bin, "version"], + capture_output=True, + text=True, + ).stdout except (FileNotFoundError, OSError) as e: logger.debug(f"Failed to get traefik version: {e}") return False - versions = {} for line in version_out.splitlines(): before, _, after = line.partition(":") key = before.strip() @@ -118,15 +123,6 @@ def ensure_traefik_binary(prefix): os.chmod(traefik_bin, 0o755) -def compute_basic_auth(username, password): - """Generate hashed HTTP basic auth from traefik_api username+password""" - ht = HtpasswdFile() - # generate htpassword - ht.set_password(username, password) - hashed_password = str(ht.to_string()).split(":")[1][:-3] - return username + ":" + hashed_password - - def load_extra_config(extra_config_dir): extra_configs = sorted(glob(os.path.join(extra_config_dir, "*.toml"))) # Load the toml list of files into dicts and merge them @@ -139,16 +135,13 @@ def ensure_traefik_config(state_dir): traefik_std_config_file = os.path.join(state_dir, "traefik.toml") traefik_extra_config_dir = os.path.join(CONFIG_DIR, "traefik_config.d") traefik_dynamic_config_dir = os.path.join(state_dir, "rules") - - config = load_config() - config["traefik_api"]["basic_auth"] = compute_basic_auth( - config["traefik_api"]["username"], - config["traefik_api"]["password"], + traefik_dynamic_config_file = os.path.join( + traefik_dynamic_config_dir, "dynamic.toml" ) - with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f: - template = Template(f.read()) - std_config = template.render(config) + config = load_config() + config["traefik_dynamic_config_dir"] = traefik_dynamic_config_dir + https = config["https"] letsencrypt = https["letsencrypt"] tls = https["tls"] @@ -163,6 +156,14 @@ def ensure_traefik_config(state_dir): ): raise ValueError("Both email and domains must be set for letsencrypt") + with (_tljh_path / "traefik.toml.tpl").open() as f: + template = Template(f.read()) + std_config = template.render(config) + + with (_tljh_path / "traefik-dynamic.toml.tpl").open() as f: + dynamic_template = Template(f.read()) + dynamic_config = dynamic_template.render(config) + # Ensure traefik extra static config dir exists and is private os.makedirs(traefik_extra_config_dir, mode=0o700, exist_ok=True) @@ -181,6 +182,12 @@ def ensure_traefik_config(state_dir): os.fchmod(f.fileno(), 0o600) toml.dump(new_toml, f) + with open(os.path.join(traefik_dynamic_config_dir, "dynamic.toml"), "w") as f: + os.fchmod(f.fileno(), 0o600) + # validate toml syntax before writing + toml.loads(dynamic_config) + f.write(dynamic_config) + with open(os.path.join(traefik_dynamic_config_dir, "rules.toml"), "w") as f: os.fchmod(f.fileno(), 0o600) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 4364c16..eb6a8ee 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -1,74 +1,59 @@ -# traefik.toml file template -{% if https['enabled'] %} -defaultEntryPoints = ["http", "https"] -{% else %} -defaultEntryPoints = ["http"] -{% endif %} +# traefik.toml static config file template +# dynamic config (e.g. TLS) goes in traefik-dynamic.toml.tpl + +# enable API +[api] + +[log] +level = "INFO" -logLevel = "INFO" # log errors, which could be proxy errors [accessLog] format = "json" + [accessLog.filters] statusCodes = ["500-999"] -[accessLog.fields.headers] [accessLog.fields.headers.names] Authorization = "redact" Cookie = "redact" Set-Cookie = "redact" X-Xsrftoken = "redact" -[respondingTimeouts] -idleTimeout = "10m0s" - [entryPoints] [entryPoints.http] - address = ":{{http['port']}}" - {% if https['enabled'] %} - [entryPoints.http.redirect] - entryPoint = "https" - {% endif %} + address = ":{{ http['port'] }}" + [entryPoints.http.transport.respondingTimeouts] + idleTimeout = "10m" {% if https['enabled'] %} + [entryPoints.http.http.redirections.entryPoint] + to = "https" + scheme = "https" + [entryPoints.https] - address = ":{{https['port']}}" - [entryPoints.https.tls] - minVersion = "VersionTLS12" - {% if https['tls']['cert'] %} - [[entryPoints.https.tls.certificates]] - certFile = "{{https['tls']['cert']}}" - keyFile = "{{https['tls']['key']}}" - {% endif %} + address = ":{{ https['port'] }}" + [entryPoints.https.http.tls] + options = "default" + + [entryPoints.https.transport.respondingTimeouts] + idleTimeout = "10m" {% endif %} + [entryPoints.auth_api] - address = "127.0.0.1:{{traefik_api['port']}}" - [entryPoints.auth_api.whiteList] - sourceRange = ['{{traefik_api['ip']}}'] - [entryPoints.auth_api.auth.basic] - users = ['{{ traefik_api['basic_auth'] }}'] + address = "localhost:{{ traefik_api['port'] }}" -[wss] -protocol = "http" - -[api] -dashboard = true -entrypoint = "auth_api" - -{% if https['enabled'] and https['letsencrypt']['email'] %} -[acme] -email = "{{https['letsencrypt']['email']}}" +{% if https['enabled'] and https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} +[certificateResolvers.letsencrypt.acme] +email = "{{ https['letsencrypt']['email'] }}" storage = "acme.json" -entryPoint = "https" - [acme.httpChallenge] - entryPoint = "http" - -{% for domain in https['letsencrypt']['domains'] %} -[[acme.domains]] - main = "{{domain}}" -{% endfor %} +[certificateResolvers.letsencrypt.acme.httpChallenge] +entryPoint = "http" {% endif %} -[file] -directory = "rules" +[providers] +providersThrottleDuration = "0s" + +[providers.file] +directory = "{{ traefik_dynamic_config_dir }}" watch = true From 2600e5ddc4051195ac825d01165ea07a6a5a0f7c Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 13:23:25 +0200 Subject: [PATCH 117/232] ensure hub env is on $PATH in jupyterhub service required for `--upgrade-db` to work, since it assumes `alembic` is on $PATH --- tljh/systemd-units/jupyterhub.service | 1 + 1 file changed, 1 insertion(+) diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index 63527c4..0648830 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -15,6 +15,7 @@ PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes Environment=TLJH_INSTALL_PREFIX={install_prefix} +Environment=PATH={install_prefix}/hub/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin # 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 From 776ff5273b553d61cde4fa29365ae39ae1ab806b Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 13:05:34 +0200 Subject: [PATCH 118/232] update letsEncrypt config after testing verified this works now --- tests/test_traefik.py | 8 ++++---- tljh/traefik-dynamic.toml.tpl | 12 ++++++------ tljh/traefik.toml.tpl | 5 ++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index f78898f..472b3b8 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -114,13 +114,13 @@ def test_letsencrypt_config(tljh_dir): } }, } - assert "certificateResolvers" in cfg - assert "letsencrypt" in cfg["certificateResolvers"] + assert "certificatesResolvers" in cfg + assert "letsencrypt" in cfg["certificatesResolvers"] - assert cfg["certificateResolvers"]["letsencrypt"]["acme"] == { + assert cfg["certificatesResolvers"]["letsencrypt"]["acme"] == { "email": "fake@jupyter.org", "storage": "acme.json", - "httpChallenge": {"entryPoint": "http"}, + "tlsChallenge": {}, } diff --git a/tljh/traefik-dynamic.toml.tpl b/tljh/traefik-dynamic.toml.tpl index b7e96d2..a233f1d 100644 --- a/tljh/traefik-dynamic.toml.tpl +++ b/tljh/traefik-dynamic.toml.tpl @@ -5,21 +5,21 @@ [tls.options.default] minVersion = "VersionTLS12" - {% if https['tls']['cert'] %} + {% if https['tls']['cert'] -%} [tls.stores.default.defaultCertificate] certFile = "{{ https['tls']['cert'] }}" keyFile = "{{ https['tls']['key'] }}" - {% endif %} + {%- endif %} - {% if https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} + {% if https['letsencrypt']['email'] and https['letsencrypt']['domains'] -%} [tls.stores.default.defaultGeneratedCert] resolver = "letsencrypt" [tls.stores.default.defaultGeneratedCert.domain] main = "{{ https['letsencrypt']['domains'][0] }}" sans = [ - {% for domain in https['letsencrypt']['domains'][1:] %} + {% for domain in https['letsencrypt']['domains'][1:] -%} "{{ domain }}", - {% endfor %} + {%- endfor %} ] - {% endif %} + {%- endif %} {% endif %} diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index eb6a8ee..0c4ac8c 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -44,11 +44,10 @@ X-Xsrftoken = "redact" address = "localhost:{{ traefik_api['port'] }}" {% if https['enabled'] and https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} -[certificateResolvers.letsencrypt.acme] +[certificatesResolvers.letsencrypt.acme] email = "{{ https['letsencrypt']['email'] }}" storage = "acme.json" -[certificateResolvers.letsencrypt.acme.httpChallenge] -entryPoint = "http" +[certificatesResolvers.letsencrypt.acme.tlsChallenge] {% endif %} [providers] From 0e76f6dff90c745f684a93a59829a99f1e8fc9d3 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 13:11:04 +0200 Subject: [PATCH 119/232] support letsencrypt staging server for easier testing --- tljh/configurer.py | 1 + tljh/traefik.toml.tpl | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tljh/configurer.py b/tljh/configurer.py index c5ddf18..8e49d75 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -40,6 +40,7 @@ default = { "letsencrypt": { "email": "", "domains": [], + "staging": False, }, }, "traefik_api": { diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 0c4ac8c..e1f82a1 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -47,6 +47,9 @@ X-Xsrftoken = "redact" [certificatesResolvers.letsencrypt.acme] email = "{{ https['letsencrypt']['email'] }}" storage = "acme.json" +{% if https['letsencrypt']['staging'] -%} +caServer = "https://acme-staging-v02.api.letsencrypt.org/directory" +{%- endif %} [certificatesResolvers.letsencrypt.acme.tlsChallenge] {% endif %} From 5763758fa40c65cf5b7b9b54fbe1f513f9818b49 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 14:53:27 +0200 Subject: [PATCH 120/232] traefik v2.10.1 Co-authored-by: Erik Sundell --- tljh/traefik.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tljh/traefik.py b/tljh/traefik.py index ca2b2b8..be07cf4 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -27,7 +27,8 @@ elif machine == "x86_64": else: plat = None -traefik_version = "2.9.9" +# Traefik releases: https://github.com/traefik/traefik/releases +traefik_version = "2.10.1" # record sha256 hashes for supported platforms here checksums = { From 7f53a4f14c6c95421775dfac710729c5e425e3fb Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 14:57:39 +0200 Subject: [PATCH 121/232] update traefik checksums --- tljh/traefik.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tljh/traefik.py b/tljh/traefik.py index be07cf4..4ea0d49 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -31,9 +31,10 @@ else: traefik_version = "2.10.1" # record sha256 hashes for supported platforms here +# checksums are published in the checksums.txt of each release checksums = { - "linux_amd64": "141db1434ae76890915486a4bc5ecf3dbafc8ece78984ce1a8db07737c42db88", - "linux_arm64": "0a65ead411307669916ba629fa13f698acda0b2c5387abe0309b43e168e4e57f", + "linux_amd64": "8d9bce0e6a5bf40b5399dbb1d5e3e5c57b9f9f04dd56a2dd57cb0713130bc824", + "linux_arm64": "260a574105e44901f8c9c562055936d81fbd9c96a21daaa575502dc69bfe390a", } _tljh_path = Path(__file__).parent.resolve() @@ -103,7 +104,7 @@ def ensure_traefik_binary(prefix): os.remove(traefik_bin) traefik_url = ( - "https://github.com/containous/traefik/releases" + "https://github.com/traefik/traefik/releases" f"/download/v{traefik_version}/traefik_v{traefik_version}_{plat}.tar.gz" ) From f0de1db60752433521276b022510ec3eb57be1a7 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Tue, 16 May 2023 21:01:15 +0400 Subject: [PATCH 122/232] docs: disable navigation with arrow keys --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 77036f0..0c40f58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -38,6 +38,7 @@ html_static_path = ["_static"] # pydata_sphinx_theme reference: https://pydata-sphinx-theme.readthedocs.io/en/stable/index.html html_theme = "pydata_sphinx_theme" html_theme_options = { + "navigation_with_keys": False, "icon_links": [ { "name": "GitHub", From 33ac7239fe9900b698c50a8e40ee23403b2023d1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 20:58:53 +0200 Subject: [PATCH 123/232] jupyterhub-traefik-proxy 1.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 255ab75..7ba44c7 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ setup( "backoff", "requests", "bcrypt", - "jupyterhub-traefik-proxy==1.0.0b3", + "jupyterhub-traefik-proxy==1.*", ], entry_points={ "console_scripts": [ From dfc8b5557cff2402c72a55183c3dcc1a4c1c0920 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 21:07:20 +0200 Subject: [PATCH 124/232] debugging progress page output --- integration-tests/test_bootstrap.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index 2d4e806..d8517ec 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -4,6 +4,7 @@ Test running bootstrap script in different circumstances import concurrent.futures import os import subprocess +import sys import time BASE_IMAGE = os.getenv("BASE_IMAGE", "ubuntu:20.04") @@ -162,8 +163,13 @@ def verify_progress_page(expected_status_code, timeout): if b"HTTP/1.0 200 OK" in resp: progress_page_status = True break - except Exception: - time.sleep(2) + else: + print( + f"Unexpected progress page response: {resp[:100]}", file=sys.stderr + ) + except Exception as e: + print(f"Error getting progress page: {e}", file=sys.stderr) + time.sleep(1) continue return progress_page_status From 59648b79d49c67e10eb301af8f6ffc37058c5652 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 21:10:44 +0200 Subject: [PATCH 125/232] increase progress page test timeout seems to be why it fails sometimes --- integration-tests/test_bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index d8517ec..545ba4c 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -185,7 +185,7 @@ def test_progress_page(): ) # Check if progress page started - started = verify_progress_page(expected_status_code=200, timeout=120) + started = verify_progress_page(expected_status_code=200, timeout=180) assert started # This will fail start tljh but should successfully get to the point From aee707c68ca126b0040933ceac0d44426b613aeb Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 16 May 2023 21:12:10 +0200 Subject: [PATCH 126/232] Specify tls cipher suites Co-authored-by: Mridul Seth --- tljh/traefik-dynamic.toml.tpl | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tljh/traefik-dynamic.toml.tpl b/tljh/traefik-dynamic.toml.tpl index a233f1d..a98e7d0 100644 --- a/tljh/traefik-dynamic.toml.tpl +++ b/tljh/traefik-dynamic.toml.tpl @@ -4,7 +4,14 @@ [tls] [tls.options.default] minVersion = "VersionTLS12" - + cipherSuites = [ + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + ] {% if https['tls']['cert'] -%} [tls.stores.default.defaultCertificate] certFile = "{{ https['tls']['cert'] }}" From c5dec7f3b4511c059dc6921a8e57a55b178b6534 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 17 May 2023 09:41:55 +0200 Subject: [PATCH 127/232] update test expectations for traefik ssl --- tests/test_traefik.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 472b3b8..4098586 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -101,7 +101,19 @@ def test_letsencrypt_config(tljh_dir): dynamic_config = _read_dynamic_config(state_dir) assert dynamic_config["tls"] == { - "options": {"default": {"minVersion": "VersionTLS12"}}, + "options": { + "default": { + "minVersion": "VersionTLS12", + "cipherSuites": [ + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + ], + } + }, "stores": { "default": { "defaultGeneratedCert": { @@ -166,7 +178,19 @@ def test_manual_ssl_config(tljh_dir): assert "tls" in dynamic_config assert dynamic_config["tls"] == { - "options": {"default": {"minVersion": "VersionTLS12"}}, + "options": { + "default": { + "minVersion": "VersionTLS12", + "cipherSuites": [ + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + ], + }, + }, "stores": { "default": { "defaultCertificate": { From 8ecb158bc9a7d487e7796015fbf7c06905e4025a Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Wed, 17 May 2023 22:58:07 -0400 Subject: [PATCH 128/232] Update Google auth docs --- docs/howto/auth/google.md | 85 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/docs/howto/auth/google.md b/docs/howto/auth/google.md index 1557a4c..b8c4a34 100644 --- a/docs/howto/auth/google.md +++ b/docs/howto/auth/google.md @@ -2,7 +2,7 @@ # Authenticate using Google -The **Google Authenticator** lets users log into your JupyterHub using their +The **Google OAuthenticator** 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. @@ -74,11 +74,18 @@ IP address** to it. In this case, **you must update your Google application info with the new IP address. ::: -## Configure your JupyterHub to use the Google Oauthenticator +## Step 3: Configure your JupyterHub to use the Google OAuthenticator -We'll use the `tljh-config` tool to configure your JupyterHub's authentication. +### Configuration with `tljh-config` + +In this section we'll use the `tljh-config` tool to configure your JupyterHub's authentication. For more information on `tljh-config`, see [](/topic/tljh-config). +:::{important} +By default, the following allows *anyone* with a Google account to login. +You can set specific allowed users and admins using [](#tljh-set-user-lists). +::: + 1. Log in as an administrator account to your JupyterHub. 2. Open a terminal window. @@ -113,7 +120,77 @@ For more information on `tljh-config`, see [](/topic/tljh-config). sudo tljh-config reload ``` -## Confirm that the new authenticator works +### Advanced Configuration with Google Groups + +Administrative and regular users of your TLJH can also be easily managed with Google Groups. +This requires a service account and a Workspace admin account that can be impersonated by the +service account to read groups in your domain. You may need to contact your Google Workspace +administrator for help performing these steps. + +1. [Create a service account](https://cloud.google.com/iam/docs/service-accounts-create). + +1. [Create a service account key](https://developers.google.com/workspace/guides/create-credentials#create_credentials_for_a_service_account). Keep this key in a safe space, you will need to add it to your instance later. + +1. Setup [domain-wide delegation](https://developers.google.com/workspace/guides/create-credentials#optional_set_up_domain-wide_delegation_for_a_service_account) for the service account that includes the following scopes: + ``` + https://www.googleapis.com/auth/admin.directory.user.readonly + https://www.googleapis.com/auth/admin.directory.group.readonly + ``` +1. Add the service account key to your instance and ensure it is _not_ readable by non-admin users of the hub. + :::{important} + The service account key is a secret. Anyone for whom you configure admin privileges on your TLJH instance will be able to access it. + ::: + +1. Log in as an administrator account to your JupyterHub. + +1. Open a terminal window. + + ```{image} ../../images/notebook/new-terminal-button.png + :alt: New terminal button. + ``` + +1. Install the extra requirements within the hub environment. + + ``` + source /opt/tljh/hub/bin/activate + pip3 install oauthenticator[googlegroups] + deactivate + ``` + +1. Create a configuration directory `jupyterhub_config.d` within `/opt/tljh/config/`. + Any `.py` files within this directory will be sourced for configuration. + + ``` + sudo mkdir /opt/tljh/config/jupyterhub_config.d + ``` + +1. Configure your hub for Google Groups-based authentication by adding the following to a `.py` file within `/opt/tljh/config/jupyterhub_config.d`. + + ```python + from oauthenticator.google import GoogleOAuthenticator + c.JupyterHub.authenticator_class = GoogleOAuthenticator + + c.GoogleOAuthenticator.google_service_account_keys = {'': ''} + c.GoogleOAuthenticator.gsuite_administrator = {'': ''} + c.GoogleOAuthenticator.allowed_google_groups = {'': ['example-group', 'another-example-group']} + c.GoogleOAuthenticator.admin_google_groups = {'': ['example-admin-group', 'another-example-admin-group']} + c.GoogleOAuthenticator.client_id = '' + c.GoogleOAuthenticator.client_secret = '' + c.GoogleOAuthenticator.hosted_domain = '' + c.GoogleOAuthenticator.login_service = '' + c.GoogleOAuthenticator.oauth_callback_url = 'http(s):///hub/oauth_callback' + ``` + + See the [Google OAuthenticator documentation](https://oauthenticator.readthedocs.io/en/latest/reference/api/gen/oauthenticator.google.html) + for more information on these and other configuration options. + + +1. Reload your configuration for the changes to take effect: + ``` + sudo tljh-config reload + ``` + +## Step 4: Confirm that the new authenticator works 1. **Open an incognito window** in your browser (do not log out until you confirm that the new authentication method works!) From be4580c21eb56e88b077605edc89510816bc13cd Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Wed, 17 May 2023 23:01:01 -0400 Subject: [PATCH 129/232] Update user config language --- docs/topic/tljh-config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topic/tljh-config.md b/docs/topic/tljh-config.md index f994213..561e4b9 100644 --- a/docs/topic/tljh-config.md +++ b/docs/topic/tljh-config.md @@ -89,9 +89,9 @@ sudo tljh-config reload proxy ### User Lists -- `users.allowed` takes in usernames to whitelist +- `users.allowed` takes in usernames to allow -- `users.banned` takes in usernames to blacklist +- `users.banned` takes in usernames to ban - `users.admin` takes in usernames to designate as admins From 324ded0003c0f0ae38188219ea8c5c95bec2bfe3 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 May 2023 11:18:09 +0200 Subject: [PATCH 130/232] Consistent whitespace chomping in jinja templates Always chomp left, never right. This is what we do in the helm chart templates and has been very easy to be consistent with for a good result with little to no issues. --- tljh/traefik-dynamic.toml.tpl | 8 ++++---- tljh/traefik.toml.tpl | 12 +++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tljh/traefik-dynamic.toml.tpl b/tljh/traefik-dynamic.toml.tpl index a98e7d0..f1144d6 100644 --- a/tljh/traefik-dynamic.toml.tpl +++ b/tljh/traefik-dynamic.toml.tpl @@ -1,6 +1,6 @@ # traefik.toml dynamic config (mostly TLS) # dynamic config in the static config file will be ignored -{% if https['enabled'] %} +{%- if https['enabled'] %} [tls] [tls.options.default] minVersion = "VersionTLS12" @@ -12,13 +12,13 @@ "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", ] - {% if https['tls']['cert'] -%} + {%- if https['tls']['cert'] %} [tls.stores.default.defaultCertificate] certFile = "{{ https['tls']['cert'] }}" keyFile = "{{ https['tls']['key'] }}" {%- endif %} - {% if https['letsencrypt']['email'] and https['letsencrypt']['domains'] -%} + {%- if https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} [tls.stores.default.defaultGeneratedCert] resolver = "letsencrypt" [tls.stores.default.defaultGeneratedCert.domain] @@ -29,4 +29,4 @@ {%- endfor %} ] {%- endif %} -{% endif %} +{%- endif %} diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index e1f82a1..fa5b6ef 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -23,35 +23,37 @@ X-Xsrftoken = "redact" [entryPoints] [entryPoints.http] address = ":{{ http['port'] }}" + [entryPoints.http.transport.respondingTimeouts] idleTimeout = "10m" - {% if https['enabled'] %} + {%- if https['enabled'] %} [entryPoints.http.http.redirections.entryPoint] to = "https" scheme = "https" [entryPoints.https] address = ":{{ https['port'] }}" + [entryPoints.https.http.tls] options = "default" [entryPoints.https.transport.respondingTimeouts] idleTimeout = "10m" - {% endif %} + {%- endif %} [entryPoints.auth_api] address = "localhost:{{ traefik_api['port'] }}" -{% if https['enabled'] and https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} +{%- if https['enabled'] and https['letsencrypt']['email'] and https['letsencrypt']['domains'] %} [certificatesResolvers.letsencrypt.acme] email = "{{ https['letsencrypt']['email'] }}" storage = "acme.json" -{% if https['letsencrypt']['staging'] -%} +{%- if https['letsencrypt']['staging'] %} caServer = "https://acme-staging-v02.api.letsencrypt.org/directory" {%- endif %} [certificatesResolvers.letsencrypt.acme.tlsChallenge] -{% endif %} +{%- endif %} [providers] providersThrottleDuration = "0s" From eeb76c0894d71a3c4ae3ee1f80cf2c5b67b5912c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 May 2023 14:08:34 +0000 Subject: [PATCH 131/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/howto/auth/google.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/howto/auth/google.md b/docs/howto/auth/google.md index b8c4a34..bba1f0c 100644 --- a/docs/howto/auth/google.md +++ b/docs/howto/auth/google.md @@ -82,7 +82,7 @@ In this section we'll use the `tljh-config` tool to configure your JupyterHub's For more information on `tljh-config`, see [](/topic/tljh-config). :::{important} -By default, the following allows *anyone* with a Google account to login. +By default, the following allows _anyone_ with a Google account to login. You can set specific allowed users and admins using [](#tljh-set-user-lists). ::: @@ -124,7 +124,7 @@ You can set specific allowed users and admins using [](#tljh-set-user-lists). Administrative and regular users of your TLJH can also be easily managed with Google Groups. This requires a service account and a Workspace admin account that can be impersonated by the -service account to read groups in your domain. You may need to contact your Google Workspace +service account to read groups in your domain. You may need to contact your Google Workspace administrator for help performing these steps. 1. [Create a service account](https://cloud.google.com/iam/docs/service-accounts-create). @@ -150,16 +150,16 @@ administrator for help performing these steps. ``` 1. Install the extra requirements within the hub environment. - + ``` source /opt/tljh/hub/bin/activate pip3 install oauthenticator[googlegroups] deactivate ``` -1. Create a configuration directory `jupyterhub_config.d` within `/opt/tljh/config/`. +1. Create a configuration directory `jupyterhub_config.d` within `/opt/tljh/config/`. Any `.py` files within this directory will be sourced for configuration. - + ``` sudo mkdir /opt/tljh/config/jupyterhub_config.d ``` @@ -170,9 +170,9 @@ administrator for help performing these steps. from oauthenticator.google import GoogleOAuthenticator c.JupyterHub.authenticator_class = GoogleOAuthenticator - c.GoogleOAuthenticator.google_service_account_keys = {'': ''} - c.GoogleOAuthenticator.gsuite_administrator = {'': ''} - c.GoogleOAuthenticator.allowed_google_groups = {'': ['example-group', 'another-example-group']} + c.GoogleOAuthenticator.google_service_account_keys = {'': ''} + c.GoogleOAuthenticator.gsuite_administrator = {'': ''} + c.GoogleOAuthenticator.allowed_google_groups = {'': ['example-group', 'another-example-group']} c.GoogleOAuthenticator.admin_google_groups = {'': ['example-admin-group', 'another-example-admin-group']} c.GoogleOAuthenticator.client_id = '' c.GoogleOAuthenticator.client_secret = '' @@ -181,10 +181,9 @@ administrator for help performing these steps. c.GoogleOAuthenticator.oauth_callback_url = 'http(s):///hub/oauth_callback' ``` - See the [Google OAuthenticator documentation](https://oauthenticator.readthedocs.io/en/latest/reference/api/gen/oauthenticator.google.html) + See the [Google OAuthenticator documentation](https://oauthenticator.readthedocs.io/en/latest/reference/api/gen/oauthenticator.google.html) for more information on these and other configuration options. - 1. Reload your configuration for the changes to take effect: ``` sudo tljh-config reload From a86e4ce153c452501f5a1d84015c6a3d078f7a28 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 13 May 2023 22:25:20 +0200 Subject: [PATCH 132/232] user env: only upgrade jupyterhub in user env when upgrading tljh --- MANIFEST.in | 2 +- tljh/installer.py | 12 +++++++++++- ...nts-base.txt => requirements-user-env-extras.txt} | 8 ++++---- tljh/requirements-user-env.txt | 7 +++++++ 4 files changed, 23 insertions(+), 6 deletions(-) rename tljh/{requirements-base.txt => requirements-user-env-extras.txt} (74%) create mode 100644 tljh/requirements-user-env.txt diff --git a/MANIFEST.in b/MANIFEST.in index 4b590a3..50c1cf7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include tljh/systemd-units/* include tljh/*.tpl -include tljh/requirements-base.txt +include tljh/requirements-*.txt diff --git a/tljh/installer.py b/tljh/installer.py index 755326e..0054ab9 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -200,6 +200,8 @@ def ensure_user_environment(user_requirements_txt_file): # Case 1: no existing environment if not package_versions: + is_fresh_install = True + # 1a. no environment, but prefix exists. # Abort to avoid clobbering something we don't recognize if os.path.exists(USER_ENV_PREFIX) and os.listdir(USER_ENV_PREFIX): @@ -219,6 +221,8 @@ def ensure_user_environment(user_requirements_txt_file): # quick sanity check: we should have conda and mamba! assert "conda" in package_versions assert "mamba" in package_versions + else: + is_fresh_install = False # next, check Python python_version = package_versions["python"] @@ -259,9 +263,15 @@ def ensure_user_environment(user_requirements_txt_file): conda.ensure_pip_requirements( USER_ENV_PREFIX, - os.path.join(HERE, "requirements-base.txt"), + os.path.join(HERE, "requirements-user-env.txt"), upgrade=True, ) + if is_fresh_install: + conda.ensure_pip_requirements( + USER_ENV_PREFIX, + os.path.join(HERE, "requirements-user-env-extras.txt"), + upgrade=True, + ) if user_requirements_txt_file: # FIXME: This currently fails hard, should fail soft and not abort installer diff --git a/tljh/requirements-base.txt b/tljh/requirements-user-env-extras.txt similarity index 74% rename from tljh/requirements-base.txt rename to tljh/requirements-user-env-extras.txt index f926a5a..7f27c70 100644 --- a/tljh/requirements-base.txt +++ b/tljh/requirements-user-env-extras.txt @@ -1,14 +1,14 @@ # When tljh.installer runs, the users' environment as typically found in -# /opt/tljh/user, is setup with these packages. +# /opt/tljh/user, is installed with these packages. +# +# Whats listed here represents additional packages that the distributions +# installs initially, but doesn't upgrade as tljh is upgraded. # # WARNING: The order of these dependencies matters, this was observed when using # the requirements-txt-fixer pre-commit hook that sorted them and made # our integration tests fail. # -# JupyterHub + notebook package are base requirements for user environment -jupyterhub==4.* notebook==6.* -# Install additional notebook frontends! jupyterlab==3.* # nbgitpuller for easily pulling in Git repositories nbgitpuller==1.* diff --git a/tljh/requirements-user-env.txt b/tljh/requirements-user-env.txt new file mode 100644 index 0000000..a9528e9 --- /dev/null +++ b/tljh/requirements-user-env.txt @@ -0,0 +1,7 @@ +# When tljh.installer runs, the users' environment as typically found in +# /opt/tljh/user, is installed or upgraded with these packages. +# +# Whats listed here should be only the unconditional requirements that we have in +# the user environment. +# +jupyterhub==4.* From eaa16babb543458e77d5558f5990c9a80a6d3975 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 May 2023 23:42:53 +0200 Subject: [PATCH 133/232] hub env: use req. file and add lower bounds for readability --- tljh/installer.py | 20 ++++---------------- tljh/requirements-hub-env.txt | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 16 deletions(-) create mode 100644 tljh/requirements-hub-env.txt diff --git a/tljh/installer.py b/tljh/installer.py index 0054ab9..fc968a3 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -106,25 +106,13 @@ 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 + # Install dependencies for installing pycurl via pip, where build-essential + # is generally useful for installing other packages as well. apt.install_packages(["libssl-dev", "libcurl4-openssl-dev", "build-essential"]) - conda.ensure_pip_packages(prefix, ["pycurl==7.*"], upgrade=True) - conda.ensure_pip_packages( + conda.ensure_pip_requirements( prefix, - [ - "jupyterhub==4.*", - "jupyterhub-systemdspawner==0.17.*", - "jupyterhub-firstuseauthenticator==1.*", - "jupyterhub-nativeauthenticator==1.*", - "jupyterhub-ldapauthenticator==1.*", - "jupyterhub-tmpauthenticator==0.6.*", - "oauthenticator==15.*", - "jupyterhub-idle-culler==1.*", - "git+https://github.com/yuvipanda/jupyterhub-configurator@317759e17c8e48de1b1352b836dac2a230536dba", - ], + os.path.join(HERE, "requirements-hub-env.txt"), upgrade=True, ) traefik.ensure_traefik_binary(prefix) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt new file mode 100644 index 0000000..ba81767 --- /dev/null +++ b/tljh/requirements-hub-env.txt @@ -0,0 +1,29 @@ +# When tljh.installer runs, the hub' environment as typically found in +# /opt/tljh/hub, is upgraded to use these packages. +# +# When a new release is made, the lower bounds should be incremented to the +# latest release to help us narrow the versions based on knowing what tljh +# version is installed from inspecting this file. +# +# If a dependency is bumped to a new major version, we should make a major +# version release of tljh. +# +jupyterhub>=4.0.0,<5 +jupyterhub-systemdspawner>=0.17.0,<5 +jupyterhub-firstuseauthenticator>=1.0.0,<2 +jupyterhub-nativeauthenticator>=1.1.0,<2 +jupyterhub-ldapauthenticator>=1.3.2,<2 +jupyterhub-tmpauthenticator>=0.6.0,<0.7 +oauthenticator>=15.1.0,<16 +jupyterhub-idle-culler>=1.2.1,<2 +git+https://github.com/yuvipanda/jupyterhub-configurator@317759e17c8e48de1b1352b836dac2a230536dba + +# pycurl is installed to improve reliability and performance for when JupyterHub +# makes web requests. JupyterHub will use tornado's CurlAsyncHTTPClient when +# making requests over tornado's SimpleHTTPClient automatically if pycurl is +# installed. +# +# ref: https://www.tornadoweb.org/en/stable/httpclient.html#module-tornado.simple_httpclient +# ref: https://github.com/jupyterhub/the-littlest-jupyterhub/issues/289 +# +pycurl>=7.45.2,<8 From 9c83e9000e6a70acbd1e62987944e00eedb7652a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 May 2023 23:45:27 +0200 Subject: [PATCH 134/232] user env: ensure pip>=23.1.2 on install --- tljh/installer.py | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index fc968a3..f94a63f 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -146,9 +146,10 @@ MAMBAFORGE_CHECKSUMS = { # minimum versions of packages MINIMUM_VERSIONS = { - # if conda/mamba are lower than this, upgrade them before installing the user packages + # if conda/mamba/pip are lower than this, upgrade them before installing the user packages "mamba": "0.16.0", "conda": "4.10", + "pip": "23.1.2", # minimum Python version (if not matched, abort to avoid big disruptive updates) "python": "3.9", } @@ -224,10 +225,14 @@ def ensure_user_environment(user_requirements_txt_file): logger.error(msg) raise ValueError(msg) - # at this point, we know we have an env ready with conda and are going to start installing - # first, check if we should upgrade/install conda and/or mamba + # Ensure minimum versions of the following packages by upgrading to the + # latest if below that version. + # + # - conda/mamba, via conda-forge + # - pip, via PyPI + # to_upgrade = [] - for pkg in ("conda", "mamba"): + for pkg in ("conda", "mamba", "pip"): version = package_versions.get(pkg) min_version = MINIMUM_VERSIONS[pkg] if not version: @@ -241,19 +246,21 @@ def ensure_user_environment(user_requirements_txt_file): ) to_upgrade.append(pkg) - if to_upgrade: - conda.ensure_conda_packages( - USER_ENV_PREFIX, - # we _could_ explicitly pin Python here, - # but conda already does this by default - to_upgrade, - ) - - conda.ensure_pip_requirements( - USER_ENV_PREFIX, - os.path.join(HERE, "requirements-user-env.txt"), - upgrade=True, - ) + cf_pkgs_to_upgrade = list(set(to_upgrade) & {"conda", "mamba"}) + if cf_pkgs_to_upgrade: + conda.ensure_conda_packages( + USER_ENV_PREFIX, + # we _could_ explicitly pin Python here, + # but conda already does this by default + cf_pkgs_to_upgrade, + ) + pypi_pkgs_to_upgrade = list(set(to_upgrade) & {"pip"}) + if pypi_pkgs_to_upgrade: + conda.ensure_pip_packages( + USER_ENV_PREFIX, + pypi_pkgs_to_upgrade, + upgrade=True, + ) if is_fresh_install: conda.ensure_pip_requirements( USER_ENV_PREFIX, From 6388d390e148366c98e0a43e93ceb37458394487 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 May 2023 23:47:04 +0200 Subject: [PATCH 135/232] refactor: improve readability of a section --- tljh/installer.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index f94a63f..6187d85 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -186,20 +186,16 @@ def ensure_user_environment(user_requirements_txt_file): # Check the existing environment for what to do package_versions = conda.get_conda_package_versions(USER_ENV_PREFIX) + is_fresh_install = not package_versions - # Case 1: no existing environment - if not package_versions: - is_fresh_install = True - - # 1a. no environment, but prefix exists. - # Abort to avoid clobbering something we don't recognize + if is_fresh_install: + # If no Python environment is detected but a folder exists we abort to + # avoid clobbering something we don't recognize. if os.path.exists(USER_ENV_PREFIX) and os.listdir(USER_ENV_PREFIX): msg = f"Found non-empty directory that is not a conda install in {USER_ENV_PREFIX}. Please remove it (or rename it to preserve files) and run tljh again." logger.error(msg) raise OSError(msg) - # 1b. No environment, directory empty or doesn't exist - # start fresh install logger.info("Downloading & setting up user environment...") installer_url, installer_sha256 = _mambaforge_url() with conda.download_miniconda_installer( @@ -207,13 +203,12 @@ def ensure_user_environment(user_requirements_txt_file): ) as installer_path: conda.install_miniconda(installer_path, USER_ENV_PREFIX) package_versions = conda.get_conda_package_versions(USER_ENV_PREFIX) + # quick sanity check: we should have conda and mamba! assert "conda" in package_versions assert "mamba" in package_versions - else: - is_fresh_install = False - # next, check Python + # Check Python version python_version = package_versions["python"] logger.debug(f"Found python={python_version} in {USER_ENV_PREFIX}") if V(python_version) < V(MINIMUM_VERSIONS["python"]): From c1aa30a479c6b03c70ae41c9848383560f1abbce Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 May 2023 23:49:39 +0200 Subject: [PATCH 136/232] hub env: remove pip pinning, always upgrade pip --- .github/workflows/unit-test.yaml | 3 --- bootstrap/bootstrap.py | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 4dd1e4e..96b22bd 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -81,10 +81,7 @@ jobs: ${{ hashFiles('setup.py', 'dev-requirements.txt', '.github/workflows/unit-test.yaml') }} - name: Install Python dependencies - # Keep pip version pinning in sync with the one in bootstrap.py! - # See changelog at https://pip.pypa.io/en/latest/news/#changelog run: | - python3 -m pip install -U "pip==23.1.*" python3 -m pip install -r dev-requirements.txt python3 -m pip install -e . pip freeze diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 9205f8f..46acf3f 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -475,11 +475,8 @@ def main(): os.makedirs(hub_env_prefix, exist_ok=True) run_subprocess(["python3", "-m", "venv", hub_env_prefix]) - # Upgrade pip - # Keep pip version pinning in sync with the one in unit-test.yml! - # See changelog at https://pip.pypa.io/en/latest/news/#changelog logger.info("Upgrading pip...") - run_subprocess([hub_env_pip, "install", "--upgrade", "pip==23.1.*"]) + run_subprocess([hub_env_pip, "install", "--upgrade", "pip"]) # Install/upgrade TLJH installer tljh_install_cmd = [hub_env_pip, "install", "--upgrade"] From 21312b2cfd1f890809409e1de5b91570221c8b3a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 May 2023 23:51:00 +0200 Subject: [PATCH 137/232] user env: upgrade jh based on hub spec --- tljh/installer.py | 11 ++++++++++- tljh/requirements-user-env.txt | 7 ------- 2 files changed, 10 insertions(+), 8 deletions(-) delete mode 100644 tljh/requirements-user-env.txt diff --git a/tljh/installer.py b/tljh/installer.py index 6187d85..668f30c 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -256,11 +256,20 @@ def ensure_user_environment(user_requirements_txt_file): pypi_pkgs_to_upgrade, upgrade=True, ) + + # Install/upgrade the jupyterhub version in the user env based on the + # version specification used for the hub env. + # + with open(os.path.join(HERE, "requirements-hub-env.txt")) as f: + jh_version_spec = [l for l in f if l.startswith("jupyterhub>=")][0] + conda.ensure_pip_packages(USER_ENV_PREFIX, [jh_version_spec], upgrade=True) + + # Install user environment extras for initial installations + # if is_fresh_install: conda.ensure_pip_requirements( USER_ENV_PREFIX, os.path.join(HERE, "requirements-user-env-extras.txt"), - upgrade=True, ) if user_requirements_txt_file: diff --git a/tljh/requirements-user-env.txt b/tljh/requirements-user-env.txt deleted file mode 100644 index a9528e9..0000000 --- a/tljh/requirements-user-env.txt +++ /dev/null @@ -1,7 +0,0 @@ -# When tljh.installer runs, the users' environment as typically found in -# /opt/tljh/user, is installed or upgraded with these packages. -# -# Whats listed here should be only the unconditional requirements that we have in -# the user environment. -# -jupyterhub==4.* From d4ef212b05dd945075433272c2eb926452699490 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 22 May 2023 12:37:51 +0200 Subject: [PATCH 138/232] Update nativeauthenticator from 1.1.0 to 1.2.0 to support jh4 --- tljh/requirements-hub-env.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index ba81767..9113418 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -11,7 +11,7 @@ jupyterhub>=4.0.0,<5 jupyterhub-systemdspawner>=0.17.0,<5 jupyterhub-firstuseauthenticator>=1.0.0,<2 -jupyterhub-nativeauthenticator>=1.1.0,<2 +jupyterhub-nativeauthenticator>=1.2.0,<2 jupyterhub-ldapauthenticator>=1.3.2,<2 jupyterhub-tmpauthenticator>=0.6.0,<0.7 oauthenticator>=15.1.0,<16 From 27a94f4645789e4b1170d102116cbcb9c8ab5256 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 22 May 2023 12:38:27 +0200 Subject: [PATCH 139/232] Update jupyterhub-configurator to latest --- tljh/requirements-hub-env.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index 9113418..628c3f5 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -16,7 +16,7 @@ jupyterhub-ldapauthenticator>=1.3.2,<2 jupyterhub-tmpauthenticator>=0.6.0,<0.7 oauthenticator>=15.1.0,<16 jupyterhub-idle-culler>=1.2.1,<2 -git+https://github.com/yuvipanda/jupyterhub-configurator@317759e17c8e48de1b1352b836dac2a230536dba +git+https://github.com/yuvipanda/jupyterhub-configurator@996405d2a7017153d5abe592b8028fed7a1801bb # pycurl is installed to improve reliability and performance for when JupyterHub # makes web requests. JupyterHub will use tornado's CurlAsyncHTTPClient when From 270ec00343f14e0458e3b212eb6ddf3958c5b633 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 22 May 2023 16:09:54 +0200 Subject: [PATCH 140/232] Update tmpauthenticator from 0.6 to 1.0.0 --- tljh/requirements-hub-env.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index 628c3f5..dfeaeec 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -13,7 +13,7 @@ jupyterhub-systemdspawner>=0.17.0,<5 jupyterhub-firstuseauthenticator>=1.0.0,<2 jupyterhub-nativeauthenticator>=1.2.0,<2 jupyterhub-ldapauthenticator>=1.3.2,<2 -jupyterhub-tmpauthenticator>=0.6.0,<0.7 +jupyterhub-tmpauthenticator>=1.0.0,<2 oauthenticator>=15.1.0,<16 jupyterhub-idle-culler>=1.2.1,<2 git+https://github.com/yuvipanda/jupyterhub-configurator@996405d2a7017153d5abe592b8028fed7a1801bb From d700d28b1f96e6667e8ed7dcc53d4a159e6c129b Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Mon, 22 May 2023 20:28:16 -0400 Subject: [PATCH 141/232] Update package and user included files to `.md` --- docs/install/{add_packages.txt => add-packages.md} | 2 +- docs/install/{add_users.txt => add-users.md} | 0 docs/install/amazon.md | 4 ++-- docs/install/azure.md | 4 ++-- docs/install/custom-server.md | 4 ++-- docs/install/digitalocean.md | 4 ++-- docs/install/google.md | 4 ++-- docs/install/jetstream.md | 4 ++-- docs/install/ovh.md | 4 ++-- 9 files changed, 15 insertions(+), 15 deletions(-) rename docs/install/{add_packages.txt => add-packages.md} (93%) rename docs/install/{add_users.txt => add-users.md} (100%) diff --git a/docs/install/add_packages.txt b/docs/install/add-packages.md similarity index 93% rename from docs/install/add_packages.txt rename to docs/install/add-packages.md index 10f8972..826f65b 100644 --- a/docs/install/add_packages.txt +++ b/docs/install/add-packages.md @@ -27,4 +27,4 @@ The packages `gdal` and `there` are now available to all users in JupyterHub. If a user already had a python notebook running, they have to restart their notebook's kernel to make the new libraries available. -See {ref}`howto-env-user-environment` for more information. +See [](#howto/user-env/user-environment) for more information. diff --git a/docs/install/add_users.txt b/docs/install/add-users.md similarity index 100% rename from docs/install/add_users.txt rename to docs/install/add-users.md diff --git a/docs/install/amazon.md b/docs/install/amazon.md index 227af91..dd042be 100644 --- a/docs/install/amazon.md +++ b/docs/install/amazon.md @@ -268,12 +268,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` diff --git a/docs/install/azure.md b/docs/install/azure.md index 90f9c98..a9f862f 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -215,12 +215,12 @@ We start by creating the Virtual Machine in which we can run TLJH (The Littlest ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` diff --git a/docs/install/custom-server.md b/docs/install/custom-server.md index 85760a9..8ef92d8 100644 --- a/docs/install/custom-server.md +++ b/docs/install/custom-server.md @@ -79,13 +79,13 @@ for custom server installations. ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` diff --git a/docs/install/digitalocean.md b/docs/install/digitalocean.md index b6c3411..2f367c6 100644 --- a/docs/install/digitalocean.md +++ b/docs/install/digitalocean.md @@ -112,12 +112,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` diff --git a/docs/install/google.md b/docs/install/google.md index 8fd73d6..e6d125e 100644 --- a/docs/install/google.md +++ b/docs/install/google.md @@ -208,12 +208,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` diff --git a/docs/install/jetstream.md b/docs/install/jetstream.md index 7205cf8..b068ca1 100644 --- a/docs/install/jetstream.md +++ b/docs/install/jetstream.md @@ -141,12 +141,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` diff --git a/docs/install/ovh.md b/docs/install/ovh.md index 2a562f6..eec287e 100644 --- a/docs/install/ovh.md +++ b/docs/install/ovh.md @@ -122,12 +122,12 @@ Let's create the server on which we can run JupyterHub. ## Step 2: Adding more users -```{include} add_users.txt +```{include} add-users.md ``` ## Step 3: Install conda / pip packages for all users -```{include} add_packages.txt +```{include} add-packages.md ``` From 230f16ba4a3b53c30976b641324360e6b4cad2a3 Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Mon, 22 May 2023 20:31:22 -0400 Subject: [PATCH 142/232] Fix broken link to admin page --- docs/howto/admin/admin-users.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/admin/admin-users.md b/docs/howto/admin/admin-users.md index 1102853..95bd5e6 100644 --- a/docs/howto/admin/admin-users.md +++ b/docs/howto/admin/admin-users.md @@ -17,7 +17,7 @@ so attackers can not easily gain control of the system. :::{important} You should make sure an admin user is present when you **install** TLJH the very first time. It is recommended that you also set a password -for the admin at this step. The [`--admin`] (/topic/customizing-installer/admin) +for the admin at this step. The [`--admin`](#howto-admin-admin-users) flag passed to the installer does this. If you had forgotten to do so, the easiest way to fix this is to run the installer again. ::: From 14af23ea2e1821b1ececdb19901f6a83a420754a Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Mon, 22 May 2023 20:32:27 -0400 Subject: [PATCH 143/232] Add link to resource resizing page --- docs/howto/admin/resource-estimation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/howto/admin/resource-estimation.md b/docs/howto/admin/resource-estimation.md index 9bc7113..6e7f263 100644 --- a/docs/howto/admin/resource-estimation.md +++ b/docs/howto/admin/resource-estimation.md @@ -72,6 +72,7 @@ $$ ## Resizing your server -Lots of cloud providers let your dynamically resize your server if you need it +Many cloud providers let your dynamically resize your server if you need it to be larger or smaller. Usually this requires a restart of the whole server - active users will be logged out, but otherwise usually nothing bad happens. +See [](#howto-admin-resize) for provider-specific instructions on resizing. From 5803e2ab3b63362645b0188399ae1b148526777b Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Mon, 22 May 2023 20:33:07 -0400 Subject: [PATCH 144/232] Re-add user env files --- docs/howto/index.md | 7 +- docs/howto/user-env/notebook-interfaces.md | 57 ++++++ docs/howto/user-env/server-resources.md | 8 + docs/howto/user-env/user-environment.md | 214 +++++++++++++++++++++ 4 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 docs/howto/user-env/notebook-interfaces.md create mode 100644 docs/howto/user-env/server-resources.md create mode 100644 docs/howto/user-env/user-environment.md diff --git a/docs/howto/index.md b/docs/howto/index.md index 7a73b8c..05c2a84 100644 --- a/docs/howto/index.md +++ b/docs/howto/index.md @@ -19,9 +19,9 @@ content/share-data :caption: The user environment :titlesonly: true -env/user-environment -env/notebook-interfaces -env/server-resources +user-env/user-environment +user-env/notebook-interfaces +user-env/server-resources ``` ## Authentication @@ -31,6 +31,7 @@ with your JupyterHub. For more information on Authentication, see [](/topic/authenticator-configuration) ```{toctree} +:caption: Authentication :titlesonly: true auth/dummy diff --git a/docs/howto/user-env/notebook-interfaces.md b/docs/howto/user-env/notebook-interfaces.md new file mode 100644 index 0000000..ef0645f --- /dev/null +++ b/docs/howto/user-env/notebook-interfaces.md @@ -0,0 +1,57 @@ +(howto/user-env/notebook-interfaces)= + +# Change default User Interface + +By default, logging into TLJH puts you in the classic Jupyter Notebook +interface we all know and love. However, there are at least two other +popular notebook interfaces you can use: + +1. [JupyterLab](http://jupyterlab.readthedocs.io/en/stable/) +2. [nteract](https://nteract.io/) + +Both these interfaces are also shipped with TLJH by default. You can try +them temporarily, or set them to be the default interface whenever you +login. + +## Trying an alternate interface temporarily + +When you log in & start your server, by default the URL in your browser +will be something like `/user//tree`. The `/tree` is what +tells the notebook server to give you the classic notebook interface. + +- **For the JupyterLab interface**: change `/tree` to `/lab`. +- **For the nteract interface**: change `/tree` to `/nteract` + +You can play around with them and see what fits your use cases best. + +## Changing the default user interface + +You can change the default interface users get when they log in by +modifying `config.yaml` as an admin user. + +1. To launch **JupyterLab** when users log in, run the following in an + admin console: + + ```bash + sudo tljh-config set user_environment.default_app jupyterlab + ``` + +2. Alternatively, to launch **nteract** when users log in, run the + following in the admin console: + + ```bash + sudo tljh-config set user_environment.default_app nteract + ``` + +3. Apply the changes by restarting JupyterHub. This should not disrupt + current users. + + ```bash + sudo tljh-config reload hub + ``` + + If this causes problems, check the [logs](#troubleshoot-logs-jupyterhub) for + clues on what went wrong. + +Users might have to restart their servers from control panel to get the +new interface. diff --git a/docs/howto/user-env/server-resources.md b/docs/howto/user-env/server-resources.md new file mode 100644 index 0000000..f31b064 --- /dev/null +++ b/docs/howto/user-env/server-resources.md @@ -0,0 +1,8 @@ +(howto/user-env/server-resources)= + +# Configure resources available to users + +To configure the resources that are available to your users (such as +RAM, CPU and Disk Space), see the section [](#tljh-set-user-limits). +For information on **resizing** the environment available to users *after* you\'ve created +your JupyterHub, see [](#howto-admin-resize). diff --git a/docs/howto/user-env/user-environment.md b/docs/howto/user-env/user-environment.md new file mode 100644 index 0000000..3347f3c --- /dev/null +++ b/docs/howto/user-env/user-environment.md @@ -0,0 +1,214 @@ +(howto/user-env/user-environment)= + +# Install conda, pip or apt packages + +`TLJH (The Littlest JupyterHub)`{.interpreted-text role="abbr"} starts +all users in the same [conda](https://conda.io/docs/) environment. +Packages / libraries installed in this environment are available to all +users on the JupyterHub. Users with [admin rights](#howto-admin-admin-users) +can install packages easily. + +(howto/user-env/user-environment-pip)= + +## Installing pip packages + +[pip](https://pypi.org/project/pip/) is the recommended tool for +installing packages in Python from the [Python Packaging Index +(PyPI)](https://pypi.org/). PyPI has almost 145,000 packages in it right +now, so a lot of what you need is going to be there! + +1. Log in as an admin user and open a Terminal in your Jupyter + Notebook. + + ![New Terminal button under New menu](../../images/notebook/new-terminal-button.png) + + If you already have a terminal open as an admin user, that should + work too! + +2. Install a package! + + ```bash + sudo -E pip install numpy + ``` + + This installs the `numpy` library from PyPI and makes it available + to all users. + + :::{note} + If you get an error message like `sudo: pip: command not found`, + make sure you are not missing the `-E` parameter after `sudo`. + ::: + +(howto/user-env/user-environment-conda)= + +## Installing conda packages + +Conda lets you install new languages (such as new versions of python, +node, R, etc) as well as packages in those languages. For lots of +scientific software, installing with conda is often simpler & easier +than installing with pip - especially if it links to C / Fortran code. + +We recommend installing packages from +[conda-forge](https://conda-forge.org/), a community maintained +repository of conda packages. + +1. Log in as an admin user and open a Terminal in your Jupyter + Notebook. + + ![New Terminal button under New menu](../../images/notebook/new-terminal-button.png) + + If you already have a terminal open as an admin user, that should + work too! + +2. Install a package! + + ```bash + sudo -E conda install -c conda-forge gdal + ``` + + This installs the `gdal` library from `conda-forge` and makes it + available to all users. `gdal` is much harder to install with pip. + + :::{note} + If you get an error message like `sudo: conda: command not found`, + make sure you are not missing the `-E` parameter after `sudo`. + ::: + +(howto/user-env/user-environment-apt)= + +## Installing apt packages + +[apt](https://help.ubuntu.com/lts/serverguide/apt.html.en) is the +official package manager for the [Ubuntu Linux +distribution](https://www.ubuntu.com/). You can install utilities (such +as `vim`, `sl`, `htop`, etc), servers (`postgres`, `mysql`, `nginx`, +etc) and a lot more languages than present in `conda` (`haskell`, +`prolog`, `INTERCAL`). Some third party software (such as +[RStudio](https://www.rstudio.com/products/rstudio/download/)) is +distributed as `.deb` files, which are the files `apt` uses to install +software. + +You can search for packages with [Ubuntu Package +search](https://packages.ubuntu.com/) - make sure to look in the version +of Ubuntu you are using! + +1. Log in as an admin user and open a Terminal in your Jupyter + Notebook. + + ![New Terminal button under New menu](../../images/notebook/new-terminal-button.png) + + If you already have a terminal open as an admin user, that should + work too! + +2. Update list of packages available. This makes sure you get the + latest version of the packages possible from the repositories. + + ```bash + sudo apt update + ``` + +3. Install the packages you want. + + ```bash + sudo apt install mysql-server git + ``` + + This installs (and starts) a [MySQL](https://www.mysql.com/) + database server and `git`. + +## User environment location + +The user environment is a conda environment set up in `/opt/tljh/user`, +with a `python3` kernel as the default. It is readable by all users, but +writeable only by users who have root access. This makes it possible for +JupyterHub admins (who have root access with `sudo`) to install software +in the user environment easily. + +## Accessing user environment outside JupyterHub + +We add `/opt/tljh/user/bin` to the `$PATH` environment variable for all +JupyterHub users, so everything installed in the user environment is +available to them automatically. If you are using `ssh` to access your +server instead, you can get access to the same environment with: + +```bash +export PATH=/opt/tljh/user/bin:${PATH} +``` + +Whenever you run any command now, the user environment will be searched +first before your system environment is. So if you run +`python3 `, it\'ll use the `python3` installed in the user +environment (`/opt/tljh/user/bin/python3`) rather than the `python3` +installed in your system environment (`/usr/bin/python3`). This is +usually what you want! + +To make this change \'stick\', you can add the line to the end of the +`.bashrc` file in your home directory. + +When using `sudo`, the `$PATH` environment variable is usually reset, for +security reasons. This leads to error messages like: + +```bash +sudo conda install -c conda-forge gdal +sudo: conda: command not found +``` + +The most common & portable way to fix this when using `ssh` is: + +```bash +sudo PATH=${PATH} conda install -c conda-forge gdal +``` + +## Upgrade to a newer Python version + +All new TLJH installs use miniconda 4.7.10, which comes with a Python +3.7 environment for the users. The previously TLJH installs came with +miniconda 4.5.4, which meant a Python 3.6 environment. + +To upgrade the Python version of the user environment, one can: + +- **Start fresh on a machine that doesn\'t have TLJH already + installed.** + + See the [](#install-installing) section about how to install TLJH. + +- **Upgrade Python manually.** + + Because upgrading Python for existing installs can break packages + already installed under the old Python, upgrading your current TLJH + installation, will NOT upgrade the Python version of the user + environment, but you may do so manually. + + **Steps:** + + 1. Activate the user environment, if using ssh. If the terminal was + started with JupyterHub, this step can be skipped: + + ```bash + source /opt/tljh/user/bin/activate + ``` + + 2. Get the list of currently installed pip packages (so you can + later install them under the new Python): + + ```bash + pip freeze > pip_pkgs.txt + ``` + + 3. Update all conda installed packages in the environment: + + ```bash + sudo PATH=${PATH} conda update --all + ``` + + 4. Update Python version: + + ```bash + sudo PATH=${PATH} conda install python=3.7 + ``` + + 5. Install the pip packages previously saved: + + ```bash + pip install -r pip_pkgs.txt + ``` From 31add381e401dec9cd684ca7fcf0401afa9fbb3f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 May 2023 00:52:05 +0000 Subject: [PATCH 145/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/howto/user-env/notebook-interfaces.md | 4 +- docs/howto/user-env/server-resources.md | 4 +- docs/howto/user-env/user-environment.md | 68 +++++++++++----------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/howto/user-env/notebook-interfaces.md b/docs/howto/user-env/notebook-interfaces.md index ef0645f..d83a05b 100644 --- a/docs/howto/user-env/notebook-interfaces.md +++ b/docs/howto/user-env/notebook-interfaces.md @@ -19,8 +19,8 @@ When you log in & start your server, by default the URL in your browser will be something like `/user//tree`. The `/tree` is what tells the notebook server to give you the classic notebook interface. -- **For the JupyterLab interface**: change `/tree` to `/lab`. -- **For the nteract interface**: change `/tree` to `/nteract` +- **For the JupyterLab interface**: change `/tree` to `/lab`. +- **For the nteract interface**: change `/tree` to `/nteract` You can play around with them and see what fits your use cases best. diff --git a/docs/howto/user-env/server-resources.md b/docs/howto/user-env/server-resources.md index f31b064..906d49c 100644 --- a/docs/howto/user-env/server-resources.md +++ b/docs/howto/user-env/server-resources.md @@ -3,6 +3,6 @@ # Configure resources available to users To configure the resources that are available to your users (such as -RAM, CPU and Disk Space), see the section [](#tljh-set-user-limits). -For information on **resizing** the environment available to users *after* you\'ve created +RAM, CPU and Disk Space), see the section [](#tljh-set-user-limits). +For information on **resizing** the environment available to users _after_ you\'ve created your JupyterHub, see [](#howto-admin-resize). diff --git a/docs/howto/user-env/user-environment.md b/docs/howto/user-env/user-environment.md index 3347f3c..74b7285 100644 --- a/docs/howto/user-env/user-environment.md +++ b/docs/howto/user-env/user-environment.md @@ -10,7 +10,7 @@ can install packages easily. (howto/user-env/user-environment-pip)= -## Installing pip packages +## Installing pip packages [pip](https://pypi.org/project/pip/) is the recommended tool for installing packages in Python from the [Python Packaging Index @@ -41,7 +41,7 @@ now, so a lot of what you need is going to be there! (howto/user-env/user-environment-conda)= -## Installing conda packages +## Installing conda packages Conda lets you install new languages (such as new versions of python, node, R, etc) as well as packages in those languages. For lots of @@ -76,7 +76,7 @@ repository of conda packages. (howto/user-env/user-environment-apt)= -## Installing apt packages +## Installing apt packages [apt](https://help.ubuntu.com/lts/serverguide/apt.html.en) is the official package manager for the [Ubuntu Linux @@ -167,48 +167,48 @@ miniconda 4.5.4, which meant a Python 3.6 environment. To upgrade the Python version of the user environment, one can: -- **Start fresh on a machine that doesn\'t have TLJH already - installed.** +- **Start fresh on a machine that doesn\'t have TLJH already + installed.** - See the [](#install-installing) section about how to install TLJH. + See the [](#install-installing) section about how to install TLJH. -- **Upgrade Python manually.** +- **Upgrade Python manually.** - Because upgrading Python for existing installs can break packages - already installed under the old Python, upgrading your current TLJH - installation, will NOT upgrade the Python version of the user - environment, but you may do so manually. + Because upgrading Python for existing installs can break packages + already installed under the old Python, upgrading your current TLJH + installation, will NOT upgrade the Python version of the user + environment, but you may do so manually. - **Steps:** + **Steps:** - 1. Activate the user environment, if using ssh. If the terminal was - started with JupyterHub, this step can be skipped: + 1. Activate the user environment, if using ssh. If the terminal was + started with JupyterHub, this step can be skipped: - ```bash - source /opt/tljh/user/bin/activate - ``` + ```bash + source /opt/tljh/user/bin/activate + ``` - 2. Get the list of currently installed pip packages (so you can - later install them under the new Python): + 2. Get the list of currently installed pip packages (so you can + later install them under the new Python): - ```bash - pip freeze > pip_pkgs.txt - ``` + ```bash + pip freeze > pip_pkgs.txt + ``` - 3. Update all conda installed packages in the environment: + 3. Update all conda installed packages in the environment: - ```bash - sudo PATH=${PATH} conda update --all - ``` + ```bash + sudo PATH=${PATH} conda update --all + ``` - 4. Update Python version: + 4. Update Python version: - ```bash - sudo PATH=${PATH} conda install python=3.7 - ``` + ```bash + sudo PATH=${PATH} conda install python=3.7 + ``` - 5. Install the pip packages previously saved: + 5. Install the pip packages previously saved: - ```bash - pip install -r pip_pkgs.txt - ``` + ```bash + pip install -r pip_pkgs.txt + ``` From 88ec6f4038576fd5673c446790ec5cb11b4c96af Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 24 May 2023 10:49:38 +0200 Subject: [PATCH 146/232] docs: setup redirects to ensure we don't get broken links --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 0c40f58..677ed3b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -107,4 +107,7 @@ ogp_use_first_image = True rediraffe_branch = "main" rediraffe_redirects = { # "old-file": "new-folder/new-file-name", + "howto/env/user-environment": "howto/user-env/user-environment", + "howto/env/notebook-interfaces": "howto/user-env/notebook-interfaces", + "howto/env/server-resources": "howto/user-env/server-resources", } From bbc6c465ac8a698f676aaf63a80e270bc5f85738 Mon Sep 17 00:00:00 2001 From: Nicolas Surleraux Date: Wed, 24 May 2023 11:42:27 +0200 Subject: [PATCH 147/232] Allow to listen on a specific address via TLJH config --- docs/topic/tljh-config.md | 13 +++++++++++++ tests/test_traefik.py | 11 +++++++++++ tljh/config.py | 7 ++++++- tljh/configurer.py | 2 ++ tljh/traefik.toml.tpl | 4 ++-- 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/topic/tljh-config.md b/docs/topic/tljh-config.md index 561e4b9..2df93ee 100644 --- a/docs/topic/tljh-config.md +++ b/docs/topic/tljh-config.md @@ -85,6 +85,19 @@ sudo tljh-config set https.port 8443 sudo tljh-config reload proxy ``` +(tljh-set-listen-address) + +### Listen address + +Use `http.address` and `https.address` to set the addresses that TLJH will listen on, +which is an empty address by default (it means it listens on all interfaces by default). + +```bash +sudo tljh-config set http.address 127.0.0.1 +sudo tljh-config set https.address 127.0.0.1 +sudo tljh-config reload proxy +``` + (tljh-set-user-lists)= ### User Lists diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 4098586..b75369d 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -240,3 +240,14 @@ def test_extra_config(tmpdir, tljh_dir): # Check that the defaults were updated by the extra config assert toml_cfg["log"]["level"] == "ERROR" assert toml_cfg["api"]["dashboard"] == True + + +def test_listen_address(tmpdir, tljh_dir): + state_dir = config.STATE_DIR + config.set_config_value(config.CONFIG_FILE, "http.address", "127.0.0.1") + config.set_config_value(config.CONFIG_FILE, "https.address", "127.0.0.1") + traefik.ensure_traefik_config(str(state_dir)) + + cfg = _read_static_config(state_dir) + assert cfg["entryPoints"]['http']['address'] == "127.0.0.1:80" + assert cfg["entryPoints"]['https']['address'] == "127.0.0.1:443" diff --git a/tljh/config.py b/tljh/config.py index 60d5cc6..0bb3921 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -244,10 +244,15 @@ def check_hub_ready(): base_url = load_config()["base_url"] base_url = base_url[:-1] if base_url[-1] == "/" else base_url + http_address = load_config()["http"]["address"] http_port = load_config()["http"]["port"] + # The default config is an empty address, so it binds on all interfaces. + # Test the connectivity on the local address. + if http_address == '': + http_address = '127.0.0.1' try: r = requests.get( - "http://127.0.0.1:%d%s/hub/api" % (http_port, base_url), verify=False + "http://%s:%d%s/hub/api" % (http_address, http_port, base_url), verify=False ) if r.status_code != 200: print(f"Hub not ready: (HTTP status {r.status_code})") diff --git a/tljh/configurer.py b/tljh/configurer.py index 8e49d75..1fb60f6 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -28,10 +28,12 @@ default = { "cpu": None, }, "http": { + "address": "", "port": 80, }, "https": { "enabled": False, + "address": "", "port": 443, "tls": { "cert": "", diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index fa5b6ef..5fc0034 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -22,7 +22,7 @@ X-Xsrftoken = "redact" [entryPoints] [entryPoints.http] - address = ":{{ http['port'] }}" + address = "{{ http['address'] }}:{{ http['port'] }}" [entryPoints.http.transport.respondingTimeouts] idleTimeout = "10m" @@ -33,7 +33,7 @@ X-Xsrftoken = "redact" scheme = "https" [entryPoints.https] - address = ":{{ https['port'] }}" + address = "{{ https['address'] }}:{{ https['port'] }}" [entryPoints.https.http.tls] options = "default" From bf360ec3322fe9f6de18bf99ae519833fe6f7531 Mon Sep 17 00:00:00 2001 From: Nicolas Surleraux Date: Wed, 24 May 2023 11:53:18 +0200 Subject: [PATCH 148/232] Enable https in test_listen_address so it renders --- tests/test_traefik.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index b75369d..69dc681 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -244,8 +244,11 @@ def test_extra_config(tmpdir, tljh_dir): def test_listen_address(tmpdir, tljh_dir): state_dir = config.STATE_DIR + config.set_config_value(config.CONFIG_FILE, "https.enabled", True) + config.set_config_value(config.CONFIG_FILE, "http.address", "127.0.0.1") config.set_config_value(config.CONFIG_FILE, "https.address", "127.0.0.1") + traefik.ensure_traefik_config(str(state_dir)) cfg = _read_static_config(state_dir) From d63732515275c9cfb64a70ea6b68e9b1a3bea33c Mon Sep 17 00:00:00 2001 From: Nicolas Surleraux Date: Thu, 25 May 2023 14:41:26 +0200 Subject: [PATCH 149/232] Add https requirements in tests --- tests/test_traefik.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 69dc681..cd7d251 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -245,10 +245,12 @@ def test_extra_config(tmpdir, tljh_dir): def test_listen_address(tmpdir, tljh_dir): state_dir = config.STATE_DIR config.set_config_value(config.CONFIG_FILE, "https.enabled", True) + config.set_config_value(config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key") + config.set_config_value(config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert") config.set_config_value(config.CONFIG_FILE, "http.address", "127.0.0.1") config.set_config_value(config.CONFIG_FILE, "https.address", "127.0.0.1") - + traefik.ensure_traefik_config(str(state_dir)) cfg = _read_static_config(state_dir) From 0f385af837ab56632bfb45622b5df5efac8ec6f2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 12:56:21 +0000 Subject: [PATCH 150/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_traefik.py | 4 ++-- tljh/config.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index cd7d251..f950266 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -254,5 +254,5 @@ def test_listen_address(tmpdir, tljh_dir): traefik.ensure_traefik_config(str(state_dir)) cfg = _read_static_config(state_dir) - assert cfg["entryPoints"]['http']['address'] == "127.0.0.1:80" - assert cfg["entryPoints"]['https']['address'] == "127.0.0.1:443" + assert cfg["entryPoints"]["http"]["address"] == "127.0.0.1:80" + assert cfg["entryPoints"]["https"]["address"] == "127.0.0.1:443" diff --git a/tljh/config.py b/tljh/config.py index 0bb3921..d308e9e 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -248,8 +248,8 @@ def check_hub_ready(): http_port = load_config()["http"]["port"] # The default config is an empty address, so it binds on all interfaces. # Test the connectivity on the local address. - if http_address == '': - http_address = '127.0.0.1' + if http_address == "": + http_address = "127.0.0.1" try: r = requests.get( "http://%s:%d%s/hub/api" % (http_address, http_port, base_url), verify=False From 9a1b600d996f40b42f4b15932e3d87488f67cb25 Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Sun, 28 May 2023 20:56:57 -0400 Subject: [PATCH 151/232] Add Google Cloud config page --- docs/howto/index.md | 1 + docs/howto/providers/google.md | 59 ++++++++++++++++++ .../google/boot-disk-edit-button.png | Bin 0 -> 18526 bytes .../google/boot-disk-resize-properties.png | Bin 0 -> 18492 bytes .../providers/google/boot-disk-resize.png | Bin 0 -> 15249 bytes 5 files changed, 60 insertions(+) create mode 100644 docs/howto/providers/google.md create mode 100644 docs/images/providers/google/boot-disk-edit-button.png create mode 100644 docs/images/providers/google/boot-disk-resize-properties.png create mode 100644 docs/images/providers/google/boot-disk-resize.png diff --git a/docs/howto/index.md b/docs/howto/index.md index 05c2a84..208f14e 100644 --- a/docs/howto/index.md +++ b/docs/howto/index.md @@ -65,4 +65,5 @@ admin/systemd providers/digitalocean providers/azure +providers/google ``` diff --git a/docs/howto/providers/google.md b/docs/howto/providers/google.md new file mode 100644 index 0000000..84bd825 --- /dev/null +++ b/docs/howto/providers/google.md @@ -0,0 +1,59 @@ +(howto-providers-google)= + +# Perform common Google Cloud configuration tasks + +This page lists various common tasks you can perform on your +Google Cloud virtual machine. + +(howto-providers-google-resize-disk)= + +## Increasing your boot disk size +Google Cloud Compute Engine supports *increasing* (but not *decreasing*) the size of existing disks. +If you selected a boot disk with a supported version of **Ubuntu** or **Debian** as the operating +system, then your boot disk can be resized easily from the console with these steps. + +:::{note} +Google Cloud resizes the root partition and file system for *boot* disks with *public* images +(such as the TLJH supported **Ubuntu** and **Debian** images) automatically after your increase +the size of your disk. If you have any other *non-boot* disks attached to your instance, you +will need to perform extra steps yourself after resizing your disk. For more information on +this and other aspects of resizing persistent disks, see +[Google's documentation](https://cloud.google.com/compute/docs/disks/resize-persistent-disk). +::: + + +1. Go to [Google Cloud Console -> Compute Engine -> VM instances](https://console.cloud.google.com/compute/instances) and select your TLJH instance. + + +1. Scroll down until you find your boot disk and select it. + ```{image} ../../images/providers/google/boot-disk-resize.png + :alt: Boot disk with Ubuntu jammy image + ``` + + +1. Select **Edit** in the top menu. This may require selecting the kebab menu (the 3 vertical dots). + ```{image} ../../images/providers/google/boot-disk-edit-button.png + :alt: Disk edit button + ``` + + +1. Update the **Size** property and save the changes at the bottom of the page. + ```{image} ../../images/providers/google/boot-disk-resize-properties.png + :alt: Boot disk size property + ``` + + +1. Reboot the VM instance by logging into your TLJH, opening the terminal, and running `sudo reboot`. + You will lose your connection to the instance while it restarts. Once it comes back up, your disk + will reflect your changes. You can verify that the automatic resize of your root partition and + file system took place by running `df -h` in the terminal, which will show the size of the disk + mounted on `/`: + ```bash + $ df -h + Filesystem Size Used Avail Use% Mounted on + /dev/root 25G 6.9G 18G 28% / + tmpfs 2.0G 0 2.0G 0% /dev/shm + tmpfs 785M 956K 784M 1% /run + tmpfs 5.0M 0 5.0M 0% /run/lock + /dev/sda15 105M 6.1M 99M 6% /boot/efi + ``` diff --git a/docs/images/providers/google/boot-disk-edit-button.png b/docs/images/providers/google/boot-disk-edit-button.png new file mode 100644 index 0000000000000000000000000000000000000000..65319c5ef017178eb9b1ce4e7c02fb6eb80d69ef GIT binary patch literal 18526 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 literal 0 HcmV?d00001 From b5bb25b559d8b0c94cb7a01932786f17d81fa764 Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Sun, 28 May 2023 20:58:33 -0400 Subject: [PATCH 152/232] Add instance logs and disk space to troubleshooting page --- .../providers/google/serial-port-console.png | Bin 0 -> 33329 bytes docs/troubleshooting/providers/google.md | 29 ++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 docs/images/providers/google/serial-port-console.png diff --git a/docs/images/providers/google/serial-port-console.png b/docs/images/providers/google/serial-port-console.png new file mode 100644 index 0000000000000000000000000000000000000000..61384dc748cf1d3ccc35fa57454e838e51e20d87 GIT binary patch literal 33329 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 Compute Engine -> VM instances](https://console.cloud.google.com/compute/instances). +Once you select your TLJH instance, select **Serial port 1 (console)**: +```{image} ../../images/providers/google/serial-port-console.png +:alt: Serial port 1 (console) under Logs heading +``` + +:::{tip} +The console will show the logs of any startup scripts you configured for your instance, +making it easy to see if it has completed and/or encountered any errors. +::: + ## 'Connection Refused' error after restarting server If you restarted your server from the Google Cloud console & then try to access @@ -15,3 +31,16 @@ IP address, you might have to change it to point to the new correct IP. You can prevent External IP changes by [reserving the static IP](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip) your server is using. + +## Issues caused by lack of disk space + +If your boot disk becomes full, this can cause your instance to become unavailable, +among other problems. If your instance appears up and running in the console but +you cannot access it at your configured external IP/domain name, this could be caused +by a lack of disk space. + +You can explore your [VM logs in the console](#viewing-vm-instance-logs) to determine +if any issues you are experiencing indicate disk space issues. + +To resolve these types of issues, you can +[increase your boot disk size](#howto-providers-google-resize-disk). From 232d595d4957e5ba2185281839e8bd09eb605f24 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 May 2023 01:09:07 +0000 Subject: [PATCH 153/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/howto/providers/google.md | 29 ++++++++++++------------ docs/troubleshooting/providers/google.md | 13 ++++++----- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/howto/providers/google.md b/docs/howto/providers/google.md index 84bd825..2aed63d 100644 --- a/docs/howto/providers/google.md +++ b/docs/howto/providers/google.md @@ -8,45 +8,44 @@ Google Cloud virtual machine. (howto-providers-google-resize-disk)= ## Increasing your boot disk size -Google Cloud Compute Engine supports *increasing* (but not *decreasing*) the size of existing disks. -If you selected a boot disk with a supported version of **Ubuntu** or **Debian** as the operating + +Google Cloud Compute Engine supports _increasing_ (but not _decreasing_) the size of existing disks. +If you selected a boot disk with a supported version of **Ubuntu** or **Debian** as the operating system, then your boot disk can be resized easily from the console with these steps. :::{note} -Google Cloud resizes the root partition and file system for *boot* disks with *public* images -(such as the TLJH supported **Ubuntu** and **Debian** images) automatically after your increase -the size of your disk. If you have any other *non-boot* disks attached to your instance, you -will need to perform extra steps yourself after resizing your disk. For more information on -this and other aspects of resizing persistent disks, see -[Google's documentation](https://cloud.google.com/compute/docs/disks/resize-persistent-disk). +Google Cloud resizes the root partition and file system for _boot_ disks with _public_ images +(such as the TLJH supported **Ubuntu** and **Debian** images) automatically after your increase +the size of your disk. If you have any other _non-boot_ disks attached to your instance, you +will need to perform extra steps yourself after resizing your disk. For more information on +this and other aspects of resizing persistent disks, see +[Google's documentation](https://cloud.google.com/compute/docs/disks/resize-persistent-disk). ::: - 1. Go to [Google Cloud Console -> Compute Engine -> VM instances](https://console.cloud.google.com/compute/instances) and select your TLJH instance. - 1. Scroll down until you find your boot disk and select it. + ```{image} ../../images/providers/google/boot-disk-resize.png :alt: Boot disk with Ubuntu jammy image ``` - 1. Select **Edit** in the top menu. This may require selecting the kebab menu (the 3 vertical dots). + ```{image} ../../images/providers/google/boot-disk-edit-button.png :alt: Disk edit button ``` - 1. Update the **Size** property and save the changes at the bottom of the page. + ```{image} ../../images/providers/google/boot-disk-resize-properties.png :alt: Boot disk size property ``` - 1. Reboot the VM instance by logging into your TLJH, opening the terminal, and running `sudo reboot`. You will lose your connection to the instance while it restarts. Once it comes back up, your disk - will reflect your changes. You can verify that the automatic resize of your root partition and - file system took place by running `df -h` in the terminal, which will show the size of the disk + will reflect your changes. You can verify that the automatic resize of your root partition and + file system took place by running `df -h` in the terminal, which will show the size of the disk mounted on `/`: ```bash $ df -h diff --git a/docs/troubleshooting/providers/google.md b/docs/troubleshooting/providers/google.md index ee27050..74c4417 100644 --- a/docs/troubleshooting/providers/google.md +++ b/docs/troubleshooting/providers/google.md @@ -5,11 +5,12 @@ TLJH on Google Cloud, and how they have fixed them! ## Viewing VM instance logs -In addition to [installer, JupyterHub, traefik, and other logs](#troubleshooting-logs) +In addition to [installer, JupyterHub, traefik, and other logs](#troubleshooting-logs) you can view VM instance logs on Google Cloud to help diagnose issues. These logs will contain detailed information and error stack traces and can be viewed from -[Google Cloud Console -> Compute Engine -> VM instances](https://console.cloud.google.com/compute/instances). +[Google Cloud Console -> Compute Engine -> VM instances](https://console.cloud.google.com/compute/instances). Once you select your TLJH instance, select **Serial port 1 (console)**: + ```{image} ../../images/providers/google/serial-port-console.png :alt: Serial port 1 (console) under Logs heading ``` @@ -37,10 +38,10 @@ your server is using. If your boot disk becomes full, this can cause your instance to become unavailable, among other problems. If your instance appears up and running in the console but you cannot access it at your configured external IP/domain name, this could be caused -by a lack of disk space. +by a lack of disk space. -You can explore your [VM logs in the console](#viewing-vm-instance-logs) to determine -if any issues you are experiencing indicate disk space issues. +You can explore your [VM logs in the console](#viewing-vm-instance-logs) to determine +if any issues you are experiencing indicate disk space issues. -To resolve these types of issues, you can +To resolve these types of issues, you can [increase your boot disk size](#howto-providers-google-resize-disk). From ebb3d9bef1eaa6a3eff687957e2bb5c71736d136 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 2 Jun 2023 16:57:15 +0200 Subject: [PATCH 154/232] Stop bundling jupyterhub-configurator which has been disabled by default --- docs/topic/index.md | 1 - docs/topic/jupyterhub-configurator.md | 21 --------------------- tljh/configurer.py | 23 ----------------------- tljh/jupyterhub_configurator_config.py | 1 - tljh/requirements-hub-env.txt | 1 - tljh/user_creating_spawner.py | 18 ++---------------- 6 files changed, 2 insertions(+), 63 deletions(-) delete mode 100644 docs/topic/jupyterhub-configurator.md delete mode 100644 tljh/jupyterhub_configurator_config.py diff --git a/docs/topic/index.md b/docs/topic/index.md index 9b9e8e8..7ec4b88 100644 --- a/docs/topic/index.md +++ b/docs/topic/index.md @@ -15,5 +15,4 @@ tljh-config authenticator-configuration escape-hatch idle-culler -jupyterhub-configurator ``` diff --git a/docs/topic/jupyterhub-configurator.md b/docs/topic/jupyterhub-configurator.md deleted file mode 100644 index 9adf524..0000000 --- a/docs/topic/jupyterhub-configurator.md +++ /dev/null @@ -1,21 +0,0 @@ -(topic-jupyterhub-configurator)= - -# JupyterHub Configurator - -The [JupyterHub configurator](https://github.com/yuvipanda/jupyterhub-configurator) allows admins to change a subset of hub settings via a GUI. - -## Enabling the configurator - -Because the configurator is under continue development and it might change over time, it is disabled by default in TLJH. -If you want to experiment with it, it can be enabled using `tljh-config`: - -```bash -sudo tljh-config set services.configurator.enabled True -sudo tljh-config reload -``` - -## Accessing the Configurator - -After enabling the configurator using `tljh-config`, the service will only be available to hub admins, from within the control panel. -The configurator can be accessed from under `Services` in the top navigation bar. It will ask to authenticate, so it knows the user is an admin. -Once done, the configurator interface will be available. diff --git a/tljh/configurer.py b/tljh/configurer.py index 1fb60f6..bf15354 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -64,7 +64,6 @@ default = { "max_age": 0, "remove_named_servers": False, }, - "configurator": {"enabled": False}, }, } @@ -277,33 +276,11 @@ def set_cull_idle_service(config): return cull_service -def set_configurator(config): - """ - Set the JupyterHub Configurator service - """ - HERE = os.path.abspath(os.path.dirname(__file__)) - configurator_cmd = [ - sys.executable, - "-m", - "jupyterhub_configurator.app", - f"--Configurator.config_file={HERE}/jupyterhub_configurator_config.py", - ] - configurator_service = { - "name": "configurator", - "url": "http://127.0.0.1:10101", - "command": configurator_cmd, - } - - return configurator_service - - def update_services(c, config): c.JupyterHub.services = [] if config["services"]["cull"]["enabled"]: c.JupyterHub.services.append(set_cull_idle_service(config)) - if config["services"]["configurator"]["enabled"]: - c.JupyterHub.services.append(set_configurator(config)) def _merge_dictionaries(a, b, path=None, update=True): diff --git a/tljh/jupyterhub_configurator_config.py b/tljh/jupyterhub_configurator_config.py deleted file mode 100644 index a6aace4..0000000 --- a/tljh/jupyterhub_configurator_config.py +++ /dev/null @@ -1 +0,0 @@ -c.Configurator.selected_fields = ["tljh.default_interface"] diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index dfeaeec..4a4de25 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -16,7 +16,6 @@ jupyterhub-ldapauthenticator>=1.3.2,<2 jupyterhub-tmpauthenticator>=1.0.0,<2 oauthenticator>=15.1.0,<16 jupyterhub-idle-culler>=1.2.1,<2 -git+https://github.com/yuvipanda/jupyterhub-configurator@996405d2a7017153d5abe592b8028fed7a1801bb # pycurl is installed to improve reliability and performance for when JupyterHub # makes web requests. JupyterHub will use tornado's CurlAsyncHTTPClient when diff --git a/tljh/user_creating_spawner.py b/tljh/user_creating_spawner.py index aa455e3..a08f24c 100644 --- a/tljh/user_creating_spawner.py +++ b/tljh/user_creating_spawner.py @@ -1,12 +1,11 @@ -from jupyterhub_configurator.mixins import ConfiguratorSpawnerMixin from systemdspawner import SystemdSpawner from traitlets import Dict, List, Unicode -from tljh import configurer, user +from tljh import user from tljh.normalize import generate_system_username -class CustomSpawner(SystemdSpawner): +class UserCreatingSpawner(SystemdSpawner): """ SystemdSpawner with user creation on spawn. @@ -35,16 +34,3 @@ class CustomSpawner(SystemdSpawner): if self.user.name in users: user.ensure_user_group(system_username, group) return super().start() - - -cfg = configurer.load_config() -# Use the jupyterhub-configurator mixin only if configurator is enabled -# otherwise, any bugs in the configurator backend will stop new user spawns! -if cfg["services"]["configurator"]["enabled"]: - # Dynamically create the Spawner class using `type`(https://docs.python.org/3/library/functions.html?#type), - # based on whether or not it should inherit from ConfiguratorSpawnerMixin - UserCreatingSpawner = type( - "UserCreatingSpawner", (ConfiguratorSpawnerMixin, CustomSpawner), {} - ) -else: - UserCreatingSpawner = type("UserCreatingSpawner", (CustomSpawner,), {}) From 6a7cbc8681630b75f8234ad64b924c032cc550ce Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:53:12 -0400 Subject: [PATCH 155/232] Add intro description of boot disk --- docs/howto/providers/google.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/howto/providers/google.md b/docs/howto/providers/google.md index 2aed63d..0d81e95 100644 --- a/docs/howto/providers/google.md +++ b/docs/howto/providers/google.md @@ -9,6 +9,11 @@ Google Cloud virtual machine. ## Increasing your boot disk size +Boot disks contain the operating system and boot loader for your TLJH instance. If you followed +the [Google Cloud TLJH installation instructions](#install-google) then you created a virtual machine +with one disk: a boot disk that will _also_ be used to hold user data in your hub. For various reasons +you may need to change your boot disk size. + Google Cloud Compute Engine supports _increasing_ (but not _decreasing_) the size of existing disks. If you selected a boot disk with a supported version of **Ubuntu** or **Debian** as the operating system, then your boot disk can be resized easily from the console with these steps. From 78bc5bcedddf5555397c283cba0659897449c41c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 5 Jun 2023 23:12:20 +0200 Subject: [PATCH 156/232] Fix recently introduced failure to upper bound systemdspawner --- tljh/requirements-hub-env.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index dfeaeec..b4e2569 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -9,7 +9,7 @@ # version release of tljh. # jupyterhub>=4.0.0,<5 -jupyterhub-systemdspawner>=0.17.0,<5 +jupyterhub-systemdspawner>=0.17.0,<1 jupyterhub-firstuseauthenticator>=1.0.0,<2 jupyterhub-nativeauthenticator>=1.2.0,<2 jupyterhub-ldapauthenticator>=1.3.2,<2 From 08a787fd8245fafef0ca7562c14c5e85084c6350 Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Tue, 6 Jun 2023 09:40:32 -0400 Subject: [PATCH 157/232] Quote `pwd` to prevent error if dir has spaces --- docs/contributing/dev-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing/dev-setup.md b/docs/contributing/dev-setup.md index 147c9dd..fc2d9ab 100644 --- a/docs/contributing/dev-setup.md +++ b/docs/contributing/dev-setup.md @@ -24,7 +24,7 @@ The easiest & safest way to develop & test TLJH is with [Docker](https://www.doc --detach \ --name=tljh-dev \ --publish 12000:80 \ - --mount type=bind,source=$(pwd),target=/srv/src \ + --mount type=bind,source="$(pwd)",target=/srv/src \ tljh-systemd ``` From a373b2108cd78a10eab116cbd4b099098a7e9fe6 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 1 Jun 2023 23:35:50 +0200 Subject: [PATCH 158/232] Update systemdspawner from v0.17 to v1.0.1+ --- tljh/requirements-hub-env.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index 81d694a..9b6fada 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -8,8 +8,8 @@ # If a dependency is bumped to a new major version, we should make a major # version release of tljh. # -jupyterhub>=4.0.0,<5 -jupyterhub-systemdspawner>=0.17.0,<1 +jupyterhub>=4.0.1,<5 +jupyterhub-systemdspawner>=1.0.1,<2 jupyterhub-firstuseauthenticator>=1.0.0,<2 jupyterhub-nativeauthenticator>=1.2.0,<2 jupyterhub-ldapauthenticator>=1.3.2,<2 From c5eae3386a7fa91d2e7a55cd123473cf30f2eee6 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 1 Jun 2023 23:43:38 +0200 Subject: [PATCH 159/232] SystemdSpawner 1: don't prevent admins from sudo / privilege escalation Having upgraded systemdspawner to 1.0.0, its configuration option `disable_user_sudo` now defaults to True. This would be a breaking unwanted change for our jupyterhub admin users who are configured with passwordless sudo. Its unlikeley a breaking change for other users, but could be if they are granted sudo rights without being a jupyterhub admin. But, if they are, then they could grant themself such rights anyhow so its reasonable to assume jupyterhub admins only should have sudo rights in a TLJH installation. --- tljh/user_creating_spawner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tljh/user_creating_spawner.py b/tljh/user_creating_spawner.py index a08f24c..eda9642 100644 --- a/tljh/user_creating_spawner.py +++ b/tljh/user_creating_spawner.py @@ -26,8 +26,10 @@ class UserCreatingSpawner(SystemdSpawner): user.ensure_user(system_username) user.ensure_user_group(system_username, "jupyterhub-users") if self.user.admin: + self.disable_user_sudo = False user.ensure_user_group(system_username, "jupyterhub-admins") else: + self.disable_user_sudo = True user.remove_user_group(system_username, "jupyterhub-admins") if self.user_groups: for group, users in self.user_groups.items(): From d4c6da52e1a73ce3cb44b68f90e9538250d9ab80 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:09:03 +0200 Subject: [PATCH 160/232] maint: let installer ensure /etc/sudoers.d directory exists --- integration-tests/Dockerfile | 2 -- tljh/installer.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile index 22cd2d4..c1c73d8 100644 --- a/integration-tests/Dockerfile +++ b/integration-tests/Dockerfile @@ -24,8 +24,6 @@ RUN find /etc/systemd/system \ -not -name '*systemd-user-sessions*' \ -exec rm \{} \; -RUN mkdir -p /etc/sudoers.d - RUN systemctl set-default multi-user.target STOPSIGNAL SIGRTMIN+3 diff --git a/tljh/installer.py b/tljh/installer.py index 668f30c..33cce12 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -126,6 +126,7 @@ def ensure_usergroups(): user.ensure_group("jupyterhub-users") logger.info("Granting passwordless sudo to JupyterHub admins...") + os.makedirs("/etc/sudoers.d/", exist_ok=True) with open("/etc/sudoers.d/jupyterhub-admins", "w") as f: # JupyterHub admins should have full passwordless sudo access f.write("%jupyterhub-admins ALL = (ALL) NOPASSWD: ALL\n") From ab947333d27dae748814d5ca2f0f19aec6735439 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:21:09 +0200 Subject: [PATCH 161/232] bootstrap.py: fix docstring summarizing --help output --- bootstrap/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 46acf3f..bea5bf5 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -36,7 +36,7 @@ Command line flags, from "bootstrap.py --help": logs can be accessed during installation. If this is passed, it will pass --progress-page-server-pid= to the tljh installer for later termination. - --version TLJH version or Git reference. Default 'latest' is + --version VERSION TLJH version or Git reference. Default 'latest' is the most recent release. Partial versions can be specified, for example '1', '1.0' or '1.0.0'. You can also pass a branch name such as 'main' or a From ecf11f0f2705e8793b46bafe689ff7f07dc67df0 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 22:48:06 +0200 Subject: [PATCH 162/232] bootstrap.py: let --version flag take precedence over env vars --- bootstrap/bootstrap.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index bea5bf5..aec78b9 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -364,7 +364,7 @@ def main(): ) parser.add_argument( "--version", - default="latest", + default="", help=( "TLJH version or Git reference. " "Default 'latest' is the most recent release. " @@ -478,21 +478,26 @@ def main(): logger.info("Upgrading pip...") run_subprocess([hub_env_pip, "install", "--upgrade", "pip"]) - # Install/upgrade TLJH installer + # pip install TLJH installer based on + # + # 1. --version, _resolve_git_version is used + # 2. TLJH_BOOTSTRAP_PIP_SPEC (then also respect TLJH_BOOTSTRAP_DEV) + # 3. latest, _resolve_git_version is used + # tljh_install_cmd = [hub_env_pip, "install", "--upgrade"] - if os.environ.get("TLJH_BOOTSTRAP_DEV", "no") == "yes": - logger.info("Selected TLJH_BOOTSTRAP_DEV=yes...") - tljh_install_cmd.append("--editable") - bootstrap_pip_spec = os.environ.get("TLJH_BOOTSTRAP_PIP_SPEC") - if not bootstrap_pip_spec: + if args.version or not bootstrap_pip_spec: + version_to_resolve = args.version or "latest" bootstrap_pip_spec = ( "git+https://github.com/jupyterhub/the-littlest-jupyterhub.git@{}".format( - _resolve_git_version(args.version) + _resolve_git_version(version_to_resolve) ) ) - + elif os.environ.get("TLJH_BOOTSTRAP_DEV", "no") == "yes": + logger.info("Selected TLJH_BOOTSTRAP_DEV=yes...") + tljh_install_cmd.append("--editable") tljh_install_cmd.append(bootstrap_pip_spec) + if initial_setup: logger.info("Installing TLJH installer...") else: From 9e9596188674a13b049160e342a118f5ab5feed2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 23:29:53 +0200 Subject: [PATCH 163/232] bootstrap.py: extract get_os_release_variable to make testing easier --- bootstrap/bootstrap.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index aec78b9..ac52039 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -183,32 +183,32 @@ def run_subprocess(cmd, *args, **kwargs): return output +def get_os_release_variable(key): + """ + Return value for key from /etc/os-release + + /etc/os-release is a bash file, so should use bash to parse it. + + Returns empty string if key is not found. + """ + return ( + subprocess.check_output( + [ + "/bin/bash", + "-c", + "source /etc/os-release && echo ${{{key}}}".format(key=key), + ] + ) + .decode() + .strip() + ) + + def ensure_host_system_can_install_tljh(): """ Check if TLJH is installable in current host system and exit with a clear error message otherwise. """ - - def get_os_release_variable(key): - """ - Return value for key from /etc/os-release - - /etc/os-release is a bash file, so should use bash to parse it. - - Returns empty string if key is not found. - """ - return ( - subprocess.check_output( - [ - "/bin/bash", - "-c", - "source /etc/os-release && echo ${{{key}}}".format(key=key), - ] - ) - .decode() - .strip() - ) - # Require Ubuntu 20.04+ or Debian 11+ distro = get_os_release_variable("ID") version = get_os_release_variable("VERSION_ID") From 06edf1a76bbca5a599baadda614e91c9b94d5e26 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:07:45 +0200 Subject: [PATCH 164/232] test refactor: put pytest config in pyproject.toml --- integration-tests/requirements.txt | 1 + pyproject.toml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/integration-tests/requirements.txt b/integration-tests/requirements.txt index a6f3d17..c086c7f 100644 --- a/integration-tests/requirements.txt +++ b/integration-tests/requirements.txt @@ -1,3 +1,4 @@ pytest +pytest-cov pytest-asyncio git+https://github.com/yuvipanda/hubtraf.git diff --git a/pyproject.toml b/pyproject.toml index 170be20..d0e97f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,24 @@ target_version = [ "py310", "py311", ] + + +# pytest is used for running Python based tests +# +# ref: https://docs.pytest.org/en/stable/ +# +[tool.pytest.ini_options] +addopts = "--verbose --color=yes --durations=10 --maxfail=1 --cov=tljh" +asyncio_mode = "auto" + + +# pytest-cov / coverage is used to measure code coverage of tests +# +# ref: https://coverage.readthedocs.io/en/stable/config.html +# +[tool.coverage.run] +parallel = true +omit = [ + "tests/**", + "integration-tests/**", +] From 2182f246ae7b802de1382e29c5c1944458bb70bf Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:02:37 +0200 Subject: [PATCH 165/232] test refactor: remove redundant pytest.mark.asyncio --- integration-tests/test_admin_installer.py | 2 -- integration-tests/test_hub.py | 8 -------- 2 files changed, 10 deletions(-) diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index c968978..083cf17 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -5,7 +5,6 @@ from hubtraf.auth.dummy import login_dummy from hubtraf.user import User -@pytest.mark.asyncio async def test_admin_login(): """ Test if the admin that was added during install can login with @@ -21,7 +20,6 @@ async def test_admin_login(): await u.ensure_server_simulate() -@pytest.mark.asyncio @pytest.mark.parametrize( "username, password", [ diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index faba9ea..7ea9ade 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -36,7 +36,6 @@ def test_hub_version(): assert V("4") <= V(info["version"]) <= V("5") -@pytest.mark.asyncio async def test_user_code_execute(): """ User logs in, starts a server & executes code @@ -68,7 +67,6 @@ async def test_user_code_execute(): assert pwd.getpwnam(f"jupyter-{username}") is not None -@pytest.mark.asyncio async def test_user_server_started_with_custom_base_url(): """ User logs in, starts a server with a custom base_url & executes code @@ -123,7 +121,6 @@ async def test_user_server_started_with_custom_base_url(): ) -@pytest.mark.asyncio async def test_user_admin_add(): """ User is made an admin, logs in and we check if they are in admin group @@ -168,7 +165,6 @@ async def test_user_admin_add(): # FIXME: Make this test pass -@pytest.mark.asyncio @pytest.mark.xfail(reason="Unclear why this is failing") async def test_user_admin_remove(): """ @@ -236,7 +232,6 @@ async def test_user_admin_remove(): assert f"jupyter-{username}" not in grp.getgrnam("jupyterhub-admins").gr_mem -@pytest.mark.asyncio async def test_long_username(): """ User with a long name logs in, and we check if their name is properly truncated. @@ -277,7 +272,6 @@ async def test_long_username(): raise -@pytest.mark.asyncio async def test_user_group_adding(): """ User logs in, and we check if they are added to the specified group. @@ -337,7 +331,6 @@ async def test_user_group_adding(): raise -@pytest.mark.asyncio async def test_idle_server_culled(): """ User logs in, starts a server & stays idle for 1 min. @@ -460,7 +453,6 @@ async def test_idle_server_culled(): ) -@pytest.mark.asyncio async def test_active_server_not_culled(): """ User logs in, starts a server & stays idle for 30s From e579d288c76e596d72a94980a1fe5cbbe7bbb898 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:03:12 +0200 Subject: [PATCH 166/232] test refactor: remove re-specification of hub_url --- integration-tests/test_hub.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 7ea9ade..bf56cc3 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -127,7 +127,6 @@ async def test_user_admin_add(): """ # 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 ( @@ -174,7 +173,6 @@ async def test_user_admin_remove(): """ # 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 ( @@ -236,9 +234,6 @@ async def test_long_username(): """ User with a long name logs in, and we check if their name is properly truncated. """ - # 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(32) assert ( @@ -278,7 +273,6 @@ async def test_user_group_adding(): """ # 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 @@ -336,9 +330,6 @@ 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 ( @@ -460,7 +451,6 @@ async def test_active_server_not_culled(): """ # 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 ( From 73e8eb2252bba31eb71e632966a208f18a99896e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:22:25 +0200 Subject: [PATCH 167/232] test refactor: slim down workflow for readability --- .github/integration-test.py | 5 +---- .github/workflows/integration-test.yaml | 10 +++------- .github/workflows/unit-test.yaml | 12 ++++++------ 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index b3fa090..b6fd9e9 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -187,10 +187,7 @@ def run_test( run_container_command( test_name, - # We abort pytest after two failures as a compromise between wanting to - # avoid a flood of logs while still understanding if multiple tests - # would fail. - "/opt/tljh/hub/bin/python3 -m pytest --verbose --maxfail=2 --color=yes --durations=10 --capture=no {}".format( + "/opt/tljh/hub/bin/python3 -m pytest --capture=no {}".format( " ".join( [os.path.join("/srv/src/integration-tests/", f) for f in test_files] ) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 1b7939e..e445347 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -59,16 +59,12 @@ jobs: with: python-version: "3.10" - - name: Install pytest - run: python3 -m pip install pytest + - name: Install integration-tests/requirements.txt + run: pip install -r integration-tests/requirements.txt - # We abort pytest after two failures as a compromise between wanting to - # avoid a flood of logs while still understanding if multiple tests would - # fail. - name: Run bootstrap tests (Runs in/Builds ${{ matrix.distro_image }} derived image) run: | - pytest --verbose --maxfail=2 --color=yes --durations=10 --capture=no \ - integration-tests/test_bootstrap.py + pytest --capture=no integration-tests/test_bootstrap.py timeout-minutes: 20 env: # integration-tests/test_bootstrap.py will build and start containers diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 96b22bd..ea7e1e2 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -82,15 +82,15 @@ jobs: - name: Install Python dependencies run: | - python3 -m pip install -r dev-requirements.txt - python3 -m pip install -e . + pip install -r dev-requirements.txt + pip install -e . + + - name: List Python dependencies + run: | pip freeze - # We abort pytest after two failures as a compromise between wanting to - # avoid a flood of logs while still understanding if multiple tests would - # fail. - name: Run unit tests - run: pytest --verbose --maxfail=2 --color=yes --durations=10 --cov=tljh tests/ + run: pytest tests timeout-minutes: 15 - uses: codecov/codecov-action@v3 From 8f4cee1e46245142118b62f6d34cc2a5fabdf62c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 14:25:00 +0200 Subject: [PATCH 168/232] test refactor: test_bootstrap.py, let container mount local dir and expose port --- integration-tests/test_bootstrap.py | 229 +++++++++++----------------- 1 file changed, 92 insertions(+), 137 deletions(-) diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index 545ba4c..d12357a 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -1,129 +1,78 @@ """ -Test running bootstrap script in different circumstances +This test file tests bootstrap.py ability to + +- error verbosely for old ubuntu +- error verbosely for no systemd +- start and provide a progress page web server """ import concurrent.futures import os import subprocess -import sys import time +GIT_REPO_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) BASE_IMAGE = os.getenv("BASE_IMAGE", "ubuntu:20.04") -def install_pkgs(container_name, show_progress_page): - # Install python3 inside the ubuntu container - # There is no trusted Ubuntu+Python3 container we can use - pkgs = ["python3"] - if show_progress_page: - pkgs += ["systemd", "git", "curl"] - # Create the sudoers dir, so that the installer successfully 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 _stop_container(): + """ + Stops a container if its already running. + """ + subprocess.run( + ["docker", "rm", "--force", "test-bootstrap"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) -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 - - -# FIXME: Refactor this function to easier to understand using the following -# parameters -# -# - param: container_apt_packages -# - param: bootstrap_tljh_source -# - local: copies local tljh repo to container and configures bootstrap to -# install tljh from copied repo -# - github: configures bootstrap to install tljh from the official github repo -# - : configures bootstrap to install tljh from any given remote location -# - param: bootstrap_flags -# -# FIXME: Consider stripping logic in this file to only testing if the bootstrap -# script successfully detects the too old Ubuntu version and the lack of -# systemd. The remaining test named test_progress_page could rely on -# running against the systemd container that cab be built by -# integration-test.py. -# -def run_bootstrap_after_preparing_container( - container_name, image, show_progress_page=False -): +def _run_bootstrap_in_container(image, complete_setup=True): """ - 1. Stops old container - 2. Starts --detached container - 3. Installs apt packages in container - 4. Two situations - - A) limited test (--show-progress-page=false) - - Copies ./bootstrap/ folder content to container /srv/src - - Runs copied bootstrap/bootstrap.py without flags - - B) full test (--show-progress-page=true) - - Copies ./ folder content to the container /srv/src - - Runs copied bootstrap/bootstrap.py with environment variables - - TLJH_BOOTSTRAP_DEV=yes - This makes --editable be used when installing the tljh package - - TLJH_BOOTSTRAP_PIP_SPEC=/srv/src - This makes us install tljh from the given location instead of from - github.com/jupyterhub/the-littlest-jupyterhub + 1. (Re-)starts a container named test-bootstrap based on image, mounting + local git repo and exposing port 8080 to the containers port 80. + 2. Installs python3, systemd, git, and curl in container + 3. Runs bootstrap/bootstrap.py in container to install the mounted git + repo's tljh package in --editable mode. """ - # stop container if it is already running - subprocess.run(["docker", "rm", "-f", container_name]) + _stop_container() # Start a detached container - subprocess.check_call( + subprocess.check_output( [ "docker", "run", "--env=DEBIAN_FRONTEND=noninteractive", + "--env=TLJH_BOOTSTRAP_DEV=yes", + "--env=TLJH_BOOTSTRAP_PIP_SPEC=/srv/src", + f"--volume={GIT_REPO_PATH}:/srv/src", + "--publish=8080:80", "--detach", - f"--name={container_name}", + "--name=test-bootstrap", image, - "/bin/bash", + "bash", "-c", - "sleep 1000s", + "sleep 300s", ] ) - 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, - "--version", - "main", - ] - if show_progress_page: - exec_flags = ( - ["-e", "TLJH_BOOTSTRAP_DEV=yes", "-e", "TLJH_BOOTSTRAP_PIP_SPEC=/srv/src"] - + exec_flags - + ["--show-progress-page"] + run = ["docker", "exec", "-i", "test-bootstrap"] + subprocess.check_output(run + ["apt-get", "update"]) + subprocess.check_output(run + ["apt-get", "install", "--yes", "python3"]) + if complete_setup: + subprocess.check_output( + run + ["apt-get", "install", "--yes", "systemd", "git", "curl"] ) - # Run bootstrap script, return the output + run_bootstrap = run + [ + "python3", + "/srv/src/bootstrap/bootstrap.py", + "--show-progress-page", + ] + + # Run bootstrap script inside detached container, return the output return subprocess.run( - ["docker", "exec"] + exec_flags, - check=False, - stdout=subprocess.PIPE, - encoding="utf-8", + run_bootstrap, + text=True, + capture_output=True, ) @@ -131,66 +80,72 @@ def test_ubuntu_too_old(): """ Error with a useful message when running in older Ubuntu """ - output = run_bootstrap_after_preparing_container("old-distro-test", "ubuntu:18.04") + output = _run_bootstrap_in_container("ubuntu:18.04", False) + _stop_container() assert output.stdout == "The Littlest JupyterHub requires Ubuntu 20.04 or higher\n" assert output.returncode == 1 -def test_inside_no_systemd_docker(): - output = run_bootstrap_after_preparing_container( - "plain-docker-test", - BASE_IMAGE, - ) +def test_no_systemd(): + output = _run_bootstrap_in_container("ubuntu:22.04", False) assert "Systemd is required to run TLJH" in output.stdout assert output.returncode == 1 -def verify_progress_page(expected_status_code, timeout): - progress_page_status = False +def _wait_for_progress_page_response(expected_status_code, timeout): start = time.time() - while not progress_page_status and (time.time() - start < timeout): + while time.time() - start < timeout: try: resp = subprocess.check_output( [ - "docker", - "exec", - "progress-page", "curl", - "-i", - "http://localhost/index.html", - ] + "--include", + "http://localhost:8080/index.html", + ], + text=True, + stderr=subprocess.DEVNULL, ) - if b"HTTP/1.0 200 OK" in resp: - progress_page_status = True - break - else: - print( - f"Unexpected progress page response: {resp[:100]}", file=sys.stderr - ) - except Exception as e: - print(f"Error getting progress page: {e}", file=sys.stderr) - time.sleep(1) - continue + if "HTTP/1.0 200 OK" in resp: + return True + except Exception: + pass + time.sleep(1) - return progress_page_status + return False -def test_progress_page(): +def test_show_progress_page(): with concurrent.futures.ThreadPoolExecutor() as executor: - installer = executor.submit( - run_bootstrap_after_preparing_container, - "progress-page", - BASE_IMAGE, - True, + run_bootstrap_job = executor.submit(_run_bootstrap_in_container, BASE_IMAGE) + + # Check that the bootstrap script started the web server reporting + # progress successfully responded. + success = _wait_for_progress_page_response( + expected_status_code=200, timeout=180 ) + if success: + # Let's terminate the test here and save a minute or so in test + # executation time, because we can know that the will be stopped + # successfully in other tests as otherwise traefik won't be able to + # start and use the same port for example. + return - # Check if progress page started - started = verify_progress_page(expected_status_code=200, timeout=180) - assert started + # Now await an expected failure to startup JupyterHub by tljh.installer, + # which should have taken over the work started by the bootstrap script. + # + # This failure is expected to occur in + # tljh.installer.ensure_jupyterhub_service calling systemd.reload_daemon + # like this: + # + # > System has not been booted with systemd as init system (PID 1). + # > Can't operate. + # + output = run_bootstrap_job.result() + print(output.stdout) + print(output.stderr) - # 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 + # At this point we should be able to see that tljh.installer + # intentionally stopped the web server reporting progress as the port + # were about to become needed by Traefik. assert "Progress page server stopped successfully." in output.stdout + assert success From 835c6a115469e5d5f687fab468ef6775dc229064 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 6 Jun 2023 16:53:16 +0200 Subject: [PATCH 169/232] test refactor: refactoring of .github/integration-test.py --- .github/integration-test.py | 275 +++++++++++----------- .github/workflows/integration-test.yaml | 64 ++--- dev-requirements.txt | 1 + integration-tests/test_admin_installer.py | 5 +- integration-tests/test_bootstrap.py | 4 + tests/test_bootstrap_functions.py | 6 +- 6 files changed, 173 insertions(+), 182 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index b6fd9e9..eb01cb1 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -1,154 +1,160 @@ #!/usr/bin/env python3 import argparse +import functools import os import subprocess import time from shutil import which +GIT_REPO_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +TEST_IMAGE_NAME = "test-systemd" -def container_runtime(): + +@functools.lru_cache() +def _get_container_runtime_cli(): runtimes = ["docker", "podman"] for runtime in runtimes: if which(runtime): return runtime - raise RuntimeError(f"No container runtime found, tried: {' '.join(runtimes)}") + raise RuntimeError(f"No container runtime CLI found, tried: {' '.join(runtimes)}") -def container_check_output(*args, **kwargs): - cmd = [container_runtime()] + list(*args) - print(f"Running {cmd} {kwargs}") - return subprocess.check_output(cmd, **kwargs) +def _cli(args, log_failure=True): + cmd = [_get_container_runtime_cli(), *args] + try: + return subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError: + if log_failure: + print(f"{cmd} failed!") + raise -def container_run(*args, **kwargs): - cmd = [container_runtime()] + list(*args) - print(f"Running {cmd} {kwargs}") - return subprocess.run(cmd, **kwargs) - - -def build_systemd_image(image_name, source_path, build_args=None): +def _await_container_startup(container_name, timeout=60): """ - Build docker image with systemd at source_path. - - Built image is tagged with image_name + Await container to become ready, as checked by attempting to run a basic + command (id) inside it. """ - cmd = ["build", f"-t={image_name}", source_path] - if build_args: - cmd.extend([f"--build-arg={ba}" for ba in build_args]) - container_check_output(cmd) - - -def check_container_ready(container_name, timeout=60): - """ - Check if container is ready to run tests - """ - now = time.time() + start = time.time() while True: try: - out = container_check_output(["exec", "-t", container_name, "id"]) - print(out.decode()) + _cli(["exec", "-t", container_name, "id"], log_failure=False) return - except subprocess.CalledProcessError as e: - print(e) - try: - out = container_check_output(["inspect", container_name]) - print(out.decode()) - except subprocess.CalledProcessError as e: - print(e) - try: - out = container_check_output(["logs", container_name]) - print(out.decode()) - except subprocess.CalledProcessError as e: - print(e) - if time.time() - now > timeout: - raise RuntimeError(f"Container {container_name} hasn't started") - time.sleep(5) + except subprocess.CalledProcessError: + if time.time() - start > timeout: + inspect = "" + logs = "" + try: + inspect = _cli(["inspect", container_name], log_failure=False) + except subprocess.CalledProcessError as e: + inspect = e.output + try: + logs = _cli(["logs", container_name], log_failure=False) + except subprocess.CalledProcessError as e: + logs = e.output + raise RuntimeError( + f"Container {container_name} failed to start! Debugging info follows...\n\n" + f"> docker inspect {container_name}\n" + "----------------------------------------\n" + f"{inspect}\n" + f"> docker logs {container_name}\n" + "----------------------------------------\n" + f"{logs}\n" + ) + time.sleep(1) -def run_systemd_image(image_name, container_name, bootstrap_pip_spec): +def build_image(build_args=None): """ - Run docker image with systemd + Build Dockerfile with systemd in the integration-tests folder to run tests + from. + """ + cmd = [ + _get_container_runtime_cli(), + "build", + f"--tag={TEST_IMAGE_NAME}", + "integration-tests", + ] + if build_args: + cmd.extend([f"--build-arg={ba}" for ba in build_args]) - Image named image_name should be built with build_systemd_image. + subprocess.run(cmd, check=True, text=True) - Container named container_name will be started. + +def start_container(container_name, bootstrap_pip_spec): + """ + Starts a container based on an image expected to start systemd. """ cmd = [ "run", - "--privileged", + "--rm", "--detach", + "--privileged", f"--name={container_name}", # A bit less than 1GB to ensure TLJH runs on 1GB VMs. # If this is changed all docs references to the required memory must be changed too. "--memory=900m", ] - if bootstrap_pip_spec: - cmd.append("-e") - cmd.append(f"TLJH_BOOTSTRAP_PIP_SPEC={bootstrap_pip_spec}") + cmd.append(f"--env=TLJH_BOOTSTRAP_PIP_SPEC={bootstrap_pip_spec}") + else: + cmd.append("--env=TLJH_BOOTSTRAP_DEV=yes") + cmd.append("--env=TLJH_BOOTSTRAP_PIP_SPEC=/srv/src") + cmd.append(TEST_IMAGE_NAME) - cmd.append(image_name) - - container_check_output(cmd) + return _cli(cmd) def stop_container(container_name): """ - Stop & remove docker container if it exists. + Stop and remove docker container if it exists. """ try: - container_check_output(["inspect", container_name], stderr=subprocess.STDOUT) + return _cli(["rm", "--force", container_name], log_failure=False) except subprocess.CalledProcessError: - # No such container exists, nothing to do - return - container_check_output(["rm", "-f", container_name]) + pass -def run_container_command(container_name, cmd): +def run_command(container_name, cmd): """ - Run cmd in a running container with a bash shell + Run a bash command in a running container and error if it fails """ - proc = container_run( - ["exec", "-t", container_name, "/bin/bash", "-c", cmd], - check=True, - ) + cmd = [ + _get_container_runtime_cli(), + "exec", + "-t", + container_name, + "/bin/bash", + "-c", + cmd, + ] + subprocess.run(cmd, check=True, text=True) def copy_to_container(container_name, src_path, dest_path): """ - Copy files from src_path to dest_path inside container_name + Copy files from a path on the local file system to a destination in a + running container """ - container_check_output(["cp", src_path, f"{container_name}:{dest_path}"]) + _cli(["cp", src_path, f"{container_name}:{dest_path}"]) def run_test( - image_name, - test_name, + container_name, bootstrap_pip_spec, test_files, upgrade_from, installer_args, ): """ - Starts a new container based on image_name, runs the bootstrap script to - setup tljh with installer_args, and runs test_name. + (Re-)starts a named container with given (Systemd based) image, then runs + the bootstrap script inside it to setup tljh with installer_args. + + Thereafter, source files are copied to the container and """ - stop_container(test_name) - run_systemd_image(image_name, test_name, bootstrap_pip_spec) - - check_container_ready(test_name) - - source_path = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) - - copy_to_container(test_name, os.path.join(source_path, "bootstrap/."), "/srv/src") - copy_to_container( - test_name, os.path.join(source_path, "integration-tests/"), "/srv/src" - ) - - # These logs can be very relevant to debug a container startup failure - print(f"--- Start of logs from the container: {test_name}") - print(container_check_output(["logs", test_name]).decode()) - print(f"--- End of logs from the container: {test_name}") + stop_container(container_name) + start_container(container_name, bootstrap_pip_spec) + _await_container_startup(container_name) + copy_to_container(container_name, GIT_REPO_PATH, "/srv/src") # To test upgrades, we run a bootstrap.py script two times instead of one, # where the initial run first installs some older version. @@ -156,38 +162,32 @@ def run_test( # We want to support testing a PR by upgrading from "main", "latest" (latest # released version), and from a previous major-like version. # - # FIXME: We currently always rely on the main branch's bootstrap.py script. - # Realistically, we should run previous versions of the bootstrap - # script which also installs previous versions of TLJH. - # - # 2023-04-15 Erik observed that https://tljh.jupyter.org/bootstrap.py - # is referencing to the master (now main) branch which didn't seem - # obvious, thinking it could have been the latest released version - # also. - # if upgrade_from: - run_container_command( - test_name, - f"curl -L https://tljh.jupyter.org/bootstrap.py | python3 - --version={upgrade_from}", + run_command( + container_name, + f"python3 /srv/src/bootstrap/bootstrap.py --version={upgrade_from}", ) - run_container_command(test_name, f"python3 /srv/src/bootstrap.py {installer_args}") + run_command( + container_name, f"python3 /srv/src/bootstrap/bootstrap.py {installer_args}" + ) # Install pkgs from requirements in hub's pip, where # the bootstrap script installed the others - run_container_command( - test_name, + run_command( + container_name, "/opt/tljh/hub/bin/python3 -m pip install -r /srv/src/integration-tests/requirements.txt", ) # show environment - run_container_command( - test_name, + run_command( + container_name, "/opt/tljh/hub/bin/python3 -m pip freeze", ) - run_container_command( - test_name, - "/opt/tljh/hub/bin/python3 -m pytest --capture=no {}".format( + # run tests + run_command( + container_name, + "/opt/tljh/hub/bin/python3 -m pytest {}".format( " ".join( [os.path.join("/srv/src/integration-tests/", f) for f in test_files] ) @@ -197,12 +197,20 @@ def run_test( def show_logs(container_name): """ - Print logs from inside container to stdout + Print jupyterhub and traefik status and logs from both. + + tljh logs ref: https://tljh.jupyter.org/en/latest/troubleshooting/logs.html """ - run_container_command(container_name, "journalctl --no-pager") - run_container_command( + systemctl = run_command( container_name, "systemctl --no-pager status jupyterhub traefik" ) + print(systemctl) + + jupyterhub_logs = run_command(container_name, "journalctl --no-pager -u jupyterhub") + print(jupyterhub_logs) + + traefik_logs = run_command(container_name, "journalctl --no-pager -u traefik") + print(traefik_logs) def main(): @@ -210,18 +218,14 @@ def main(): subparsers = argparser.add_subparsers(dest="action") build_image_parser = subparsers.add_parser("build-image") - build_image_parser.add_argument( - "--build-arg", - action="append", - dest="build_args", - ) - - stop_container_parser = subparsers.add_parser("stop-container") - stop_container_parser.add_argument("container_name") + build_image_parser.add_argument("--build-arg", action="append", dest="build_args") start_container_parser = subparsers.add_parser("start-container") start_container_parser.add_argument("container_name") + stop_container_parser = subparsers.add_parser("stop-container") + stop_container_parser.add_argument("container_name") + run_parser = subparsers.add_parser("run") run_parser.add_argument("container_name") run_parser.add_argument("command") @@ -234,10 +238,8 @@ def main(): run_test_parser = subparsers.add_parser("run-test") run_test_parser.add_argument("--installer-args", default="") run_test_parser.add_argument("--upgrade-from", default="") - run_test_parser.add_argument( - "--bootstrap-pip-spec", nargs="?", default="", type=str - ) - run_test_parser.add_argument("test_name") + run_test_parser.add_argument("--bootstrap-pip-spec", default="/srv/src") + run_test_parser.add_argument("container_name") run_test_parser.add_argument("test_files", nargs="+") show_logs_parser = subparsers.add_parser("show-logs") @@ -245,12 +247,19 @@ def main(): args = argparser.parse_args() - image_name = "tljh-systemd" - - if args.action == "run-test": + if args.action == "build-image": + build_image(args.build_args) + elif args.action == "start-container": + start_container(args.container_name, args.bootstrap_pip_spec) + elif args.action == "stop-container": + stop_container(args.container_name) + elif args.action == "run": + run_command(args.container_name, args.command) + elif args.action == "copy": + copy_to_container(args.container_name, args.src, args.dest) + elif args.action == "run-test": run_test( - image_name, - args.test_name, + args.container_name, args.bootstrap_pip_spec, args.test_files, args.upgrade_from, @@ -258,16 +267,6 @@ def main(): ) elif args.action == "show-logs": show_logs(args.container_name) - elif args.action == "run": - run_container_command(args.container_name, args.command) - elif args.action == "copy": - copy_to_container(args.container_name, args.src, args.dest) - elif args.action == "start-container": - run_systemd_image(image_name, args.container_name, args.bootstrap_pip_spec) - elif args.action == "stop-container": - stop_container(args.container_name) - elif args.action == "build-image": - build_systemd_image(image_name, "integration-tests", args.build_args) if __name__ == "__main__": diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index e445347..b020e06 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -64,8 +64,8 @@ jobs: - name: Run bootstrap tests (Runs in/Builds ${{ matrix.distro_image }} derived image) run: | - pytest --capture=no integration-tests/test_bootstrap.py - timeout-minutes: 20 + pytest integration-tests/test_bootstrap.py + timeout-minutes: 10 env: # integration-tests/test_bootstrap.py will build and start containers # based on this environment variable. This is similar to how @@ -73,64 +73,52 @@ jobs: # setting the base image via a Dockerfile ARG. BASE_IMAGE: ${{ matrix.distro_image }} - # We build a docker image from wherein we will work - - name: Build systemd image (Builds ${{ matrix.distro_image }} derived image) + - name: Build systemd image, derived from ${{ matrix.distro_image }} run: | .github/integration-test.py build-image \ --build-arg "BASE_IMAGE=${{ matrix.distro_image }}" - # FIXME: Make the logic below easier to follow. - # - In short, setting BOOTSTRAP_PIP_SPEC here, specifies from what - # location the tljh python package should be installed from. In this - # GitHub Workflow's test job, we provide a remote reference to itself as - # found on GitHub - this could be the HEAD of a PR branch or the default - # branch on merge. - # # Overview of how this logic influences the end result. # - integration-test.yaml: - # Runs integration-test.py by passing --bootstrap-pip-spec flag with a - # reference to the pull request on GitHub. - # - integration-test.py: - # Starts a pre-build systemd container, setting the - # TLJH_BOOTSTRAP_PIP_SPEC based on its passed --bootstrap-pip-spec value. - # - systemd container: - # Runs bootstrap.py - # - bootstrap.py - # Makes use of TLJH_BOOTSTRAP_PIP_SPEC environment variable to install - # the tljh package from a given location, which could be a local git - # clone of this repo where setup.py resides, or a reference to some - # GitHub branch for example. - - name: Set BOOTSTRAP_PIP_SPEC value - run: | - BOOTSTRAP_PIP_SPEC="git+https://github.com/$GITHUB_REPOSITORY.git@$GITHUB_REF" - echo "BOOTSTRAP_PIP_SPEC=$BOOTSTRAP_PIP_SPEC" >> $GITHUB_ENV - echo $BOOTSTRAP_PIP_SPEC - - - name: Run basic tests (Runs in ${{ matrix.distro_image }} derived image) + # + # - Runs integration-test.py build-image, to build a systemd based image + # to use later. + # - Runs integration-test.py run-tests, to start a systemd based + # container, run the bootstrap.py script inside it, and then run + # pytest from the hub python environment setup by the bootstrap + # script. + # + - name: pytest integration-tests/ run: | .github/integration-test.py run-test basic-tests \ - --bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \ ${{ matrix.extra_flags }} \ test_hub.py \ test_proxy.py \ test_install.py \ test_extensions.py - timeout-minutes: 15 + timeout-minutes: 10 + - name: show logs + run: | + .github/integration-test.py show-logs basic-tests - - name: Run admin tests (Runs in ${{ matrix.distro_image }} derived image) + - name: pytest integration-tests/test_admin_installer.py run: | .github/integration-test.py run-test admin-tests \ --installer-args "--admin admin:admin" \ - --bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \ ${{ matrix.extra_flags }} \ test_admin_installer.py - timeout-minutes: 15 + timeout-minutes: 5 + - name: show logs + run: | + .github/integration-test.py show-logs admin-tests - - name: Run plugin tests (Runs in ${{ matrix.distro_image }} derived image) + - name: pytest integration-tests/test_simplest_plugin.py run: | .github/integration-test.py run-test plugin-tests \ - --bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \ --installer-args "--plugin /srv/src/integration-tests/plugins/simplest" \ ${{ matrix.extra_flags }} \ test_simplest_plugin.py - timeout-minutes: 15 + timeout-minutes: 5 + - name: show logs + run: | + .github/integration-test.py show-logs plugin-tests diff --git a/dev-requirements.txt b/dev-requirements.txt index 9f14bc3..672ad32 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,5 @@ packaging pytest pytest-cov +pytest-asyncio pytest-mock diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index 083cf17..44fa18a 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -4,13 +4,14 @@ import pytest from hubtraf.auth.dummy import login_dummy from hubtraf.user import User +hub_url = "http://localhost" + async def test_admin_login(): """ Test if the admin that was added during install can login with the password provided. """ - hub_url = "http://localhost" username = "admin" password = "admin" @@ -32,8 +33,6 @@ async def test_unsuccessful_login(username, password): """ Ensure nobody but the admin that was added during install can login """ - hub_url = "http://localhost" - async with User(username, hub_url, partial(login_dummy, password="")) as u: user_logged_in = await u.login() diff --git a/integration-tests/test_bootstrap.py b/integration-tests/test_bootstrap.py index d12357a..09ae140 100644 --- a/integration-tests/test_bootstrap.py +++ b/integration-tests/test_bootstrap.py @@ -4,6 +4,10 @@ This test file tests bootstrap.py ability to - error verbosely for old ubuntu - error verbosely for no systemd - start and provide a progress page web server + +FIXME: The last test stands out and could be part of the other tests, and the + first two could be more like unit tests. Ideally, this file is + significantly reduced. """ import concurrent.futures import os diff --git a/tests/test_bootstrap_functions.py b/tests/test_bootstrap_functions.py index 799dc3c..b3625b8 100644 --- a/tests/test_bootstrap_functions.py +++ b/tests/test_bootstrap_functions.py @@ -1,12 +1,12 @@ # Unit test some functions from bootstrap.py -# Since bootstrap.py isn't part of the package, it's not automatically importable import os import sys -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - import pytest +# Since bootstrap.py isn't part of the package, it's not automatically importable +GIT_REPO_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +sys.path.insert(0, GIT_REPO_PATH) from bootstrap import bootstrap From 7f873966b57145de80c242e05afc9fd7638b7835 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 7 Jun 2023 00:21:15 +0200 Subject: [PATCH 170/232] test refactor: combine admin and plugin tests --- .github/workflows/integration-test.yaml | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index b020e06..1cca9f6 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -101,24 +101,14 @@ jobs: run: | .github/integration-test.py show-logs basic-tests - - name: pytest integration-tests/test_admin_installer.py + - name: pytest integration-tests/test_simplest_plugin.py integration-tests/test_admin_installer.py run: | - .github/integration-test.py run-test admin-tests \ - --installer-args "--admin admin:admin" \ - ${{ matrix.extra_flags }} \ - test_admin_installer.py - timeout-minutes: 5 - - name: show logs - run: | - .github/integration-test.py show-logs admin-tests - - - name: pytest integration-tests/test_simplest_plugin.py - run: | - .github/integration-test.py run-test plugin-tests \ - --installer-args "--plugin /srv/src/integration-tests/plugins/simplest" \ + .github/integration-test.py run-test admin-plugin-tests \ + --installer-args "--admin admin:admin --plugin /srv/src/integration-tests/plugins/simplest" \ ${{ matrix.extra_flags }} \ + test_admin_installer.py \ test_simplest_plugin.py timeout-minutes: 5 - name: show logs run: | - .github/integration-test.py show-logs plugin-tests + .github/integration-test.py show-logs admin-plugin-tests From f127322aa32c32490e557613a47d3f63d4d51071 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 7 Jun 2023 00:34:02 +0200 Subject: [PATCH 171/232] test refactor: be more generous with timeout values --- .github/workflows/integration-test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 1cca9f6..2942d16 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -96,7 +96,7 @@ jobs: test_proxy.py \ test_install.py \ test_extensions.py - timeout-minutes: 10 + timeout-minutes: 15 - name: show logs run: | .github/integration-test.py show-logs basic-tests @@ -108,7 +108,7 @@ jobs: ${{ matrix.extra_flags }} \ test_admin_installer.py \ test_simplest_plugin.py - timeout-minutes: 5 + timeout-minutes: 15 - name: show logs run: | .github/integration-test.py show-logs admin-plugin-tests From cdc4a9d3880bbe194da259ec2891901bcbcde096 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 7 Jun 2023 01:35:34 +0200 Subject: [PATCH 172/232] test refactor: reduce timeout for starting user server, sleep less --- integration-tests/test_admin_installer.py | 2 +- integration-tests/test_hub.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index 44fa18a..88ca9e7 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -18,7 +18,7 @@ async def test_admin_login(): async with User(username, hub_url, partial(login_dummy, password=password)) as u: await u.login() # If user is not logged in, this will raise an exception - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) @pytest.mark.parametrize( diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index bf56cc3..502df94 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -59,7 +59,7 @@ async def test_user_code_execute(): async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) await u.start_kernel() await u.assert_code_output("5 * 4", "20", 5, 5) @@ -102,7 +102,7 @@ async def test_user_server_started_with_custom_base_url(): async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # unset base_url to avoid problems with other tests assert ( @@ -154,7 +154,7 @@ async def test_user_admin_add(): async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists assert pwd.getpwnam(f"jupyter-{username}") is not None @@ -200,7 +200,7 @@ async def test_user_admin_remove(): async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists assert pwd.getpwnam(f"jupyter-{username}") is not None @@ -224,7 +224,7 @@ async def test_user_admin_remove(): ) await u.stop_server() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user does *not* have admin rights assert f"jupyter-{username}" not in grp.getgrnam("jupyterhub-admins").gr_mem @@ -254,7 +254,7 @@ async def test_long_username(): try: async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists system_username = generate_system_username(f"jupyter-{username}") @@ -307,7 +307,7 @@ async def test_user_group_adding(): try: async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists system_username = generate_system_username(f"jupyter-{username}") @@ -379,7 +379,7 @@ async def test_idle_server_culled(): await u.login() # Start user's server - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists assert pwd.getpwnam(f"jupyter-{username}") is not None @@ -498,7 +498,7 @@ async def test_active_server_not_culled(): async with User(username, hub_url, partial(login_dummy, password="")) as u: await u.login() # Start user's server - await u.ensure_server_simulate() + await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists assert pwd.getpwnam(f"jupyter-{username}") is not None From 971b7d5c7ef7931731f3cfaae72d8a741f3f713c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 7 Jun 2023 01:25:35 +0200 Subject: [PATCH 173/232] test refactor: show logs from jupyterhub and traefik after tests --- .github/integration-test.py | 55 +++++++++---------------- .github/workflows/integration-test.yaml | 4 ++ 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index eb01cb1..39d27f4 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -25,7 +25,7 @@ def _cli(args, log_failure=True): return subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT) except subprocess.CalledProcessError: if log_failure: - print(f"{cmd} failed!") + print(f"{cmd} failed!", flush=True) raise @@ -114,7 +114,7 @@ def stop_container(container_name): pass -def run_command(container_name, cmd): +def run_command(container_name, command): """ Run a bash command in a running container and error if it fails """ @@ -125,8 +125,9 @@ def run_command(container_name, cmd): container_name, "/bin/bash", "-c", - cmd, + command, ] + print(f"\nRunning: {cmd}\n----------------------------------------", flush=True) subprocess.run(cmd, check=True, text=True) @@ -163,36 +164,25 @@ def run_test( # released version), and from a previous major-like version. # if upgrade_from: - run_command( - container_name, - f"python3 /srv/src/bootstrap/bootstrap.py --version={upgrade_from}", - ) - run_command( - container_name, f"python3 /srv/src/bootstrap/bootstrap.py {installer_args}" - ) + command = f"python3 /srv/src/bootstrap/bootstrap.py --version={upgrade_from}" + run_command(container_name, command) + + command = f"python3 /srv/src/bootstrap/bootstrap.py {installer_args}" + run_command(container_name, command) # Install pkgs from requirements in hub's pip, where # the bootstrap script installed the others - run_command( - container_name, - "/opt/tljh/hub/bin/python3 -m pip install -r /srv/src/integration-tests/requirements.txt", - ) + command = "/opt/tljh/hub/bin/python3 -m pip install -r /srv/src/integration-tests/requirements.txt" + run_command(container_name, command) # show environment - run_command( - container_name, - "/opt/tljh/hub/bin/python3 -m pip freeze", - ) + command = "/opt/tljh/hub/bin/python3 -m pip freeze" + run_command(container_name, command) # run tests - run_command( - container_name, - "/opt/tljh/hub/bin/python3 -m pytest {}".format( - " ".join( - [os.path.join("/srv/src/integration-tests/", f) for f in test_files] - ) - ), - ) + test_files = " ".join([f"/srv/src/integration-tests/{f}" for f in test_files]) + command = f"/opt/tljh/hub/bin/python3 -m pytest {test_files}" + run_command(container_name, command) def show_logs(container_name): @@ -201,16 +191,9 @@ def show_logs(container_name): tljh logs ref: https://tljh.jupyter.org/en/latest/troubleshooting/logs.html """ - systemctl = run_command( - container_name, "systemctl --no-pager status jupyterhub traefik" - ) - print(systemctl) - - jupyterhub_logs = run_command(container_name, "journalctl --no-pager -u jupyterhub") - print(jupyterhub_logs) - - traefik_logs = run_command(container_name, "journalctl --no-pager -u traefik") - print(traefik_logs) + run_command(container_name, "systemctl --no-pager status jupyterhub traefik") + run_command(container_name, "journalctl --no-pager -u jupyterhub") + run_command(container_name, "journalctl --no-pager -u traefik") def main(): diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 2942d16..d53eec0 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -89,6 +89,7 @@ jobs: # script. # - name: pytest integration-tests/ + id: basic-tests run: | .github/integration-test.py run-test basic-tests \ ${{ matrix.extra_flags }} \ @@ -98,10 +99,12 @@ jobs: test_extensions.py timeout-minutes: 15 - name: show logs + if: always() && steps.basic-tests.outcome != 'skipped' run: | .github/integration-test.py show-logs basic-tests - name: pytest integration-tests/test_simplest_plugin.py integration-tests/test_admin_installer.py + id: admin-plugin-tests run: | .github/integration-test.py run-test admin-plugin-tests \ --installer-args "--admin admin:admin --plugin /srv/src/integration-tests/plugins/simplest" \ @@ -110,5 +113,6 @@ jobs: test_simplest_plugin.py timeout-minutes: 15 - name: show logs + if: always() && steps.admin-plugin-tests.outcome != 'skipped' run: | .github/integration-test.py show-logs admin-plugin-tests From 40b88cb780cb009d55fd9128a00ff705b428eb34 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jun 2023 22:44:21 +0200 Subject: [PATCH 174/232] test refactor: update simplest plugin tests With this refactor, the simplest plugin stops interfering with other tests. --- .../plugins/simplest/tljh_simplest.py | 36 +++----- integration-tests/test_simplest_plugin.py | 86 ++++++++++--------- 2 files changed, 60 insertions(+), 62 deletions(-) diff --git a/integration-tests/plugins/simplest/tljh_simplest.py b/integration-tests/plugins/simplest/tljh_simplest.py index a134083..015b504 100644 --- a/integration-tests/plugins/simplest/tljh_simplest.py +++ b/integration-tests/plugins/simplest/tljh_simplest.py @@ -1,55 +1,47 @@ """ -Simplest plugin that exercises all the hooks +Simplest plugin that exercises all the hooks defined in tljh/hooks.py. """ from tljh.hooks import hookimpl @hookimpl def tljh_extra_user_conda_packages(): - return [ - "hypothesis", - ] + return ["tqdm"] @hookimpl def tljh_extra_user_pip_packages(): - return [ - "django", - ] + return ["django"] @hookimpl def tljh_extra_hub_pip_packages(): - return [ - "there", - ] + return ["there"] @hookimpl def tljh_extra_apt_packages(): - return [ - "sl", - ] - - -@hookimpl -def tljh_config_post_install(config): - # Put an arbitrary marker we can test for - config["simplest_plugin"] = {"present": True} + return ["sl"] @hookimpl def tljh_custom_jupyterhub_config(c): - c.JupyterHub.authenticator_class = "tmpauthenticator.TmpAuthenticator" + c.Test.jupyterhub_config_set_by_simplest_plugin = True + + +@hookimpl +def tljh_config_post_install(config): + config["Test"] = {"tljh_config_set_by_simplest_plugin": True} @hookimpl def tljh_post_install(): - with open("test_post_install", "w") as f: - f.write("123456789") + with open("test_tljh_post_install", "w") as f: + f.write("file_written_by_simplest_plugin") @hookimpl def tljh_new_user_create(username): with open("test_new_user_create", "w") as f: + f.write("file_written_by_simplest_plugin") f.write(username) diff --git a/integration-tests/test_simplest_plugin.py b/integration-tests/test_simplest_plugin.py index 171578c..96eb0a5 100644 --- a/integration-tests/test_simplest_plugin.py +++ b/integration-tests/test_simplest_plugin.py @@ -1,79 +1,85 @@ """ -Test simplest plugin +Test the plugin in integration-tests/plugins/simplest that makes use of all tljh +recognized plugin hooks that are defined in tljh/hooks.py. """ import os import subprocess -import requests from ruamel.yaml import YAML from tljh import user from tljh.config import CONFIG_FILE, HUB_ENV_PREFIX, USER_ENV_PREFIX +GIT_REPO_PATH = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) yaml = YAML(typ="rt") -def test_apt_packages(): - """ - Test extra apt packages are installed - """ - assert os.path.exists("/usr/games/sl") +def test_tljh_extra_user_conda_packages(): + subprocess.check_call([f"{USER_ENV_PREFIX}/bin/python3", "-c", "import tqdm"]) -def test_pip_packages(): - """ - Test extra user & hub pip packages are installed - """ +def test_tljh_extra_user_pip_packages(): subprocess.check_call([f"{USER_ENV_PREFIX}/bin/python3", "-c", "import django"]) + +def test_tljh_extra_hub_pip_packages(): subprocess.check_call([f"{HUB_ENV_PREFIX}/bin/python3", "-c", "import there"]) -def test_conda_packages(): - """ - Test extra user conda packages are installed - """ - subprocess.check_call([f"{USER_ENV_PREFIX}/bin/python3", "-c", "import hypothesis"]) +def test_tljh_extra_apt_packages(): + assert os.path.exists("/usr/games/sl") -def test_config_hook(): +def test_tljh_custom_jupyterhub_config(): """ - Check config changes are present + Test that the provided tljh_custom_jupyterhub_config hook has made the tljh + jupyterhub load additional jupyterhub config. + """ + tljh_jupyterhub_config = os.path.join(GIT_REPO_PATH, "tljh", "jupyterhub_config.py") + output = subprocess.check_output( + [ + f"{HUB_ENV_PREFIX}/bin/python3", + "-m", + "jupyterhub", + "--show-config", + "--config", + tljh_jupyterhub_config, + ], + text=True, + ) + assert "jupyterhub_config_set_by_simplest_plugin" in output + + +def test_tljh_config_post_install(): + """ + Test that the provided tljh_config_post_install hook has made tljh recognize + additional tljh config. """ with open(CONFIG_FILE) as f: - data = yaml.load(f) - - assert data["simplest_plugin"]["present"] + tljh_config = yaml.load(f) + assert tljh_config["Test"]["tljh_config_set_by_simplest_plugin"] -def test_jupyterhub_config_hook(): +def test_tljh_post_install(): """ - Test that tmpauthenticator is enabled by our custom config plugin + Test that the provided tljh_post_install hook has been executed by looking + for a specific file written. """ - 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: + with open("test_tljh_post_install") as f: content = f.read() - - assert content == "123456789" + assert "file_written_by_simplest_plugin" in content -def test_new_user_create(): +def test_tljh_new_user_create(): """ - Test that plugin receives username as arg + Test that the provided tljh_new_user_create hook has been executed by + looking for a specific file written. """ + # Trigger the hook by letting tljh's code create a user username = "user1" - # Call ensure_user to make sure the user plugin gets called user.ensure_user(username) with open("test_new_user_create") as f: content = f.read() - - assert content == username + assert "file_written_by_simplest_plugin" in content + assert username in content From 111e9bee86d99d0c1cd475deb63f9079b91e70d0 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 8 Jun 2023 23:35:45 +0200 Subject: [PATCH 175/232] test refactor: combine two separate installation setups --- .github/workflows/integration-test.yaml | 33 ++++++++++++------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index d53eec0..3243692 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -83,36 +83,35 @@ jobs: # # - Runs integration-test.py build-image, to build a systemd based image # to use later. + # # - Runs integration-test.py run-tests, to start a systemd based # container, run the bootstrap.py script inside it, and then run # pytest from the hub python environment setup by the bootstrap # script. # + # About passed --installer-args: + # + # - --admin admin:admin + # Required for test_admin_installer.py + # + # - --plugin /srv/src/integration-tests/plugins/simplest + # Required for test_simplest_plugin.py + # - name: pytest integration-tests/ - id: basic-tests + id: integration-tests run: | - .github/integration-test.py run-test basic-tests \ + .github/integration-test.py run-test integration-tests \ + --installer-args "--admin test-admin-username:test-admin-password" \ + --installer-args "--plugin /srv/src/integration-tests/plugins/simplest" \ ${{ matrix.extra_flags }} \ test_hub.py \ test_proxy.py \ test_install.py \ - test_extensions.py - timeout-minutes: 15 - - name: show logs - if: always() && steps.basic-tests.outcome != 'skipped' - run: | - .github/integration-test.py show-logs basic-tests - - - name: pytest integration-tests/test_simplest_plugin.py integration-tests/test_admin_installer.py - id: admin-plugin-tests - run: | - .github/integration-test.py run-test admin-plugin-tests \ - --installer-args "--admin admin:admin --plugin /srv/src/integration-tests/plugins/simplest" \ - ${{ matrix.extra_flags }} \ + test_extensions.py \ test_admin_installer.py \ test_simplest_plugin.py timeout-minutes: 15 - name: show logs - if: always() && steps.admin-plugin-tests.outcome != 'skipped' + if: always() && steps.integration-tests.outcome != 'skipped' run: | - .github/integration-test.py show-logs admin-plugin-tests + .github/integration-test.py show-logs integration-tests From b1c7e53be454c74e484061690675edf863ce0e4a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 00:20:58 +0200 Subject: [PATCH 176/232] test refactor: try decouple admin tests from other tests --- .github/integration-test.py | 4 ++-- integration-tests/test_admin_installer.py | 8 ++++---- tljh/installer.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/integration-test.py b/.github/integration-test.py index 39d27f4..fcb8768 100755 --- a/.github/integration-test.py +++ b/.github/integration-test.py @@ -167,7 +167,7 @@ def run_test( command = f"python3 /srv/src/bootstrap/bootstrap.py --version={upgrade_from}" run_command(container_name, command) - command = f"python3 /srv/src/bootstrap/bootstrap.py {installer_args}" + command = f"python3 /srv/src/bootstrap/bootstrap.py {' '.join(installer_args)}" run_command(container_name, command) # Install pkgs from requirements in hub's pip, where @@ -219,7 +219,7 @@ def main(): copy_parser.add_argument("dest") run_test_parser = subparsers.add_parser("run-test") - run_test_parser.add_argument("--installer-args", default="") + run_test_parser.add_argument("--installer-args", action="append") run_test_parser.add_argument("--upgrade-from", default="") run_test_parser.add_argument("--bootstrap-pip-spec", default="/srv/src") run_test_parser.add_argument("container_name") diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index 88ca9e7..8314bfa 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -12,8 +12,8 @@ async def test_admin_login(): Test if the admin that was added during install can login with the password provided. """ - username = "admin" - password = "admin" + username = "test-admin-username" + password = "test-admin-password" async with User(username, hub_url, partial(login_dummy, password=password)) as u: await u.login() @@ -24,8 +24,8 @@ async def test_admin_login(): @pytest.mark.parametrize( "username, password", [ - ("admin", ""), - ("admin", "wrong_passw"), + ("test-admin-username", ""), + ("test-admin-username", "wrong_passw"), ("user", "password"), ], ) diff --git a/tljh/installer.py b/tljh/installer.py index 33cce12..dc787f4 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -284,7 +284,7 @@ def ensure_user_environment(user_requirements_txt_file): def ensure_admins(admin_password_list): """ - Setup given list of users as admins. + Setup given list of user[:password] strings as admins. """ os.makedirs(STATE_DIR, mode=0o700, exist_ok=True) From d1c2e5152541fbd95a99645215f7c9aca2292cf9 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 00:33:41 +0200 Subject: [PATCH 177/232] test refactor: avoid a redundant server startup test --- integration-tests/test_admin_installer.py | 33 ++++++++--------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index 8314bfa..99fdf65 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -7,33 +7,22 @@ from hubtraf.user import User hub_url = "http://localhost" -async def test_admin_login(): - """ - Test if the admin that was added during install can login with - the password provided. - """ - username = "test-admin-username" - password = "test-admin-password" - - async with User(username, hub_url, partial(login_dummy, password=password)) as u: - await u.login() - # If user is not logged in, this will raise an exception - await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) - - @pytest.mark.parametrize( - "username, password", + "username, password, expect_successful_login", [ - ("test-admin-username", ""), - ("test-admin-username", "wrong_passw"), - ("user", "password"), + ("test-admin-username", "wrong_passw", False), + ("test-admin-username", "test-admin-password", True), + ("test-admin-username", "", False), + ("user", "", False), + ("user", "password", False), ], ) -async def test_unsuccessful_login(username, password): +async def test_pre_configured_admin_login(username, password, expect_successful_login): """ - Ensure nobody but the admin that was added during install can login + Verify that the "--admin :" flag allows that user/pass + combination and no other user can login. """ - async with User(username, hub_url, partial(login_dummy, password="")) as u: + async with User(username, hub_url, partial(login_dummy, password=password)) as u: user_logged_in = await u.login() - assert user_logged_in == False + assert user_logged_in == expect_successful_login From ec3ee03dc8446daeb462e0d1194a605d604eb7db Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 00:42:07 +0200 Subject: [PATCH 178/232] test refactor: ignore code coverage warning --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d0e97f8..d256f58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ target_version = [ [tool.pytest.ini_options] addopts = "--verbose --color=yes --durations=10 --maxfail=1 --cov=tljh" asyncio_mode = "auto" +filterwarnings = [ + 'ignore:.*Module bootstrap was never imported.*:coverage.exceptions.CoverageWarning', +] # pytest-cov / coverage is used to measure code coverage of tests From 3e9ee8ca8f823c4fff415380bf5ab9bdcc0cb5f0 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 01:14:01 +0200 Subject: [PATCH 179/232] test refactor: remove test with broken assumption To remove a user from the admin list doesn't matter, even though its clearly something one may expect should matter. The issue is that JupyterHub itself makes use of the admin_users list as a way to declare new admins, not to remove users having been marked as admin in the past. What to do about this is not clear, but having a test for this doesn't make sense. --- integration-tests/test_hub.py | 67 ----------------------------------- 1 file changed, 67 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 502df94..c6f5a57 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -163,73 +163,6 @@ async def test_user_admin_add(): assert f"jupyter-{username}" in grp.getgrnam("jupyterhub-admins").gr_mem -# FIXME: Make this test pass -@pytest.mark.xfail(reason="Unclear why this is failing") -async def test_user_admin_remove(): - """ - User is made an admin, logs in and we check if they are in admin group. - - Then we remove them from admin group, and check they *aren't* in admin group :D - """ - # This *must* be localhost, not an IP - # aiohttp throws away cookies if we are connecting to an IP! - username = secrets.token_hex(8) - - assert ( - 0 - == await ( - await asyncio.create_subprocess_exec( - *TLJH_CONFIG_PATH, "set", "auth.type", "dummy" - ) - ).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() - ) - - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() - await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) - - # Assert that the user exists - assert pwd.getpwnam(f"jupyter-{username}") is not None - - # Assert that the user has admin rights - 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() - ) - - await u.stop_server() - await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) - - # Assert that the user does *not* have admin rights - assert f"jupyter-{username}" not in grp.getgrnam("jupyterhub-admins").gr_mem - - async def test_long_username(): """ User with a long name logs in, and we check if their name is properly truncated. From 1d203ccd2a43c6f03d91e059a9291c8a6c8034d2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 01:48:00 +0200 Subject: [PATCH 180/232] test refactor: reduce time spent waiting on culling/not culling --- integration-tests/test_hub.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index c6f5a57..5066d98 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -260,7 +260,7 @@ async def test_user_group_adding(): async def test_idle_server_culled(): """ - User logs in, starts a server & stays idle for 1 min. + User logs in, starts a server & stays idle for a while. (the user's server should be culled during this period) """ username = secrets.token_hex(8) @@ -291,12 +291,12 @@ async def test_idle_server_culled(): ) ).wait() ) - # Cull servers and users after 30s, regardless of activity + # Cull servers and users after a while, regardless of activity assert ( 0 == await ( await asyncio.create_subprocess_exec( - *TLJH_CONFIG_PATH, "set", "services.cull.max_age", "30" + *TLJH_CONFIG_PATH, "set", "services.cull.max_age", "15" ) ).wait() ) @@ -349,7 +349,7 @@ async def test_idle_server_culled(): # Wait for culling # step 1: check if the server is still running - timeout = 100 + timeout = 30 async def server_stopped(): """Has the server been stopped?""" @@ -365,7 +365,7 @@ async def test_idle_server_culled(): # step 2. wait for user to be deleted async def user_removed(): - # Check that after 60s, the user has been culled + # Check that after a while, the user has been culled r = await hub_api_request() print(f"{r.status} {r.url}") return r.status == 403 @@ -379,7 +379,7 @@ async def test_idle_server_culled(): async def test_active_server_not_culled(): """ - User logs in, starts a server & stays idle for 30s + User logs in, starts a server & stays idle for a while (the user's server should not be culled during this period). """ # This *must* be localhost, not an IP @@ -412,12 +412,12 @@ async def test_active_server_not_culled(): ) ).wait() ) - # Cull servers and users after 30s, regardless of activity + # Cull servers and users after a while, regardless of activity assert ( 0 == await ( await asyncio.create_subprocess_exec( - *TLJH_CONFIG_PATH, "set", "services.cull.max_age", "60" + *TLJH_CONFIG_PATH, "set", "services.cull.max_age", "30" ) ).wait() ) @@ -441,7 +441,7 @@ async def test_active_server_not_culled(): assert r.status == 200 async def server_has_stopped(): - # Check that after 30s, we can still reach the user's server + # Check that after a while, we can still reach the user's server r = await u.session.get(user_url, allow_redirects=False) print(f"{r.status} {r.url}") return r.status != 200 @@ -450,7 +450,7 @@ async def test_active_server_not_culled(): await exponential_backoff( server_has_stopped, "User's server is still reachable (good!)", - timeout=30, + timeout=15, ) except asyncio.TimeoutError: # timeout error means the test passed - the server didn't go away while we were waiting From b1822d7098f7fef2d9a01db2c19414672126f85b Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 02:00:30 +0200 Subject: [PATCH 181/232] test refactor: small details in test_hub.py --- integration-tests/test_admin_installer.py | 4 +-- integration-tests/test_hub.py | 39 +++++++++++------------ 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index 99fdf65..3b910cc 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -4,7 +4,7 @@ import pytest from hubtraf.auth.dummy import login_dummy from hubtraf.user import User -hub_url = "http://localhost" +HUB_URL = "http://localhost" @pytest.mark.parametrize( @@ -22,7 +22,7 @@ async def test_pre_configured_admin_login(username, password, expect_successful_ Verify that the "--admin :" flag allows that user/pass combination and no other user can login. """ - async with User(username, hub_url, partial(login_dummy, password=password)) as u: + async with User(username, HUB_URL, partial(login_dummy, password=password)) as u: user_logged_in = await u.login() assert user_logged_in == expect_successful_login diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 5066d98..55a35cd 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -21,16 +21,16 @@ TLJH_CONFIG_PATH = ["sudo", "tljh-config"] # This *must* be localhost, not an IP # aiohttp throws away cookies if we are connecting to an IP! -hub_url = "http://localhost" +HUB_URL = "http://localhost" def test_hub_up(): - r = requests.get(hub_url) + r = requests.get(HUB_URL) r.raise_for_status() def test_hub_version(): - r = requests.get(hub_url + "/hub/api") + r = requests.get(HUB_URL + "/hub/api") r.raise_for_status() info = r.json() assert V("4") <= V(info["version"]) <= V("5") @@ -57,15 +57,12 @@ async def test_user_code_execute(): ).wait() ) - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() + async with User(username, HUB_URL, partial(login_dummy, password="")) as u: + assert await u.login() await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) await u.start_kernel() await u.assert_code_output("5 * 4", "20", 5, 5) - # Assert that the user exists - assert pwd.getpwnam(f"jupyter-{username}") is not None - async def test_user_server_started_with_custom_base_url(): """ @@ -74,7 +71,7 @@ async def test_user_server_started_with_custom_base_url(): # This *must* be localhost, not an IP # aiohttp throws away cookies if we are connecting to an IP! base_url = "/custom-base" - hub_url = f"http://localhost{base_url}" + custom_hub_url = f"{HUB_URL}{base_url}" username = secrets.token_hex(8) assert ( @@ -100,8 +97,8 @@ async def test_user_server_started_with_custom_base_url(): ).wait() ) - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() + async with User(username, custom_hub_url, partial(login_dummy, password="")) as u: + assert await u.login() await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # unset base_url to avoid problems with other tests @@ -152,8 +149,8 @@ async def test_user_admin_add(): ).wait() ) - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() + async with User(username, HUB_URL, partial(login_dummy, password="")) as u: + assert await u.login() await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists @@ -185,8 +182,8 @@ async def test_long_username(): ) try: - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() + async with User(username, HUB_URL, partial(login_dummy, password="")) as u: + assert await u.login() await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists @@ -238,8 +235,8 @@ async def test_user_group_adding(): ) try: - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() + async with User(username, HUB_URL, partial(login_dummy, password="")) as u: + assert await u.login() await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists @@ -307,9 +304,9 @@ async def test_idle_server_culled(): ).wait() ) - async with User(username, hub_url, partial(login_dummy, password="")) as u: + async with User(username, HUB_URL, partial(login_dummy, password="")) as u: # Login the user - await u.login() + assert await u.login() # Start user's server await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) @@ -428,8 +425,8 @@ async def test_active_server_not_culled(): ).wait() ) - async with User(username, hub_url, partial(login_dummy, password="")) as u: - await u.login() + async with User(username, HUB_URL, partial(login_dummy, password="")) as u: + assert await u.login() # Start user's server await u.ensure_server_simulate(timeout=60, spawn_refresh_time=5) # Assert that the user exists From b02a8b044f0ee497b243c6d7c4660437317b92d4 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 02:55:06 +0200 Subject: [PATCH 182/232] test refactor: mitigate persisted state between tests --- integration-tests/test_admin_installer.py | 34 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/integration-tests/test_admin_installer.py b/integration-tests/test_admin_installer.py index 3b910cc..7eb2533 100644 --- a/integration-tests/test_admin_installer.py +++ b/integration-tests/test_admin_installer.py @@ -1,20 +1,48 @@ +import asyncio from functools import partial import pytest from hubtraf.auth.dummy import login_dummy from hubtraf.user import User +# Use sudo to invoke it, since this is how users invoke it. +# This catches issues with PATH +TLJH_CONFIG_PATH = ["sudo", "tljh-config"] + +# This *must* be localhost, not an IP +# aiohttp throws away cookies if we are connecting to an IP! HUB_URL = "http://localhost" +# FIXME: Other tests may have set the auth.type to dummy, so we reset it here to +# get the default of firstuseauthenticator. Tests should cleanup after +# themselves to a better degree, but its a bit trouble to reload the +# jupyterhub between each test as well if thats needed... +async def test_restore_relevant_tljh_state(): + assert ( + 0 + == await ( + await asyncio.create_subprocess_exec( + *TLJH_CONFIG_PATH, + "set", + "auth.type", + "firstuseauthenticator.FirstUseAuthenticator", + ) + ).wait() + ) + assert ( + 0 + == await ( + await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, "reload") + ).wait() + ) + + @pytest.mark.parametrize( "username, password, expect_successful_login", [ - ("test-admin-username", "wrong_passw", False), ("test-admin-username", "test-admin-password", True), - ("test-admin-username", "", False), ("user", "", False), - ("user", "password", False), ], ) async def test_pre_configured_admin_login(username, password, expect_successful_login): From cb200d9702956133415bf420bee4024b699c8b52 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 03:40:36 +0200 Subject: [PATCH 183/232] test refactor: comment about test_bootstrap.py --- .github/workflows/integration-test.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 3243692..905f996 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -59,7 +59,14 @@ jobs: with: python-version: "3.10" - - name: Install integration-tests/requirements.txt + # FIXME: The test_bootstrap.py script has duplicated logic to run build + # and start images and run things in them. This makes tests slower, + # and adds code to maintain. Let's try to remove it. + # + # - bootstrap.py's failure detections, put in unit tests? + # - bootstrap.py's --show-progress-page test, include as integration test? + # + - name: Install integration-tests/requirements.txt for test_bootstrap.py run: pip install -r integration-tests/requirements.txt - name: Run bootstrap tests (Runs in/Builds ${{ matrix.distro_image }} derived image) From 0c914afeab5941890b01f4ca41445824e1957165 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 12:47:21 +0200 Subject: [PATCH 184/232] test refactor: put bootstrap tests in an isolated job --- .github/workflows/integration-test.yaml | 64 ++++++++++++++++--------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 905f996..feba78d 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -35,7 +35,6 @@ jobs: include: - name: "Debian 11, Py 3.9" distro_image: "debian:11" - runs_on: "ubuntu-22.04" extra_flags: "" - name: "Ubuntu 20.04, Py 3.8" distro_image: "ubuntu:20.04" @@ -59,27 +58,6 @@ jobs: with: python-version: "3.10" - # FIXME: The test_bootstrap.py script has duplicated logic to run build - # and start images and run things in them. This makes tests slower, - # and adds code to maintain. Let's try to remove it. - # - # - bootstrap.py's failure detections, put in unit tests? - # - bootstrap.py's --show-progress-page test, include as integration test? - # - - name: Install integration-tests/requirements.txt for test_bootstrap.py - run: pip install -r integration-tests/requirements.txt - - - name: Run bootstrap tests (Runs in/Builds ${{ matrix.distro_image }} derived image) - run: | - pytest integration-tests/test_bootstrap.py - timeout-minutes: 10 - env: - # integration-tests/test_bootstrap.py will build and start containers - # based on this environment variable. This is similar to how - # .github/integration-test.py build-image can take a --build-arg - # setting the base image via a Dockerfile ARG. - BASE_IMAGE: ${{ matrix.distro_image }} - - name: Build systemd image, derived from ${{ matrix.distro_image }} run: | .github/integration-test.py build-image \ @@ -122,3 +100,45 @@ jobs: if: always() && steps.integration-tests.outcome != 'skipped' run: | .github/integration-test.py show-logs integration-tests + + integration-tests-bootstrap: + # integration tests run in a container, + # not in the worker, so this version is not relevant to the tests + # and can be the same for all tested versions + runs-on: ubuntu-22.04 + + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: "Ubuntu 22.04 Py 3.10 (test_bootstrap.py)" + distro_image: "ubuntu:22.04" + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + + # FIXME: The test_bootstrap.py script has duplicated logic to run build + # and start images and run things in them. This makes tests slower, + # and adds code to maintain. Let's try to remove it. + # + # - bootstrap.py's failure detections, put in unit tests? + # - bootstrap.py's --show-progress-page test, include as a normal + # integration test? + # + - name: Install integration-tests/requirements.txt for test_bootstrap.py + run: pip install -r integration-tests/requirements.txt + + - name: Run bootstrap tests (Runs in/Builds ${{ matrix.distro_image }} derived image) + run: | + pytest integration-tests/test_bootstrap.py + timeout-minutes: 10 + env: + # integration-tests/test_bootstrap.py will build and start containers + # based on this environment variable. This is similar to how + # .github/integration-test.py build-image can take a --build-arg + # setting the base image via a Dockerfile ARG. + BASE_IMAGE: ${{ matrix.distro_image }} From 4f0179a84cfadfcd622282134bfb0c60a2a4890d Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Jun 2023 13:58:32 +0200 Subject: [PATCH 185/232] bump minimum conda/mamba versions an old conda bug causes RemoveError: requests is a dependency of conda when installing other packages upgrading conda itself avoids this bug. It's unclear what the true minimum version is to fix this, but the important thing is that it will be upgraded if it's still the version in 0.2.0 (4.10), so pick the version from today which we know works --- tljh/installer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index dc787f4..b65ddac 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -148,8 +148,8 @@ MAMBAFORGE_CHECKSUMS = { # minimum versions of packages MINIMUM_VERSIONS = { # if conda/mamba/pip are lower than this, upgrade them before installing the user packages - "mamba": "0.16.0", - "conda": "4.10", + "mamba": "1.4.2", + "conda": "23.3.1", "pip": "23.1.2", # minimum Python version (if not matched, abort to avoid big disruptive updates) "python": "3.9", From 4cc55df2a446b0fcf60ae8777c7206bb8be0cf62 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Jun 2023 14:08:03 +0200 Subject: [PATCH 186/232] force upgrade of conda itself if conda upgrade conda is broken, we are in trouble this is required to avoid the RemoveError --- tljh/conda.py | 13 ++++++++----- tljh/installer.py | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tljh/conda.py b/tljh/conda.py index df9a027..ca849e8 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -98,7 +98,7 @@ def install_miniconda(installer_path, prefix): fix_permissions(prefix) -def ensure_conda_packages(prefix, packages): +def ensure_conda_packages(prefix, packages, force=False): """ Ensure packages (from conda-forge) are installed in the conda prefix. @@ -110,13 +110,16 @@ def ensure_conda_packages(prefix, packages): # fallback on conda if mamba is not present (e.g. for mamba to install itself) conda_executable = os.path.join(prefix, "bin", "conda") + cmd = [conda_executable, "install", "--yes"] + + if force: + cmd += ["--force"] + abspath = os.path.abspath(prefix) utils.run_subprocess( - [ - conda_executable, - "install", - "-y", + cmd + + [ "-c", "conda-forge", # Make customizable if we ever need to "--prefix", diff --git a/tljh/installer.py b/tljh/installer.py index b65ddac..8c429ec 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -249,6 +249,8 @@ def ensure_user_environment(user_requirements_txt_file): # we _could_ explicitly pin Python here, # but conda already does this by default cf_pkgs_to_upgrade, + # use force to avoid RemoveError: 'requests' is a dependency of conda + force=True, ) pypi_pkgs_to_upgrade = list(set(to_upgrade) & {"pip"}) if pypi_pkgs_to_upgrade: From 29b354b42b5e549d5a7c6b68ce3c7036a65a7cfc Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Jun 2023 14:16:27 +0200 Subject: [PATCH 187/232] use force-reinstall not deprecated --force --- tljh/conda.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tljh/conda.py b/tljh/conda.py index ca849e8..834b600 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -113,7 +113,9 @@ def ensure_conda_packages(prefix, packages, force=False): cmd = [conda_executable, "install", "--yes"] if force: - cmd += ["--force"] + # use force-reinstall, e.g. for conda/mamba to ensure everything is okay + # avoids problems with RemoveError upgrading conda from old versions + cmd += ["--force-reinstall"] abspath = os.path.abspath(prefix) From ee23e041dee6f081d7bb62ffb41f2e277c100f4a Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Jun 2023 14:45:30 +0200 Subject: [PATCH 188/232] reinstall conda/mamba in a separate, unconditional step makes it more likely that subsequent conda installs will succeed - fix indentation on the upgrade steps so they aren't run every iteration - no longer need to bump required versions --- tljh/conda.py | 4 ++-- tljh/installer.py | 51 +++++++++++++++++++++++++++++------------------ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/tljh/conda.py b/tljh/conda.py index 834b600..a543645 100644 --- a/tljh/conda.py +++ b/tljh/conda.py @@ -98,7 +98,7 @@ def install_miniconda(installer_path, prefix): fix_permissions(prefix) -def ensure_conda_packages(prefix, packages, force=False): +def ensure_conda_packages(prefix, packages, force_reinstall=False): """ Ensure packages (from conda-forge) are installed in the conda prefix. @@ -112,7 +112,7 @@ def ensure_conda_packages(prefix, packages, force=False): cmd = [conda_executable, "install", "--yes"] - if force: + if force_reinstall: # use force-reinstall, e.g. for conda/mamba to ensure everything is okay # avoids problems with RemoveError upgrading conda from old versions cmd += ["--force-reinstall"] diff --git a/tljh/installer.py b/tljh/installer.py index 8c429ec..ab7a460 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -148,8 +148,8 @@ MAMBAFORGE_CHECKSUMS = { # minimum versions of packages MINIMUM_VERSIONS = { # if conda/mamba/pip are lower than this, upgrade them before installing the user packages - "mamba": "1.4.2", - "conda": "23.3.1", + "mamba": "0.16.0", + "conda": "4.10", "pip": "23.1.2", # minimum Python version (if not matched, abort to avoid big disruptive updates) "python": "3.9", @@ -242,23 +242,36 @@ def ensure_user_environment(user_requirements_txt_file): ) to_upgrade.append(pkg) - cf_pkgs_to_upgrade = list(set(to_upgrade) & {"conda", "mamba"}) - if cf_pkgs_to_upgrade: - conda.ensure_conda_packages( - USER_ENV_PREFIX, - # we _could_ explicitly pin Python here, - # but conda already does this by default - cf_pkgs_to_upgrade, - # use force to avoid RemoveError: 'requests' is a dependency of conda - force=True, - ) - pypi_pkgs_to_upgrade = list(set(to_upgrade) & {"pip"}) - if pypi_pkgs_to_upgrade: - conda.ensure_pip_packages( - USER_ENV_PREFIX, - pypi_pkgs_to_upgrade, - upgrade=True, - ) + # force reinstall conda/mamba to ensure a basically consistent env + # avoids issues with RemoveError: 'requests' is a dependency of conda + if not is_fresh_install: + # force-reinstall doesn't upgrade packages + # it reinstalls them in-place + # only include packages already installed here + to_reinstall = {"conda", "mamba"} & set(package_versions) + logger.info( + f"Reinstalling {', '.join(to_reinstall)} to ensure a consistent environment" + ) + conda.ensure_conda_packages( + USER_ENV_PREFIX, list(to_reinstall), force_reinstall=True + ) + + cf_pkgs_to_upgrade = list(set(to_upgrade) & {"conda", "mamba"}) + if cf_pkgs_to_upgrade: + conda.ensure_conda_packages( + USER_ENV_PREFIX, + # we _could_ explicitly pin Python here, + # but conda already does this by default + cf_pkgs_to_upgrade, + ) + + pypi_pkgs_to_upgrade = list(set(to_upgrade) & {"pip"}) + if pypi_pkgs_to_upgrade: + conda.ensure_pip_packages( + USER_ENV_PREFIX, + pypi_pkgs_to_upgrade, + upgrade=True, + ) # Install/upgrade the jupyterhub version in the user env based on the # version specification used for the hub env. From 39b1e1a7c718e882ecc34800402ce5a9f0c30dcd Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Jun 2023 15:33:36 +0200 Subject: [PATCH 189/232] preserve version pin when reinstalling conda avoids upgrade on older versions --- tljh/installer.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index ab7a460..aff24fc 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -244,11 +244,17 @@ def ensure_user_environment(user_requirements_txt_file): # force reinstall conda/mamba to ensure a basically consistent env # avoids issues with RemoveError: 'requests' is a dependency of conda - if not is_fresh_install: + # only do this for 'old' conda versions known to have a problem + # we don't know how old, but we know 4.10 is affected and 23.1 is not + if not is_fresh_install and V(package_versions.get("conda", "0")) < V("23.1"): # force-reinstall doesn't upgrade packages # it reinstalls them in-place - # only include packages already installed here - to_reinstall = {"conda", "mamba"} & set(package_versions) + # only reinstall packages already present + to_reinstall = [] + for pkg in ["conda", "mamba"]: + if pkg in package_versions: + # add version pin to avoid upgrades + to_reinstall.append(f"{pkg}=={package_versions[pkg]}") logger.info( f"Reinstalling {', '.join(to_reinstall)} to ensure a consistent environment" ) From 54bc5fb81b7349a17d76a676c8d619f94a9d1493 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 9 Jun 2023 17:08:12 +0200 Subject: [PATCH 190/232] test refactor: add comment about python/conda/mamba --- tests/test_installer.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 1d0699c..07081e4 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -108,6 +108,14 @@ def _specifier(version): @pytest.mark.parametrize( + # - distro: None, mambaforge, or miniforge + # - distro_version: https://github.com/conda-forge/miniforge/releases + # - expected_versions: versions of python, conda, and mamba in user env + # + # TLJH of a specific version comes with a specific distro_version as + # declared in installer.py's MAMBAFORGE_VERSION variable, and it comes with + # python, conda, and mamba of certain versions. + # "distro, distro_version, expected_versions", [ # No previous install, start fresh @@ -135,9 +143,9 @@ def _specifier(version): "mambaforge", "4.10.3-7", { + "python": "3.9.*", "conda": "4.10.3", "mamba": "0.16.0", - "python": "3.9.*", }, ), # simulate missing mamba @@ -147,9 +155,9 @@ def _specifier(version): "miniforge", "4.10.3-7", { + "python": "3.9.*", "conda": "4.10.3", "mamba": ">=1.1.0", - "python": "3.9.*", }, ), # too-old Python (3.7), abort From bc09121677a2b5ab3379af0e2dfd6d8c8e8d244c Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Tue, 13 Jun 2023 21:57:17 -0400 Subject: [PATCH 191/232] Add `JupyterLab` setting overrides docs --- docs/howto/index.md | 1 + docs/howto/user-env/override-lab-settings.md | 114 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 docs/howto/user-env/override-lab-settings.md diff --git a/docs/howto/index.md b/docs/howto/index.md index 208f14e..ea4135d 100644 --- a/docs/howto/index.md +++ b/docs/howto/index.md @@ -22,6 +22,7 @@ content/share-data user-env/user-environment user-env/notebook-interfaces user-env/server-resources +user-env/override-lab-settings ``` ## Authentication diff --git a/docs/howto/user-env/override-lab-settings.md b/docs/howto/user-env/override-lab-settings.md new file mode 100644 index 0000000..40fbb83 --- /dev/null +++ b/docs/howto/user-env/override-lab-settings.md @@ -0,0 +1,114 @@ +(topic-override-lab-settings)= + +# Overriding Default JupyterLab Settings + +If you or other users of your hub tend to use JupyterLab as your default notebook app, +then you may want to override some of the default settings for the users of your hub. +You can do this by creating a file `/opt/tljh/user/share/jupyter/lab/settings/overrides.json` +with the necessary settings. + +This how-to guide will go through the necessary steps to set new defaults +for all users of your `TLJH` by example: setting the default theme to **JupyterLab Dark**. + +## Step 1: Change your Personal Settings + +The easiest way to set new default settings for all users starts with +configuring your own settings preferences to what you would like everyone else to have. + +1. Make sure you are in the [JupyterLab notebook interface](#howto/user-env/notebook-interfaces), +which will look something like `http(s):///user/ JupyterLab Dark**. + +## Step 2: Determine your Personal Settings Configuration + +To set **JupyterLab Dark** as the default theme for all users, we will need to create +a `json` formatted file with the setting override. Now that you have changed your +personal setting, you can use the **JSON Settings Editor** to get the relevant +setting snippet to add to the `overrides.json` file later. + +1. Go to **Settings -> Advanced Settings Editor** then select **JSON Settings Editor** on the right. + +1. Scroll down and select **Theme**. You should see the `json` formatted configuration: + ```json + { + // Theme + // @jupyterlab/apputils-extension:themes + // Theme manager settings. + // ************************************* + + // Theme CSS Overrides + // Override theme CSS variables by setting key-value pairs here + "overrides": { + "code-font-family": null, + "code-font-size": null, + "content-font-family": null, + "content-font-size1": null, + "ui-font-family": null, + "ui-font-size1": null + }, + + // Selected Theme + // Application-level visual styling theme + "theme": "JupyterLab Dark", + + // Scrollbar Theming + // Enable/disable styling of the application scrollbars + "theme-scrollbars": false + } + ``` + +1. Determine the setting that you want to change. In this example it's the `theme` +setting of `@jupyterlab/apputils-extension:theme` as can be seen above. + +1. Build your `json` snippet. In this case, our snippet should look like this: + ```json + { + "@jupyterlab/apputils-extension:themes": { + "theme": "JupyterLab Dark" + } + } + ``` + We only want to change the **Selected Theme**, so we don't need to include + the other theme-related settings for CSS and the scrollbar. + + :::{note} + To apply overrides for more than one setting, separate each setting by commas. For example, + if you *also* wanted to change the interval at which the notebook autosaves your content, you can use + ```json + { + "@jupyterlab/apputils-extension:themes": { + "theme": "JupyterLab Dark" + }, + + "@jupyterlab/docmanager-extension:plugin": { + "autosaveInterval": 30 + } + } + ``` + ::: + +## Step 3: Apply the Overrides to the Hub + +Once you have your setting snippet created, you can add it to the `overrides.json` file +so that it gets applied to all users. + +1. First, create the settings directory if it doesn't already exist: + ```bash + sudo mkdir -p /opt/tljh/user/share/jupyter/lab/settings + ``` + +1. Use `nano` to create and add content to the `overrides.json` file: + ```bash + sudo nano /opt/tljh/user/share/jupyter/lab/settings/overrides.json + ``` + +1. Copy and paste your snippet into the file and save. + +1. Reload your configuration: + ```bash + sudo tljh-config reload + ``` + +The new default settings should now be set for all users in your `TLJH` using the +JupyterLab notebook interface. \ No newline at end of file From 3753a3c775c203430f2a209988dbcfc9a60b6739 Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Tue, 13 Jun 2023 22:05:55 -0400 Subject: [PATCH 192/232] Update heading 1 --- docs/howto/user-env/override-lab-settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/user-env/override-lab-settings.md b/docs/howto/user-env/override-lab-settings.md index 40fbb83..707ba94 100644 --- a/docs/howto/user-env/override-lab-settings.md +++ b/docs/howto/user-env/override-lab-settings.md @@ -1,6 +1,6 @@ (topic-override-lab-settings)= -# Overriding Default JupyterLab Settings +# Setting New Default JupyterLab Settings If you or other users of your hub tend to use JupyterLab as your default notebook app, then you may want to override some of the default settings for the users of your hub. From 259d2ff11d57d7a01ef3d4d8a69f12a1635f17af Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 14 Jun 2023 02:10:29 +0000 Subject: [PATCH 193/232] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/howto/user-env/override-lab-settings.md | 107 ++++++++++--------- 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/docs/howto/user-env/override-lab-settings.md b/docs/howto/user-env/override-lab-settings.md index 707ba94..4f924a5 100644 --- a/docs/howto/user-env/override-lab-settings.md +++ b/docs/howto/user-env/override-lab-settings.md @@ -2,91 +2,94 @@ # Setting New Default JupyterLab Settings -If you or other users of your hub tend to use JupyterLab as your default notebook app, -then you may want to override some of the default settings for the users of your hub. +If you or other users of your hub tend to use JupyterLab as your default notebook app, +then you may want to override some of the default settings for the users of your hub. You can do this by creating a file `/opt/tljh/user/share/jupyter/lab/settings/overrides.json` with the necessary settings. -This how-to guide will go through the necessary steps to set new defaults +This how-to guide will go through the necessary steps to set new defaults for all users of your `TLJH` by example: setting the default theme to **JupyterLab Dark**. ## Step 1: Change your Personal Settings -The easiest way to set new default settings for all users starts with -configuring your own settings preferences to what you would like everyone else to have. +The easiest way to set new default settings for all users starts with +configuring your own settings preferences to what you would like everyone else to have. -1. Make sure you are in the [JupyterLab notebook interface](#howto/user-env/notebook-interfaces), -which will look something like `http(s):///user//user/ JupyterLab Dark**. ## Step 2: Determine your Personal Settings Configuration -To set **JupyterLab Dark** as the default theme for all users, we will need to create -a `json` formatted file with the setting override. Now that you have changed your +To set **JupyterLab Dark** as the default theme for all users, we will need to create +a `json` formatted file with the setting override. Now that you have changed your personal setting, you can use the **JSON Settings Editor** to get the relevant setting snippet to add to the `overrides.json` file later. 1. Go to **Settings -> Advanced Settings Editor** then select **JSON Settings Editor** on the right. 1. Scroll down and select **Theme**. You should see the `json` formatted configuration: + ```json { - // Theme - // @jupyterlab/apputils-extension:themes - // Theme manager settings. - // ************************************* + // Theme + // @jupyterlab/apputils-extension:themes + // Theme manager settings. + // ************************************* - // Theme CSS Overrides - // Override theme CSS variables by setting key-value pairs here - "overrides": { - "code-font-family": null, - "code-font-size": null, - "content-font-family": null, - "content-font-size1": null, - "ui-font-family": null, - "ui-font-size1": null - }, + // Theme CSS Overrides + // Override theme CSS variables by setting key-value pairs here + "overrides": { + "code-font-family": null, + "code-font-size": null, + "content-font-family": null, + "content-font-size1": null, + "ui-font-family": null, + "ui-font-size1": null + }, - // Selected Theme - // Application-level visual styling theme - "theme": "JupyterLab Dark", + // Selected Theme + // Application-level visual styling theme + "theme": "JupyterLab Dark", - // Scrollbar Theming - // Enable/disable styling of the application scrollbars - "theme-scrollbars": false + // Scrollbar Theming + // Enable/disable styling of the application scrollbars + "theme-scrollbars": false } ``` -1. Determine the setting that you want to change. In this example it's the `theme` -setting of `@jupyterlab/apputils-extension:theme` as can be seen above. +1. Determine the setting that you want to change. In this example it's the `theme` + setting of `@jupyterlab/apputils-extension:theme` as can be seen above. 1. Build your `json` snippet. In this case, our snippet should look like this: ```json - { - "@jupyterlab/apputils-extension:themes": { - "theme": "JupyterLab Dark" - } + { + "@jupyterlab/apputils-extension:themes": { + "theme": "JupyterLab Dark" + } } ``` We only want to change the **Selected Theme**, so we don't need to include the other theme-related settings for CSS and the scrollbar. - :::{note} - To apply overrides for more than one setting, separate each setting by commas. For example, - if you *also* wanted to change the interval at which the notebook autosaves your content, you can use - ```json - { - "@jupyterlab/apputils-extension:themes": { - "theme": "JupyterLab Dark" - }, - - "@jupyterlab/docmanager-extension:plugin": { - "autosaveInterval": 30 - } +:::{note} +To apply overrides for more than one setting, separate each setting by commas. For example, +if you _also_ wanted to change the interval at which the notebook autosaves your content, you can use + +```json +{ + "@jupyterlab/apputils-extension:themes": { + "theme": "JupyterLab Dark" + }, + + "@jupyterlab/docmanager-extension:plugin": { + "autosaveInterval": 30 } - ``` - ::: +} +``` + +::: ## Step 3: Apply the Overrides to the Hub @@ -94,16 +97,18 @@ Once you have your setting snippet created, you can add it to the `overrides.jso so that it gets applied to all users. 1. First, create the settings directory if it doesn't already exist: + ```bash sudo mkdir -p /opt/tljh/user/share/jupyter/lab/settings ``` 1. Use `nano` to create and add content to the `overrides.json` file: + ```bash sudo nano /opt/tljh/user/share/jupyter/lab/settings/overrides.json ``` -1. Copy and paste your snippet into the file and save. +1. Copy and paste your snippet into the file and save. 1. Reload your configuration: ```bash @@ -111,4 +116,4 @@ so that it gets applied to all users. ``` The new default settings should now be set for all users in your `TLJH` using the -JupyterLab notebook interface. \ No newline at end of file +JupyterLab notebook interface. From d20344cb1ddedca05e5fa69f1fbb9c0a8fc0537b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Jul 2023 06:28:38 +0000 Subject: [PATCH 194/232] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.4.0 → v3.8.0](https://github.com/asottile/pyupgrade/compare/v3.4.0...v3.8.0) - [github.com/PyCQA/autoflake: v2.1.1 → v2.2.0](https://github.com/PyCQA/autoflake/compare/v2.1.1...v2.2.0) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f62bd40..9464b41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 + rev: v3.8.0 hooks: - id: pyupgrade args: @@ -22,7 +22,7 @@ repos: # Autoformat: Python code - repo: https://github.com/PyCQA/autoflake - rev: v2.1.1 + rev: v2.2.0 hooks: - id: autoflake # args ref: https://github.com/PyCQA/autoflake#advanced-usage From 831a368b5aeb4979ee27a19ca4ace55839476f24 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 5 Jul 2023 16:01:18 +0200 Subject: [PATCH 195/232] breaking: update oauthenticator from 15.1.0 to >=16.0.2,<17 --- tljh/requirements-hub-env.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt index 9b6fada..e1c9273 100644 --- a/tljh/requirements-hub-env.txt +++ b/tljh/requirements-hub-env.txt @@ -14,7 +14,7 @@ jupyterhub-firstuseauthenticator>=1.0.0,<2 jupyterhub-nativeauthenticator>=1.2.0,<2 jupyterhub-ldapauthenticator>=1.3.2,<2 jupyterhub-tmpauthenticator>=1.0.0,<2 -oauthenticator>=15.1.0,<16 +oauthenticator>=16.0.2,<17 jupyterhub-idle-culler>=1.2.1,<2 # pycurl is installed to improve reliability and performance for when JupyterHub From c6fed712d419926f68564acfdd8c19ca32f8b269 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 5 Jul 2023 22:13:00 +0200 Subject: [PATCH 196/232] docs: add warning to authenticator docs, linking to official docs --- docs/howto/auth/awscognito.md | 12 ++++++++++++ docs/howto/auth/dummy.md | 2 ++ docs/howto/auth/firstuse.md | 12 ++++++++++++ docs/howto/auth/github.md | 12 ++++++++++++ docs/howto/auth/google.md | 12 ++++++++++++ docs/howto/auth/nativeauth.md | 12 ++++++++++++ 6 files changed, 62 insertions(+) diff --git a/docs/howto/auth/awscognito.md b/docs/howto/auth/awscognito.md index bf83116..3827189 100644 --- a/docs/howto/auth/awscognito.md +++ b/docs/howto/auth/awscognito.md @@ -2,6 +2,18 @@ # Authenticate using AWS Cognito +```{warning} +This documentation has not been updated recently, and a major version of +OAuthenticator has been released since it was. Due to that, please only use this +_as a complement_ to the official [OAuthenticator documentation]. + +[OAuthenticator documentation]: https://oauthenticator.readthedocs.io/en/latest/tutorials/provider-specific-setup/providers/generic.html#setup-for-aws-cognito + +Going onwards, the goal is to ensure we have good documentation in the +OAuthenticator project and reference that instead of maintaining similar +documentation in this project also. +``` + The **AWS Cognito Authenticator** lets users log into your JupyterHub using cognito user pools. To do so, you'll first need to register and configure a cognito user pool and app, and then provide information about this diff --git a/docs/howto/auth/dummy.md b/docs/howto/auth/dummy.md index b9ee2bc..99bc530 100644 --- a/docs/howto/auth/dummy.md +++ b/docs/howto/auth/dummy.md @@ -2,9 +2,11 @@ # Authenticate _any_ user with a single shared password +```{warning} The **Dummy Authenticator** lets _any_ user log in with the given password. This authenticator is **extremely insecure**, so do not use it if you can avoid it. +``` ## Enabling the authenticator diff --git a/docs/howto/auth/firstuse.md b/docs/howto/auth/firstuse.md index edbf958..3bd05d4 100644 --- a/docs/howto/auth/firstuse.md +++ b/docs/howto/auth/firstuse.md @@ -2,6 +2,18 @@ # Let users choose a password when they first log in +```{warning} +This documentation is not being updated regularly and may be out of date. Due to +that, please only use this _as a complement_ to the official +[FirstUseAuthenticator documentation]. + +[FirstUseAuthenticator documentation]: https://github.com/jupyterhub/firstuseauthenticator#readme + +Going onwards, the goal is to ensure we have good documentation in the +FirstUseAuthenticator project and reference that instead of maintaining similar +documentation in this project also. +``` + The **First Use Authenticator** lets users choose their own password. Upon their first log-in attempt, whatever password they use will be stored as their password for subsequent log in attempts. This is diff --git a/docs/howto/auth/github.md b/docs/howto/auth/github.md index a1ce701..47fed0a 100644 --- a/docs/howto/auth/github.md +++ b/docs/howto/auth/github.md @@ -2,6 +2,18 @@ # Authenticate using GitHub Usernames +```{warning} +This documentation has not been updated recently, and a major version of +OAuthenticator has been released since it was. Due to that, please only use this +_as a complement_ to the official [OAuthenticator documentation]. + +[OAuthenticator documentation]: https://oauthenticator.readthedocs.io/en/latest/tutorials/provider-specific-setup/providers/github.html + +Going onwards, the goal is to ensure we have good documentation in the +OAuthenticator project and reference that instead of maintaining similar +documentation in this project also. +``` + The **GitHub Authenticator** lets users log into your JupyterHub using their GitHub user ID / password. To do so, you'll first need to register an application with GitHub, and then provide information about this diff --git a/docs/howto/auth/google.md b/docs/howto/auth/google.md index bba1f0c..2d74893 100644 --- a/docs/howto/auth/google.md +++ b/docs/howto/auth/google.md @@ -2,6 +2,18 @@ # Authenticate using Google +```{warning} +This documentation has not been updated recently, and a major version of +OAuthenticator has been released since it was. Due to that, please only use this +_as a complement_ to the official [OAuthenticator documentation]. + +[OAuthenticator documentation]: https://oauthenticator.readthedocs.io/en/latest/tutorials/provider-specific-setup/providers/google.html + +Going onwards, the goal is to ensure we have good documentation in the +OAuthenticator project and reference that instead of maintaining similar +documentation in this project also. +``` + The **Google OAuthenticator** 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 diff --git a/docs/howto/auth/nativeauth.md b/docs/howto/auth/nativeauth.md index 92f3a03..370e2e2 100644 --- a/docs/howto/auth/nativeauth.md +++ b/docs/howto/auth/nativeauth.md @@ -2,6 +2,18 @@ # Let users sign up with a username and password +```{warning} +This documentation is not being updated regularly and may be out of date. Due to +that, please only use this _as a complement_ to the official +[NativeAuthenticator documentation]. + +[NativeAuthenticator documentation]: https://native-authenticator.readthedocs.io/en/latest/ + +Going onwards, the goal is to ensure we have good documentation in the +NativeAuthenticator project and reference that instead of maintaining similar +documentation in this project also. +``` + The **Native Authenticator** lets users signup for creating a new username and password. When they signup, they won't be able to login until they are authorized by an From 51c501b9f125e23476c3936f1eee3c75ab7c81f0 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 13 May 2023 20:12:32 +0200 Subject: [PATCH 197/232] Add changelog for 1.0.0b1 --- changelog.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 94b38fd..c5264bb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,8 +1,164 @@ -# 0.2.0 - 2023-02-27 +# Changelog + +## 1.0 + +### 1.0.0b1 - 2023-07-06 + +````{warning} +This is a beta release. + +To upgrade to it, after having read the breaking changes below, you can do the +following from a terminal on a machine TLJH is installed. + +```shell +# This should NOT be run from a JupyterHub started user server, but should +# run from a standalone terminal session in the machine where TLJH has been +# installed. +curl -L https://tljh.jupyter.org/bootstrap.py \ + | sudo python3 - \ + --version=1.0.0b1 +``` +```` + +This release bundles with the latest available software from the JupyterHub +ecosystem. + +The TLJH project now has tests to verify upgrades of installations between +releases and procedures with automation to make releases. Going onwards, TLJH +installations of version 0.2.0 and later are meant to be easy to upgrade. + +#### Breaking changes + +- JupyterHub 1.\* has been upgraded to >=4.0.1,<5 + - This upgrade requires user servers to be restarted if they were running + during the upgrade. + - Refer to the [JupyterHub changelog] for details where you pay attention to + the entries for JupyterHub version 2.0.0, 3.0.0, and 4.0.0. +- Several JupyterHub Authenticators has been upgraded a major version, inspect + the changelog for the authenticator class your installation makes use of. For + links to the changelogs, see the section below. +- The configured JupyterHub Proxy class `traefik-proxy` and the `traefik` server + controlled by JupyterHub via the proxy class has been upgraded to a new major + version, but no breaking change are expected to be noticed for users of this + distribution. +- The configured JupyterHub Spawner class `jupyterhub-systemdspawner` has been + upgraded to a new major version, but no breaking change are expected to be + noticed for users of this distribution. + +[jupyterhub changelog]: https://jupyterhub.readthedocs.io/en/stable/changelog.html + +#### Notable dependencies updated + +A TLJH installation provides a Python environment where the software for +JupyterHub itself runs - _the hub environment_, and a Python environment where the +software of users runs - _the user environment_. + +If you are installing TLJH for the first time, the user environment will be +setup initially with Python 3.10 and some other packages described in +[tljh/requirements-user-env-extras.txt]. + +If you are upgrading to this version of TLJH, the bare minimum is changed in the +user environment. The hub environment's dependencies are on the other hand +always upgraded to the latest version within the specified version range defined +in [tljh/requirements-hub-env.txt] and seen below. + +[tljh/requirements-user-env-extras.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/1.0.0b1/tljh/requirements-user-env-extras.txt +[tljh/requirements-hub-env.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/1.0.0b1/tljh/requirements-hub-env.txt + +The changes in the respective environments between TLJH version 0.2.0 and +1.0.0b1 are summarized below. + +| Dependency changes in the _hub environment_ | Version in 0.2.0 | Version in 1.0.0b1 | Changelog link | Note | +| ------------------------------------------------------------------------------ | ---------------- | ------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| [jupyterhub](https://github.com/jupyterhub/jupyterhub) | 1.\* | >=4.0.1,<5 | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html) | Running in the `jupyterhub` systemd service | +| [traefik](https://github.com/traefik/traefik) | 1.7.33 | 2.10.1 | [Changelog](https://github.com/traefik/traefik/blob/master/CHANGELOG.md) | Running in the `traefik` systemd service | +| [traefik-proxy](https://github.com/jupyterhub/traefik-proxy) | 0.3.\* | >=1.1.0,<2 | [Changelog](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/changelog.html) | Run by jupyterhub, controls `traefik` | +| [systemdspawner](https://github.com/jupyterhub/systemdspawner) | 0.16.\* | >=1.0.1,<2 | [Changelog](https://github.com/jupyterhub/systemdspawner/blob/master/CHANGELOG.md) | Run by jupyterhub, controls user servers via systemd | +| [jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler) | 1.\* | >=1.2.1,<2 | [Changelog](https://github.com/jupyterhub/jupyterhub-idle-culler/blob/main/CHANGELOG.md) | Run by jupyterhub, stops inactivate servers etc. | +| [firstuseauthenticator](https://github.com/jupyterhub/firstuseauthenticator) | 1.\* | >=1.0.0,<2 | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html) | An optional way to authenticate users | +| [tmpauthenticator](https://github.com/jupyterhub/tmpauthenticator) | 0.6.\* | >=1.0.0,<2 | [Changelog](https://github.com/jupyterhub/tmpauthenticator/blob/HEAD/CHANGELOG.md) | An optional way to authenticate users | +| [nativeauthenticator](https://github.com/jupyterhub/nativeauthenticator) | 1.\* | >=1.2.0,<2 | [Changelog](https://github.com/jupyterhub/nativeauthenticator/blob/HEAD/CHANGELOG.md) | An optional way to authenticate users | +| [oauthenticator](https://github.com/jupyterhub/oauthenticator) | 14.\* | >=16.0.1,<17 | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html) | An optional way to authenticate users | +| [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator) | 1.\* | >=1.3.2,<2 | [Changelog](https://github.com/jupyterhub/ldapauthenticator/blob/HEAD/CHANGELOG.md) | An optional way to authenticate users | +| [pip](https://github.com/pypa/pip) | 21.3.\* | >=23.1.2 | [Changelog](https://pip.pypa.io/en/stable/news/) | - | + +| Dependency changes in the _user environment_ | Version in 0.2.0 | Version in 1.0.0 | Changelog link | Note | +| -------------------------------------------------------- | ---------------- | ---------------- | --------------------------------------------------------------------------------- | ------------------------ | +| [jupyterhub](https://github.com/jupyterhub/jupyterhub) | 1.\* | >=4.0.1,<5 | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html) | Always upgraded. | +| [pip](https://github.com/pypa/pip) | \* | >=23.1.2 | [Changelog](https://pip.pypa.io/en/stable/news/) | Only upgraded if needed. | +| [conda](https://docs.conda.io/projects/conda/en/stable/) | 0.16.0 | >=0.16.0 | [Changelog](https://docs.conda.io/projects/conda/en/stable/release-notes.html) | Only upgraded if needed. | +| [mamba](https://mamba.readthedocs.io/en/latest/) | 4.10.3 | >=4.10.0 | [Changelog](https://github.com/mamba-org/mamba/blob/main/CHANGELOG.md) | Only upgraded if needed. | + +#### New features added + +- Add http[s].address config to control where traefik listens [#905](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/905) ([@nsurleraux-railnova](https://github.com/nsurleraux-railnova), [@minrk](https://github.com/minrk)) +- Add support for debian >=10 to bootstrap.py [#800](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/800) ([@jochym](https://github.com/jochym), [@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@yuvipanda](https://github.com/yuvipanda)) + +#### Enhancements made + +- added `remove_named_servers` setting for jupyterhub-idle-culler [#881](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/881) ([@consideRatio](https://github.com/consideRatio)) +- Traefik v2, TraefikProxy v1 [#861](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/861) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@MridulS](https://github.com/MridulS)) + +#### Maintenance and upkeep improvements + +- breaking: update oauthenticator from 15.1.0 to >=16.0.1,<17, make tljh auth docs link out [#924](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/924) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) +- test refactor: add comment about python/conda/mamba [#921](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/921) ([@consideRatio](https://github.com/consideRatio)) +- --force-reinstall old conda to ensure it's working before we try to install conda packages [#920](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/920) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) +- test refactor: put bootstrap tests in an isolated job, save ~3 min in each of the integration test jobs [#919](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/919) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- maint: refactor tests, fix upgrade tests (now correctly failing) [#916](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/916) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Update systemdspawner from version 0.17.\* to >=1.0.1,<2 [#915](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/915) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics)) +- Fix recently introduced failure to upper bound systemdspawner [#914](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/914) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Stop bundling jupyterhub-configurator which has been disabled by default [#912](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/912) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena), [@yuvipanda](https://github.com/yuvipanda)) +- Update nativeauthenticator, tmpauthenticator, and jupyterhub-configurator [#900](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/900) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- ensure hub env is on $PATH in jupyterhub service [#895](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/895) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics)) +- pre-commit: add isort and autoflake [#893](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/893) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Upgrade pip in hub env from 21.3 to to 23.1 when bootstrap script runs [#892](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/892) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- pre-commit.ci configured to update pre-commit hooks on a monthly basis [#891](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/891) ([@consideRatio](https://github.com/consideRatio)) +- Only upgrade jupyterhub in user env when upgrading tljh, ensure pip>=23.1.2 in user env [#890](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/890) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) +- add integration test for hub version [#886](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/886) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) +- update: jupyterhub 4 [#880](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/880) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- maint: add upgrade test from main branch, latest release, and 0.2.0 [#876](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/876) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- dependabot: monthly updates of github actions [#871](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/871) ([@consideRatio](https://github.com/consideRatio)) +- maint: remove deprecated nteract-on-jupyter [#869](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/869) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) +- avoid registering duplicate log handlers [#862](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/862) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) +- bump version to 1.0.0.dev0 [#859](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/859) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics)) +- Update base user environment to mambaforge 23.1.0-1 (Python 3.10) [#858](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/858) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics)) +- require ubuntu 20.04, test on debian 11, require Python 3.8 [#856](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/856) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics)) +- update: jupyterhub 3, oauthenticator 15, systemdspawner 0.17 (user env: ipywidgets 8) [#842](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/842) ([@yuvipanda](https://github.com/yuvipanda), [@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- Release 0.2.0 (JupyterHub 1.\*) [#838](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/838) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) + +#### Documentation improvements + +- Quote `pwd` to prevent error if dir has spaces [#917](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/917) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio)) +- Google Cloud troubleshooting and configuration updates [#906](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/906) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio)) +- Add user env doc files [#902](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/902) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio)) +- Update Google auth docs [#898](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/898) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio)) +- docs: disable navigation with arrow keys [#896](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/896) ([@MridulS](https://github.com/MridulS), [@consideRatio](https://github.com/consideRatio)) +- docs(awscognito): add custom claims example [#887](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/887) ([@consideRatio](https://github.com/consideRatio)) +- Docs: Update DigitalOcean install instructions with new screenshot for "user data" [#883](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/883) ([@audiodude](https://github.com/audiodude), [@consideRatio](https://github.com/consideRatio)) +- Typo : username -> admin-user-name [#879](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/879) ([@Rom1deTroyes](https://github.com/Rom1deTroyes), [@consideRatio](https://github.com/consideRatio)) +- docs: fix readme badge for tests [#878](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/878) ([@consideRatio](https://github.com/consideRatio)) +- docs: fix remaining issues following rst to myst transition [#870](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/870) ([@consideRatio](https://github.com/consideRatio)) +- docs: transition from rst to myst markdown using rst2myst [#863](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/863) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@jrdnbradford](https://github.com/jrdnbradford)) +- Typo in user-environment.rst [#849](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/849) ([@jawiv](https://github.com/jawiv), [@minrk](https://github.com/minrk)) +- Recommend Ubuntu 22.04 in docs [#843](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/843) ([@adonm](https://github.com/adonm), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/the-littlest-jupyterhub/graphs/contributors?from=2023-02-27&to=2023-06-09&type=c)) + +@adonm ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aadonm+updated%3A2023-02-27..2023-06-09&type=Issues)) | @audiodude ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaudiodude+updated%3A2023-02-27..2023-06-09&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AconsideRatio+updated%3A2023-02-27..2023-06-09&type=Issues)) | @eingemaischt ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aeingemaischt+updated%3A2023-02-27..2023-06-09&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGeorgianaElena+updated%3A2023-02-27..2023-06-09&type=Issues)) | @Hannnsen ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AHannnsen+updated%3A2023-02-27..2023-06-09&type=Issues)) | @jawiv ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajawiv+updated%3A2023-02-27..2023-06-09&type=Issues)) | @jochym ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajochym+updated%3A2023-02-27..2023-06-09&type=Issues)) | @jrdnbradford ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajrdnbradford+updated%3A2023-02-27..2023-06-09&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amanics+updated%3A2023-02-27..2023-06-09&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aminrk+updated%3A2023-02-27..2023-06-09&type=Issues)) | @MridulS ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMridulS+updated%3A2023-02-27..2023-06-09&type=Issues)) | @nsurleraux-railnova ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ansurleraux-railnova+updated%3A2023-02-27..2023-06-09&type=Issues)) | @Rom1deTroyes ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ARom1deTroyes+updated%3A2023-02-27..2023-06-09&type=Issues)) | @wjcapehart ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awjcapehart+updated%3A2023-02-27..2023-06-09&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ayuvipanda+updated%3A2023-02-27..2023-06-09&type=Issues)) + +## 0.2.0 + +### 0.2.0 - 2023-02-27 ([full changelog](https://github.com/jupyterhub/the-littlest-jupyterhub/compare/4a74ad17a1a19f6378efe12a01ba634ed90f1e03...0.2.0)) -## Merged PRs +#### Merged PRs - Fix broken CI [#851](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/851) ([@pnasrat](https://github.com/pnasrat)) - Ensure SQLAlchemy 1.x used for hub [#848](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/848) ([@pnasrat](https://github.com/pnasrat)) From 0ce328555ccf4dc1563981d2496e91853f2a1761 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 6 Jul 2023 09:54:03 +0200 Subject: [PATCH 198/232] docs: put changelog in tljh's built documentation under a new reference section --- CHANGELOG.md | 1 + docs/index.md | 12 ++++++++++++ changelog.md => docs/reference/changelog.md | 0 docs/reference/index.md | 10 ++++++++++ 4 files changed, 23 insertions(+) create mode 100644 CHANGELOG.md rename changelog.md => docs/reference/changelog.md (100%) create mode 100644 docs/reference/index.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c49abc5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +The changelog now lives in [TLJH's documentation](https://tljh.jupyter.org/en/stable/reference/changelog.html). diff --git a/docs/index.md b/docs/index.md index dddc39e..fe8dcf7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,6 +53,18 @@ Topic guides provide in-depth explanations of specific topics. topic/index ``` +## Reference + +The reference documentation is meant to provide narrowly scoped technical +descriptions that other documentation can link to for details. + +```{toctree} +:maxdepth: 2 +:titlesonly: true + +reference/index +``` + ## Troubleshooting In time, all systems have issues that need to be debugged. Troubleshooting diff --git a/changelog.md b/docs/reference/changelog.md similarity index 100% rename from changelog.md rename to docs/reference/changelog.md diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..f5dba5d --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,10 @@ +# Reference + +The reference documentation is meant to provide narrowly scoped technical +descriptions that other documentation can link to for details. + +```{toctree} +:titlesonly: true + +changelog +``` From bdd0b3124e37fabd8453f95aaba16c847a66221c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 6 Jul 2023 21:32:05 +0200 Subject: [PATCH 199/232] Bump oauthenticator to 16.0.2 and update 1.0.0b1 release date --- docs/reference/changelog.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index c5264bb..cf8dbf0 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -2,7 +2,7 @@ ## 1.0 -### 1.0.0b1 - 2023-07-06 +### 1.0.0b1 - 2023-07-07 ````{warning} This is a beta release. @@ -78,7 +78,7 @@ The changes in the respective environments between TLJH version 0.2.0 and | [firstuseauthenticator](https://github.com/jupyterhub/firstuseauthenticator) | 1.\* | >=1.0.0,<2 | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html) | An optional way to authenticate users | | [tmpauthenticator](https://github.com/jupyterhub/tmpauthenticator) | 0.6.\* | >=1.0.0,<2 | [Changelog](https://github.com/jupyterhub/tmpauthenticator/blob/HEAD/CHANGELOG.md) | An optional way to authenticate users | | [nativeauthenticator](https://github.com/jupyterhub/nativeauthenticator) | 1.\* | >=1.2.0,<2 | [Changelog](https://github.com/jupyterhub/nativeauthenticator/blob/HEAD/CHANGELOG.md) | An optional way to authenticate users | -| [oauthenticator](https://github.com/jupyterhub/oauthenticator) | 14.\* | >=16.0.1,<17 | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html) | An optional way to authenticate users | +| [oauthenticator](https://github.com/jupyterhub/oauthenticator) | 14.\* | >=16.0.2,<17 | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html) | An optional way to authenticate users | | [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator) | 1.\* | >=1.3.2,<2 | [Changelog](https://github.com/jupyterhub/ldapauthenticator/blob/HEAD/CHANGELOG.md) | An optional way to authenticate users | | [pip](https://github.com/pypa/pip) | 21.3.\* | >=23.1.2 | [Changelog](https://pip.pypa.io/en/stable/news/) | - | @@ -101,7 +101,7 @@ The changes in the respective environments between TLJH version 0.2.0 and #### Maintenance and upkeep improvements -- breaking: update oauthenticator from 15.1.0 to >=16.0.1,<17, make tljh auth docs link out [#924](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/924) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) +- breaking: update oauthenticator from 15.1.0 to >=16.0.2,<17, make tljh auth docs link out [#924](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/924) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) - test refactor: add comment about python/conda/mamba [#921](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/921) ([@consideRatio](https://github.com/consideRatio)) - --force-reinstall old conda to ensure it's working before we try to install conda packages [#920](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/920) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) - test refactor: put bootstrap tests in an isolated job, save ~3 min in each of the integration test jobs [#919](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/919) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) From b76db83c365c44183edc20a4ae65aee96bc764b1 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 7 Jul 2023 18:28:55 +0200 Subject: [PATCH 200/232] Add tbump config --- pyproject.toml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d256f58..d675e0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,3 +56,32 @@ omit = [ "tests/**", "integration-tests/**", ] + + +# tbump is used to simplify and standardize the release process when updating +# the version, making a git commit and tag, and pushing changes. +# +# ref: https://github.com/your-tools/tbump#readme +# +[tool.tbump] +github_url = "https://github.com/jupyterhub/the-littlest-jupyterhub" + +[tool.tbump.version] +current = "1.0.0.dev0" +regex = ''' + (?P\d+) + \. + (?P\d+) + \. + (?P\d+) + (?P
((a|b|rc)\d+)|)
+    \.?
+    (?P(?<=\.)dev\d*|)
+'''
+
+[tool.tbump.git]
+message_template = "Bump to {new_version}"
+tag_template = "{new_version}"
+
+[[tool.tbump.file]]
+src = "setup.py"

From 3ea72c8a85ca89d73864a5abdc9ed8dba073c354 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Fri, 7 Jul 2023 18:33:24 +0200
Subject: [PATCH 201/232] Bump to 1.0.0b1

---
 pyproject.toml | 2 +-
 setup.py       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index d675e0d..e8f2db9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,7 +67,7 @@ omit = [
 github_url = "https://github.com/jupyterhub/the-littlest-jupyterhub"
 
 [tool.tbump.version]
-current = "1.0.0.dev0"
+current = "1.0.0b1"
 regex = '''
     (?P\d+)
     \.
diff --git a/setup.py b/setup.py
index 7ba44c7..0ff550d 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
 
 setup(
     name="the-littlest-jupyterhub",
-    version="1.0.0.dev0",
+    version="1.0.0b1",
     description="A small JupyterHub distribution",
     url="https://github.com/jupyterhub/the-littlest-jupyterhub",
     author="Jupyter Development Team",

From 86a4bb67c87f977cd693fbf521c164f0cdbc7d40 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 06:12:05 +0000
Subject: [PATCH 202/232] Update Notebook, JupyterLab, Jupyter Resource Usage

---
 tljh/requirements-user-env-extras.txt | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tljh/requirements-user-env-extras.txt b/tljh/requirements-user-env-extras.txt
index 7f27c70..020779b 100644
--- a/tljh/requirements-user-env-extras.txt
+++ b/tljh/requirements-user-env-extras.txt
@@ -8,11 +8,11 @@
 #          the requirements-txt-fixer pre-commit hook that sorted them and made
 #          our integration tests fail.
 #
-notebook==6.*
-jupyterlab==3.*
+notebook==7.*
+jupyterlab==4.*
 # nbgitpuller for easily pulling in Git repositories
 nbgitpuller==1.*
 # jupyter-resource-usage to show people how much RAM they are using
-jupyter-resource-usage==0.7.*
+jupyter-resource-usage==1.*
 # Most people consider ipywidgets to be part of the core notebook experience
 ipywidgets==8.*

From bdfabc5ce8886716a485087b8c3a05729131b93f Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 06:26:18 +0000
Subject: [PATCH 203/232] Update tests

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 6ed0a9d..af87832 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -12,7 +12,7 @@ def test_serverextensions():
     )
 
     extensions = [
-        "jupyterlab 3.",
+        "jupyterlab 4.",
         "nbgitpuller 1.",
         "jupyter_resource_usage",
     ]

From b6ee97c77023bec7acb503309fde65467f6e3d84 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 06:28:39 +0000
Subject: [PATCH 204/232] do not check the lab version

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index af87832..2fb22a6 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -12,7 +12,7 @@ def test_serverextensions():
     )
 
     extensions = [
-        "jupyterlab 4.",
+        "jupyterlab",
         "nbgitpuller 1.",
         "jupyter_resource_usage",
     ]

From 6f38ec6a959c22dedc4aad66442183c80f4c50c4 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 06:43:49 +0000
Subject: [PATCH 205/232] test for lab extensions

---
 integration-tests/test_extensions.py | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 2fb22a6..676fb89 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -21,21 +21,21 @@ def test_serverextensions():
         assert e in proc.stderr.decode()
 
 
-def test_nbextensions():
+def test_labextensions():
     """
-    Validate nbextensions we want are installed & enabled
+    Validate JupyterLab extensions we want are installed & enabled
     """
-    # jupyter-nbextension writes to stdout and stderr weirdly
+    # jupyter-labextension writes to stdout and stderr weirdly
     proc = subprocess.run(
-        ["/opt/tljh/user/bin/jupyter-nbextension", "list", "--sys-prefix"],
+        ["/opt/tljh/user/bin/jupyter-labextension", "list"],
         stderr=subprocess.PIPE,
         stdout=subprocess.PIPE,
     )
 
     extensions = [
-        "jupyter_resource_usage/main",
-        # This is what ipywidgets nbextension is called
-        "jupyter-js-widgets/extension",
+        "@jupyter-server/resource-usage",
+        # This is what ipywidgets lab extension is called
+        "@jupyter-widgets/jupyterlab-manager",
     ]
 
     for e in extensions:

From ad1455e4da7b874163f79d13fd4678e510b1ccaa Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 06:52:56 +0000
Subject: [PATCH 206/232] update jupyter server command for now

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 676fb89..2d3f097 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -7,7 +7,7 @@ def test_serverextensions():
     """
     # jupyter-serverextension writes to stdout and stderr weirdly
     proc = subprocess.run(
-        ["/opt/tljh/user/bin/jupyter-serverextension", "list", "--sys-prefix"],
+        ["/opt/tljh/user/bin/jupyter-server", "extension", "list", "--sys-prefix"],
         stderr=subprocess.PIPE,
     )
 

From 4b13303d01dc24bd1e9f1374c46de24ee44d8380 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 07:01:41 +0000
Subject: [PATCH 207/232] test nbgitpuller only

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 2d3f097..4e73d2b 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -13,7 +13,7 @@ def test_serverextensions():
 
     extensions = [
         "jupyterlab",
-        "nbgitpuller 1.",
+        "nbgitpuller",
         "jupyter_resource_usage",
     ]
 

From dfeddc2e530110e4d660069b1a77d0a492050806 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 07:01:58 +0000
Subject: [PATCH 208/232] tmp: do not check nbgitpuller

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 4e73d2b..26d4f03 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -13,7 +13,7 @@ def test_serverextensions():
 
     extensions = [
         "jupyterlab",
-        "nbgitpuller",
+        # "nbgitpuller",
         "jupyter_resource_usage",
     ]
 

From 645251a134fe7764f59bc592af0c6ae3fadb118c Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 07:38:32 +0000
Subject: [PATCH 209/232] update test for labextensions

---
 integration-tests/test_extensions.py | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 26d4f03..bdd9e60 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -39,9 +39,8 @@ def test_labextensions():
     ]
 
     for e in extensions:
-        assert f"{e} \x1b[32m enabled \x1b[0m" in proc.stdout.decode()
-
-    # Ensure we have 'OK' messages in our stdout, to make sure everything is importable
-    assert proc.stderr.decode() == "      - Validating: \x1b[32mOK\x1b[0m\n" * len(
-        extensions
-    )
+        # jupyter labextension lists outputs to stderr
+        out = proc.stderr.decode()
+        enabled_ok_pattern = re.compile(fr"{e}.*enabled.*OK")
+        matches = enabled_ok_pattern.search(proc.stderr.decode())
+        assert matches is not None

From 91af54ade31823da7fab1e49594141cd35ec7a6d Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 07:38:54 +0000
Subject: [PATCH 210/232] typo

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index bdd9e60..fdc1653 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -42,5 +42,5 @@ def test_labextensions():
         # jupyter labextension lists outputs to stderr
         out = proc.stderr.decode()
         enabled_ok_pattern = re.compile(fr"{e}.*enabled.*OK")
-        matches = enabled_ok_pattern.search(proc.stderr.decode())
+        matches = enabled_ok_pattern.search(out)
         assert matches is not None

From 0da7d09298b271e56e469dacb75f650fe1bc9bbd Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 1 Aug 2023 07:39:10 +0000
Subject: [PATCH 211/232] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index fdc1653..850096a 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -41,6 +41,6 @@ def test_labextensions():
     for e in extensions:
         # jupyter labextension lists outputs to stderr
         out = proc.stderr.decode()
-        enabled_ok_pattern = re.compile(fr"{e}.*enabled.*OK")
+        enabled_ok_pattern = re.compile(rf"{e}.*enabled.*OK")
         matches = enabled_ok_pattern.search(out)
         assert matches is not None

From 2fbe803530452a1eec27cf8453d9b426fb7176e1 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Tue, 1 Aug 2023 07:40:29 +0000
Subject: [PATCH 212/232] add missing import

---
 integration-tests/test_extensions.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 850096a..0ca12ea 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -1,3 +1,4 @@
+import re
 import subprocess
 
 

From 6801c683332e416824d61ef387c35436b7ef54d3 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 1 Aug 2023 08:36:35 +0000
Subject: [PATCH 213/232] [pre-commit.ci] pre-commit autoupdate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

updates:
- [github.com/asottile/pyupgrade: v3.8.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.8.0...v3.10.1)
- [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0)
- [github.com/pre-commit/mirrors-prettier: v3.0.0-alpha.9-for-vscode → v3.0.0](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.0-alpha.9-for-vscode...v3.0.0)
- [github.com/pycqa/flake8: 6.0.0 → 6.1.0](https://github.com/pycqa/flake8/compare/6.0.0...6.1.0)
---
 .pre-commit-config.yaml | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9464b41..dc47edb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@
 repos:
   # Autoformat: Python code, syntax patterns are modernized
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.8.0
+    rev: v3.10.1
     hooks:
       - id: pyupgrade
         args:
@@ -37,13 +37,13 @@ repos:
 
   # Autoformat: Python code
   - repo: https://github.com/psf/black
-    rev: 23.3.0
+    rev: 23.7.0
     hooks:
       - id: black
 
   # Autoformat: markdown, yaml
   - repo: https://github.com/pre-commit/mirrors-prettier
-    rev: v3.0.0-alpha.9-for-vscode
+    rev: v3.0.0
     hooks:
       - id: prettier
 
@@ -64,7 +64,7 @@ repos:
 
   # Lint: Python code
   - repo: https://github.com/pycqa/flake8
-    rev: "6.0.0"
+    rev: "6.1.0"
     hooks:
       - id: flake8
 

From 0f03f40eff3eb49ea9bac332805961f6c4df7df4 Mon Sep 17 00:00:00 2001
From: Jeremy Tuloup 
Date: Wed, 9 Aug 2023 05:25:21 +0000
Subject: [PATCH 214/232] Re-add `nbgitpuller`

---
 integration-tests/test_extensions.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integration-tests/test_extensions.py b/integration-tests/test_extensions.py
index 0ca12ea..661432b 100644
--- a/integration-tests/test_extensions.py
+++ b/integration-tests/test_extensions.py
@@ -14,7 +14,7 @@ def test_serverextensions():
 
     extensions = [
         "jupyterlab",
-        # "nbgitpuller",
+        "nbgitpuller",
         "jupyter_resource_usage",
     ]
 

From 2b1b6787b8a4b139c1984f315f1e797abad8cb27 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Wed, 9 Aug 2023 09:28:37 +0200
Subject: [PATCH 215/232] Apply suggestions from code review

---
 docs/howto/user-env/override-lab-settings.md | 28 ++++++++++----------
 1 file changed, 14 insertions(+), 14 deletions(-)

diff --git a/docs/howto/user-env/override-lab-settings.md b/docs/howto/user-env/override-lab-settings.md
index 4f924a5..7bb6dfe 100644
--- a/docs/howto/user-env/override-lab-settings.md
+++ b/docs/howto/user-env/override-lab-settings.md
@@ -73,23 +73,23 @@ setting snippet to add to the `overrides.json` file later.
    We only want to change the **Selected Theme**, so we don't need to include
    the other theme-related settings for CSS and the scrollbar.
 
-:::{note}
-To apply overrides for more than one setting, separate each setting by commas. For example,
-if you _also_ wanted to change the interval at which the notebook autosaves your content, you can use
+   :::{note}
+   To apply overrides for more than one setting, separate each setting by commas. For example,
+   if you _also_ wanted to change the interval at which the notebook autosaves your content, you can use
 
-```json
-{
-  "@jupyterlab/apputils-extension:themes": {
-    "theme": "JupyterLab Dark"
-  },
+   ```json
+   {
+     "@jupyterlab/apputils-extension:themes": {
+       "theme": "JupyterLab Dark"
+     },
 
-  "@jupyterlab/docmanager-extension:plugin": {
-    "autosaveInterval": 30
-  }
-}
-```
+     "@jupyterlab/docmanager-extension:plugin": {
+       "autosaveInterval": 30
+     }
+   }
+   ```
 
-:::
+   :::
 
 ## Step 3: Apply the Overrides to the Hub
 

From e9de9f604248e1562d4606e096de4aee3dc10cc5 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Wed, 9 Aug 2023 07:28:46 +0000
Subject: [PATCH 216/232] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 docs/howto/user-env/override-lab-settings.md | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/docs/howto/user-env/override-lab-settings.md b/docs/howto/user-env/override-lab-settings.md
index 7bb6dfe..cba3703 100644
--- a/docs/howto/user-env/override-lab-settings.md
+++ b/docs/howto/user-env/override-lab-settings.md
@@ -63,6 +63,7 @@ setting snippet to add to the `overrides.json` file later.
    setting of `@jupyterlab/apputils-extension:theme` as can be seen above.
 
 1. Build your `json` snippet. In this case, our snippet should look like this:
+
    ```json
    {
      "@jupyterlab/apputils-extension:themes": {
@@ -70,6 +71,7 @@ setting snippet to add to the `overrides.json` file later.
      }
    }
    ```
+
    We only want to change the **Selected Theme**, so we don't need to include
    the other theme-related settings for CSS and the scrollbar.
 

From 854f5edd0662831bde5aa264417ed35b4e599732 Mon Sep 17 00:00:00 2001
From: Ray Bell 
Date: Thu, 16 Dec 2021 22:15:56 -0500
Subject: [PATCH 217/232] WIP: try default_app

---
 tljh/configurer.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/tljh/configurer.py b/tljh/configurer.py
index bf15354..962cdde 100644
--- a/tljh/configurer.py
+++ b/tljh/configurer.py
@@ -52,7 +52,7 @@ default = {
         "password": "",
     },
     "user_environment": {
-        "default_app": "classic",
+        "default_app": "jupyterlab",
     },
     "services": {
         "cull": {
@@ -231,6 +231,8 @@ def update_user_environment(c, config):
     # Set default application users are launched into
     if user_env["default_app"] == "jupyterlab":
         c.Spawner.default_url = "/lab"
+    elif user_env["default_app"] == "classic":
+        c.Spawner.default_url = "/tree"
 
 
 def update_user_account_config(c, config):

From a8c6c40b2bd557274b139f3398b585dd1de539b5 Mon Sep 17 00:00:00 2001
From: Ray Bell 
Date: Thu, 16 Dec 2021 22:26:24 -0500
Subject: [PATCH 218/232] switch jhub to classic test

---
 tests/test_configurer.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/tests/test_configurer.py b/tests/test_configurer.py
index 29c073a..ff549d5 100644
--- a/tests/test_configurer.py
+++ b/tests/test_configurer.py
@@ -62,12 +62,12 @@ def test_app_default():
     assert "default_url" not in c.Spawner
 
 
-def test_app_jupyterlab():
+def test_app_classic():
     """
-    Test setting JupyterLab as default application
+    Test setting classic as default application
     """
-    c = apply_mock_config({"user_environment": {"default_app": "jupyterlab"}})
-    assert c.Spawner.default_url == "/lab"
+    c = apply_mock_config({"user_environment": {"default_app": "classic"}})
+    assert c.Spawner.default_url == "/tree"
 
 
 def test_auth_default():

From 5fdc31f9a9177cc7dca744fc438648b566d932e5 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Wed, 9 Aug 2023 10:24:15 +0200
Subject: [PATCH 219/232] docs: adjust docs to reflect jupyterlab as default

---
 docs/conf.py                               | 26 +++++++++++
 docs/howto/user-env/notebook-interfaces.md | 54 +++++++++++-----------
 docs/reference/changelog.md                |  5 ++
 3 files changed, 57 insertions(+), 28 deletions(-)

diff --git a/docs/conf.py b/docs/conf.py
index 677ed3b..dcb64fa 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -74,6 +74,32 @@ myst_enable_extensions = [
     "fieldlist",
 ]
 
+
+# -- Options for intersphinx extension ---------------------------------------
+# ref: https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration
+#
+# The extension makes us able to link like to other projects like below.
+#
+#     rST  - :external:py:class:`jupyterhub.spawner.Spawner`
+#     MyST - {external:py:class}`jupyterhub.spawner.Spawner`
+#
+#     rST  - :external:py:attribute:`jupyterhub.spawner.Spawner.default_url`
+#     MyST - {external:py:attribute}`jupyterhub.spawner.Spawner.default_url`
+#
+# To see what we can link to, do the following where "objects.inv" is appended
+# to the sphinx based website:
+#
+#     python -m sphinx.ext.intersphinx https://jupyterhub.readthedocs.io/en/stable/objects.inv
+#
+intersphinx_mapping = {
+    "jupyterhub": ("https://jupyterhub.readthedocs.io/en/stable/", None),
+}
+
+# intersphinx_disabled_reftypes set based on recommendation in
+# https://docs.readthedocs.io/en/stable/guides/intersphinx.html#using-intersphinx
+intersphinx_disabled_reftypes = ["*"]
+
+
 # -- Options for linkcheck builder -------------------------------------------
 # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder
 #
diff --git a/docs/howto/user-env/notebook-interfaces.md b/docs/howto/user-env/notebook-interfaces.md
index d83a05b..063c004 100644
--- a/docs/howto/user-env/notebook-interfaces.md
+++ b/docs/howto/user-env/notebook-interfaces.md
@@ -1,49 +1,47 @@
 (howto/user-env/notebook-interfaces)=
 
-# Change default User Interface
+# Change default user interface
 
-By default, logging into TLJH puts you in the classic Jupyter Notebook
-interface we all know and love. However, there are at least two other
-popular notebook interfaces you can use:
+By default a user starting a server will see the JupyterLab interface. This can
+be changed with TLJH config `user_environment.default_app` or with the
+JupyterHub config
+{external:py:attribute}`jupyterhub.spawner.Spawner.default_url` directly.
 
-1.  [JupyterLab](http://jupyterlab.readthedocs.io/en/stable/)
-2.  [nteract](https://nteract.io/)
+The TLJH config supports the options `jupyterlab` and `classic`, which
+translates to a `Spawner.default_url` config of `/lab` and `/tree`.
 
-Both these interfaces are also shipped with TLJH by default. You can try
-them temporarily, or set them to be the default interface whenever you
-login.
+Both these interfaces are also shipped with TLJH by default. You can try them
+temporarily, or set them to be the default interface whenever you login.
 
 ## Trying an alternate interface temporarily
 
-When you log in & start your server, by default the URL in your browser
-will be something like `/user//tree`. The `/tree` is what
-tells the notebook server to give you the classic notebook interface.
+When you log in and start your server, by default the URL in your browser will
+be something like `/user//lab`. The `/lab` is what tells the jupyter
+server to give you the JupyterLab user interface.
 
-- **For the JupyterLab interface**: change `/tree` to `/lab`.
-- **For the nteract interface**: change `/tree` to `/nteract`
+As an example, you can update the URL to not end with `/lab`, but instead end
+with `/tree` to temporarily switch to the classic interface.
 
-You can play around with them and see what fits your use cases best.
+## Changing the default user interface using TLJH config
 
-## Changing the default user interface
+You can change the default url, and therefore the interface users get when they
+log in by modifying TLJH config as an admin user.
 
-You can change the default interface users get when they log in by
-modifying `config.yaml` as an admin user.
+1.  To launch the classic notebook interface when users log in, run the
+    following in the admin console:
 
-1.  To launch **JupyterLab** when users log in, run the following in an
-    admin console:
+    ```bash
+    sudo tljh-config set user_environment.default_app classic
+    ```
+
+1.  To launch JupyterLab when users log in, run the following in an admin
+    console:
 
     ```bash
     sudo tljh-config set user_environment.default_app jupyterlab
     ```
 
-2.  Alternatively, to launch **nteract** when users log in, run the
-    following in the admin console:
-
-    ```bash
-    sudo tljh-config set user_environment.default_app nteract
-    ```
-
-3.  Apply the changes by restarting JupyterHub. This should not disrupt
+1.  Apply the changes by restarting JupyterHub. This should not disrupt
     current users.
 
     ```bash
diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md
index cf8dbf0..b0913c6 100644
--- a/docs/reference/changelog.md
+++ b/docs/reference/changelog.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## Unreleased
+
+- The default user interface changed to JupyterLab, to restore previous behavior
+  see the documentation about [User interfaces](#howto/user-env/notebook-interfaces).
+
 ## 1.0
 
 ### 1.0.0b1 - 2023-07-07

From e893959ce5b2c44d67fd59ed230b7eed43ff0018 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Wed, 9 Aug 2023 10:31:32 +0200
Subject: [PATCH 220/232] Update tests to reflect jupyterlab by default

---
 tests/test_configurer.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/tests/test_configurer.py b/tests/test_configurer.py
index ff549d5..3c1db3d 100644
--- a/tests/test_configurer.py
+++ b/tests/test_configurer.py
@@ -58,8 +58,7 @@ def test_app_default():
     Test default application with no config overrides.
     """
     c = apply_mock_config({})
-    # default_url is not set, so JupyterHub will pick default.
-    assert "default_url" not in c.Spawner
+    assert c.Spawner.default_url == "/lab"
 
 
 def test_app_classic():

From 4cd5da40c38fb01bb5ac5a6df538ac1b13d5a1fb Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:14:09 +0200
Subject: [PATCH 221/232] Update to jupyterhub >=4.0.2,<5

---
 docs/reference/changelog.md   | 4 ++--
 tljh/requirements-hub-env.txt | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md
index b0913c6..10ebbec 100644
--- a/docs/reference/changelog.md
+++ b/docs/reference/changelog.md
@@ -75,7 +75,7 @@ The changes in the respective environments between TLJH version 0.2.0 and
 
 | Dependency changes in the _hub environment_                                    | Version in 0.2.0 | Version in 1.0.0b1 | Changelog link                                                                           | Note                                                 |
 | ------------------------------------------------------------------------------ | ---------------- | ------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- |
-| [jupyterhub](https://github.com/jupyterhub/jupyterhub)                         | 1.\*             | >=4.0.1,<5         | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html)        | Running in the `jupyterhub` systemd service          |
+| [jupyterhub](https://github.com/jupyterhub/jupyterhub)                         | 1.\*             | >=4.0.2,<5         | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html)        | Running in the `jupyterhub` systemd service          |
 | [traefik](https://github.com/traefik/traefik)                                  | 1.7.33           | 2.10.1             | [Changelog](https://github.com/traefik/traefik/blob/master/CHANGELOG.md)                 | Running in the `traefik` systemd service             |
 | [traefik-proxy](https://github.com/jupyterhub/traefik-proxy)                   | 0.3.\*           | >=1.1.0,<2         | [Changelog](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/changelog.html)    | Run by jupyterhub, controls `traefik`                |
 | [systemdspawner](https://github.com/jupyterhub/systemdspawner)                 | 0.16.\*          | >=1.0.1,<2         | [Changelog](https://github.com/jupyterhub/systemdspawner/blob/master/CHANGELOG.md)       | Run by jupyterhub, controls user servers via systemd |
@@ -89,7 +89,7 @@ The changes in the respective environments between TLJH version 0.2.0 and
 
 | Dependency changes in the _user environment_             | Version in 0.2.0 | Version in 1.0.0 | Changelog link                                                                    | Note                     |
 | -------------------------------------------------------- | ---------------- | ---------------- | --------------------------------------------------------------------------------- | ------------------------ |
-| [jupyterhub](https://github.com/jupyterhub/jupyterhub)   | 1.\*             | >=4.0.1,<5       | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html) | Always upgraded.         |
+| [jupyterhub](https://github.com/jupyterhub/jupyterhub)   | 1.\*             | >=4.0.2,<5       | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html) | Always upgraded.         |
 | [pip](https://github.com/pypa/pip)                       | \*               | >=23.1.2         | [Changelog](https://pip.pypa.io/en/stable/news/)                                  | Only upgraded if needed. |
 | [conda](https://docs.conda.io/projects/conda/en/stable/) | 0.16.0           | >=0.16.0         | [Changelog](https://docs.conda.io/projects/conda/en/stable/release-notes.html)    | Only upgraded if needed. |
 | [mamba](https://mamba.readthedocs.io/en/latest/)         | 4.10.3           | >=4.10.0         | [Changelog](https://github.com/mamba-org/mamba/blob/main/CHANGELOG.md)            | Only upgraded if needed. |
diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt
index e1c9273..f8e7efd 100644
--- a/tljh/requirements-hub-env.txt
+++ b/tljh/requirements-hub-env.txt
@@ -8,7 +8,7 @@
 # If a dependency is bumped to a new major version, we should make a major
 # version release of tljh.
 #
-jupyterhub>=4.0.1,<5
+jupyterhub>=4.0.2,<5
 jupyterhub-systemdspawner>=1.0.1,<2
 jupyterhub-firstuseauthenticator>=1.0.0,<2
 jupyterhub-nativeauthenticator>=1.2.0,<2

From c6d86d059fb6006641b350184a25fc2c182d572d Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:14:37 +0200
Subject: [PATCH 222/232] docs: ensure intersphinx extension is enabled

---
 docs/conf.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/docs/conf.py b/docs/conf.py
index dcb64fa..f3e2e15 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -20,6 +20,7 @@ author = "Project Jupyter Contributors"
 #
 extensions = [
     "sphinx_copybutton",
+    "sphinx.ext.intersphinx",
     "sphinxext.opengraph",
     "sphinxext.rediraffe",
     "myst_parser",

From cc00bf3a1ac000ffd7aa8f21a13c18f910061787 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:16:11 +0200
Subject: [PATCH 223/232] docs: add docs on the system, hub, user environments

---
 docs/topic/index.md              |  1 +
 docs/topic/three-environments.md | 76 ++++++++++++++++++++++++++++++++
 2 files changed, 77 insertions(+)
 create mode 100644 docs/topic/three-environments.md

diff --git a/docs/topic/index.md b/docs/topic/index.md
index 7ec4b88..90e7e9c 100644
--- a/docs/topic/index.md
+++ b/docs/topic/index.md
@@ -8,6 +8,7 @@ Topic guides provide in-depth explanations of specific topics.
 
 whentouse
 requirements
+three-environments
 security
 customizing-installer
 installer-actions
diff --git a/docs/topic/three-environments.md b/docs/topic/three-environments.md
new file mode 100644
index 0000000..46718b7
--- /dev/null
+++ b/docs/topic/three-environments.md
@@ -0,0 +1,76 @@
+(topic-three-environments)=
+
+# The system, hub, and user environments
+
+TLJH's documentation mentions the _system environment_, the _hub environment_,
+and the _user environment_. This section will introduce what is meant with that
+and clarify the distinctions between the environments.
+
+(system-environment)=
+
+## The system environment
+
+When this documentation mentions the _system environment_, it refers to the
+Linux environment with its installed `apt` packages, users in `/etc/passwd`,
+etc.
+
+A part of the system environment is a Python environment setup via the `apt`
+package `python` installed by default in Linux distributions supported by TLJH.
+To be specific, we can refer to this as the _system's Python environment_.
+
+If you would do `sudo python3 -m pip install ` you would end up
+installing something in the system's Python environment, and that would not be
+available in the hub environment or the user environment.
+
+The system's Python environment is only used by TLJH to run the `bootstrap.py`
+script downloaded as part of installing or upgrading TLJH. This script is also
+responsible for setting up the hub environment.
+
+(hub-environment)=
+
+## The hub environment
+
+The _hub environment_ is a [virtual Python environment] setup in `/opt/tljh/hub`
+by the `bootstrap.py` script using the system's Python environment during TLJH
+installation.
+
+The hub environment has Python packages installed in it related to running
+JupyterHub itself such as an JupyterHub authenticator package, but it doesn't
+include packages to start user servers like JupyterLab.
+
+When TLJH is installed/upgraded, the packages listed in
+[tljh/requirements-hub-env.txt] are installed/upgraded in this environment.
+
+If you would do `sudo /opt/tljh/hub/bin/python3 -m pip install ` you
+would end up installing something in the hub environment, and that would not be
+available in the system's Python environment or the user environment.
+
+[virtual Python environment]: https://docs.python.org/3/library/venv.html
+
+[tljh/requirements-hub-env.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/HEAD/tljh/requirements-hub-env.txt
+
+(user-environment)=
+
+## The user environment
+
+The _user environment_ is a Python environment setup in `/opt/tljh/user` by the
+TLJH installer during TLJH installation. The user environment is not a virtual
+environment because an entirely separate installation of Python has been made
+for it.
+
+The user environment has packages installed in it related to running individual
+jupyter servers, such as `jupyterlab`.
+
+When TLJH is _installed_, the packages listed in
+[tljh/requirements-user-env.txt] are installed in this environment. When TLJH is
+_upgraded_ though, as little as possible is done to this environment. Typically
+only `jupyterhub` is upgraded to match the version in the hub environment. If
+upgrading to a new major version of TLJH, then something small may be done
+besides this, and then it should be described the changelog.
+
+If you would do `sudo /opt/tljh/user/bin/python3 -m pip install `, or
+from a user server's terminal do `sudo -E pip install ` you would end
+up installing something in the user environment, and that would not be available
+in the system's Python environment or the hub environment.
+
+[tljh/requirements-user-env-extras.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/HEAD/tljh/requirements-user-env-extras.txt

From e14b8d8b775d1cd410e23896afa1e3232592ea86 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:17:03 +0200
Subject: [PATCH 224/232] docs: add section on whats done during upgrades

---
 docs/topic/index.md                     |  1 +
 docs/topic/installer-upgrade-actions.md | 30 +++++++++++++++++++++++++
 2 files changed, 31 insertions(+)
 create mode 100644 docs/topic/installer-upgrade-actions.md

diff --git a/docs/topic/index.md b/docs/topic/index.md
index 90e7e9c..dc38061 100644
--- a/docs/topic/index.md
+++ b/docs/topic/index.md
@@ -12,6 +12,7 @@ three-environments
 security
 customizing-installer
 installer-actions
+installer-upgrade-actions
 tljh-config
 authenticator-configuration
 escape-hatch
diff --git a/docs/topic/installer-upgrade-actions.md b/docs/topic/installer-upgrade-actions.md
new file mode 100644
index 0000000..f367434
--- /dev/null
+++ b/docs/topic/installer-upgrade-actions.md
@@ -0,0 +1,30 @@
+(topic-installer-upgrade-actions)=
+
+# What is done during an upgrade of TLJH?
+
+Once TLJH has been installed, it should be possible to upgrade the installation.
+This documentation is meant to capture the changes made during an upgrade.
+
+```{versionchanged} 1.0.0
+Ensuring upgrades work has only been done since 1.0.0 upgrading from version
+0.2.0.
+```
+
+## Changes to the system environment
+
+The [system environment](system-environment) is not meant to be influenced
+unless explicitly mentioned in the changelog, typically only during major
+version upgrades.
+
+## Changes to the hub environment
+
+The [hub environment](hub-environment) gets several packages upgraded based on
+version ranges specified in [tljh/requirements-hub-env.txt].
+
+## Changes to the user environment
+
+The [user environment](user-environment) gets is `jupyterhub` package upgraded,
+but no other packages gets upgraded unless explicitly mentioned in the
+changelog, typically only during major version upgrades.
+
+[tljh/requirements-hub-env.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/HEAD/tljh/requirements-hub-env.txt

From 9efe59f4f74f5c346c9ff687491a1151ae86c9f9 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:17:50 +0200
Subject: [PATCH 225/232] docs: add how-to section on making upgrades

---
 docs/howto/admin/upgrade-tljh.md | 64 ++++++++++++++++++++++++++++++++
 docs/howto/index.md              |  1 +
 docs/troubleshooting/index.md    |  2 +
 3 files changed, 67 insertions(+)
 create mode 100644 docs/howto/admin/upgrade-tljh.md

diff --git a/docs/howto/admin/upgrade-tljh.md b/docs/howto/admin/upgrade-tljh.md
new file mode 100644
index 0000000..1fcfe10
--- /dev/null
+++ b/docs/howto/admin/upgrade-tljh.md
@@ -0,0 +1,64 @@
+(howto-admin-upgrade-tljh)=
+
+# Upgrade TLJH
+
+An TLJH installation is supposed to be upgradable to get updates to JupyterHub
+itself and its dependencies in the [hub environment](hub-environment). For
+details on what is done during an upgrade, see
+[](topic-installer-upgrade-actions).
+
+## Step 1: Read the changelog
+
+Before making an upgrade, please read the [](changelog) to become aware about
+breaking changes. If there are breaking changes, you may need to update your
+configuration files or take other actions as well as part of the upgrade.
+
+Adjusting to the breaking changes isn't part of this documentation, please rely
+on the TLJH changelog and the changelogs of related projects linked to from the
+TLJH changelog.
+
+## Step 2: Consider making a backup
+
+Before making an upgrade, consider if you want to first make a backup in some
+way. While upgrades between TLJH versions are tested with automation, there are
+no guarantees.
+
+This project does't yet provide documentation on how to make backups, but if
+TLJH is installed on a virtual machine in a cloud, a good option is to try
+create a snapshot of the associated disk. If this isn't an option, you could
+consider making a backup of the files in `/opt/tljh` that contain most but not
+all things during an upgrade, or perhaps only the JupyterHub database with
+information about its users in `/opt/tljh/state` together with some other
+details.
+
+## Step 3: Make the upgrade
+
+To initialize the upgrade, do the following from a terminal on the machine where
+TLJH is installed.
+
+```shell
+# IMPORTANT: This should NOT be run from a JupyterHub started user server, but
+#            should only run from a standalone terminal session in the machine
+#            where TLJH has been installed.
+#
+curl -L https://tljh.jupyter.org/bootstrap.py \
+  | sudo python3 - \
+    --version=latest
+```
+
+You can also upgrade to specific version by changing `--version=latest` to
+`--version=1.0.0` or similar. There is no need to specify admin users etc again.
+
+## Step 4: Verify function
+
+After having made an upgrade its always good to verify that the JupyterHub
+installation still works as expected. You may want to try logging out, logging
+in, and starting a new server for example.
+
+If you have issues consider the [](troubleshooting) documentation. If you need
+help you can ask questions in [Jupyter forum], and if you think there is a bug
+or documentation improvement that should be made you can open an issue or pull
+request in the [TLJH GitHub project].
+
+[jupyter forum]: https://discourse.jupyter.org/c/jupyterhub/tljh
+[tljh github project]: https://github.com/jupyterhub/the-littlest-jupyterhub
diff --git a/docs/howto/index.md b/docs/howto/index.md
index ea4135d..7b28885 100644
--- a/docs/howto/index.md
+++ b/docs/howto/index.md
@@ -56,6 +56,7 @@ admin/nbresuse
 admin/https
 admin/enable-extensions
 admin/systemd
+admin/upgrade-tljh
 ```
 
 ## Cloud provider configuration
diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md
index 178a64b..9c899fd 100644
--- a/docs/troubleshooting/index.md
+++ b/docs/troubleshooting/index.md
@@ -1,3 +1,5 @@
+(troubleshooting)=
+
 # Troubleshooting
 
 In time, all systems have issues that need to be debugged. Troubleshooting

From fb78464dec8c7091c3338fb60b7ad9ac5b7552d4 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:19:26 +0200
Subject: [PATCH 226/232] docs: fix broken link

---
 docs/howto/user-env/notebook-interfaces.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/howto/user-env/notebook-interfaces.md b/docs/howto/user-env/notebook-interfaces.md
index 063c004..8560b17 100644
--- a/docs/howto/user-env/notebook-interfaces.md
+++ b/docs/howto/user-env/notebook-interfaces.md
@@ -5,7 +5,7 @@
 By default a user starting a server will see the JupyterLab interface. This can
 be changed with TLJH config `user_environment.default_app` or with the
 JupyterHub config
-{external:py:attribute}`jupyterhub.spawner.Spawner.default_url` directly.
+{external:py:attr}`jupyterhub.spawner.Spawner.default_url` directly.
 
 The TLJH config supports the options `jupyterlab` and `classic`, which
 translates to a `Spawner.default_url` config of `/lab` and `/tree`.

From c35a4cbe658fa2a676623fa591ab5b3f90ef7ec7 Mon Sep 17 00:00:00 2001
From: Min RK 
Date: Fri, 11 Aug 2023 11:46:37 +0200
Subject: [PATCH 227/232] typo

---
 docs/howto/admin/upgrade-tljh.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/howto/admin/upgrade-tljh.md b/docs/howto/admin/upgrade-tljh.md
index 1fcfe10..1cb417a 100644
--- a/docs/howto/admin/upgrade-tljh.md
+++ b/docs/howto/admin/upgrade-tljh.md
@@ -2,7 +2,7 @@
 
 # Upgrade TLJH
 
-An TLJH installation is supposed to be upgradable to get updates to JupyterHub
+A TLJH installation is supposed to be upgradable to get updates to JupyterHub
 itself and its dependencies in the [hub environment](hub-environment). For
 details on what is done during an upgrade, see
 [](topic-installer-upgrade-actions).

From b13f09564db6055a7f747122e8a5a5cd1e6ed5fb Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Thu, 10 Aug 2023 15:20:37 +0200
Subject: [PATCH 228/232] docs: update changelog for 1.0.0 release

---
 docs/reference/changelog.md   | 74 +++++++++++++++--------------------
 tljh/requirements-hub-env.txt |  2 +-
 2 files changed, 33 insertions(+), 43 deletions(-)

diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md
index 10ebbec..e542d2c 100644
--- a/docs/reference/changelog.md
+++ b/docs/reference/changelog.md
@@ -1,29 +1,10 @@
+(changelog)=
+
 # Changelog
 
-## Unreleased
-
-- The default user interface changed to JupyterLab, to restore previous behavior
-  see the documentation about [User interfaces](#howto/user-env/notebook-interfaces).
-
 ## 1.0
 
-### 1.0.0b1 - 2023-07-07
-
-````{warning}
-This is a beta release.
-
-To upgrade to it, after having read the breaking changes below, you can do the
-following from a terminal on a machine TLJH is installed.
-
-```shell
-# This should NOT be run from a JupyterHub started user server, but should
-# run from a standalone terminal session in the machine where TLJH has been
-# installed.
-curl -L https://tljh.jupyter.org/bootstrap.py \
-  | sudo python3 - \
-    --version=1.0.0b1
-```
-````
+### 1.0.0 - 2023-08-11
 
 This release bundles with the latest available software from the JupyterHub
 ecosystem.
@@ -32,9 +13,11 @@ The TLJH project now has tests to verify upgrades of installations between
 releases and procedures with automation to make releases. Going onwards, TLJH
 installations of version 0.2.0 and later are meant to be easy to upgrade.
 
+For instructions on how to make an upgrade, see [](howto-admin-upgrade-tljh).
+
 #### Breaking changes
 
-- JupyterHub 1.\* has been upgraded to >=4.0.1,<5
+- JupyterHub 1.\* has been upgraded to >=4.0.2,<5
   - This upgrade requires user servers to be restarted if they were running
     during the upgrade.
   - Refer to the [JupyterHub changelog] for details where you pay attention to
@@ -49,6 +32,9 @@ installations of version 0.2.0 and later are meant to be easy to upgrade.
 - The configured JupyterHub Spawner class `jupyterhub-systemdspawner` has been
   upgraded to a new major version, but no breaking change are expected to be
   noticed for users of this distribution.
+- User servers now launch into `/lab` by default, to revert this a JupyterHub
+  admin user can do `sudo tljh-config set user_environment.default_app classic`
+  or set the JupyterHub config `c.Spawner.default_url` directly.
 
 [jupyterhub changelog]: https://jupyterhub.readthedocs.io/en/stable/changelog.html
 
@@ -67,25 +53,25 @@ user environment. The hub environment's dependencies are on the other hand
 always upgraded to the latest version within the specified version range defined
 in [tljh/requirements-hub-env.txt] and seen below.
 
-[tljh/requirements-user-env-extras.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/1.0.0b1/tljh/requirements-user-env-extras.txt
-[tljh/requirements-hub-env.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/1.0.0b1/tljh/requirements-hub-env.txt
+[tljh/requirements-user-env-extras.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/1.0.0/tljh/requirements-user-env-extras.txt
+[tljh/requirements-hub-env.txt]: https://github.com/jupyterhub/the-littlest-jupyterhub/blob/1.0.0/tljh/requirements-hub-env.txt
 
-The changes in the respective environments between TLJH version 0.2.0 and
-1.0.0b1 are summarized below.
+The changes in the respective environments between TLJH version 0.2.0 and 1.0.0
+are summarized below.
 
-| Dependency changes in the _hub environment_                                    | Version in 0.2.0 | Version in 1.0.0b1 | Changelog link                                                                           | Note                                                 |
-| ------------------------------------------------------------------------------ | ---------------- | ------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- |
-| [jupyterhub](https://github.com/jupyterhub/jupyterhub)                         | 1.\*             | >=4.0.2,<5         | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html)        | Running in the `jupyterhub` systemd service          |
-| [traefik](https://github.com/traefik/traefik)                                  | 1.7.33           | 2.10.1             | [Changelog](https://github.com/traefik/traefik/blob/master/CHANGELOG.md)                 | Running in the `traefik` systemd service             |
-| [traefik-proxy](https://github.com/jupyterhub/traefik-proxy)                   | 0.3.\*           | >=1.1.0,<2         | [Changelog](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/changelog.html)    | Run by jupyterhub, controls `traefik`                |
-| [systemdspawner](https://github.com/jupyterhub/systemdspawner)                 | 0.16.\*          | >=1.0.1,<2         | [Changelog](https://github.com/jupyterhub/systemdspawner/blob/master/CHANGELOG.md)       | Run by jupyterhub, controls user servers via systemd |
-| [jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler) | 1.\*             | >=1.2.1,<2         | [Changelog](https://github.com/jupyterhub/jupyterhub-idle-culler/blob/main/CHANGELOG.md) | Run by jupyterhub, stops inactivate servers etc.     |
-| [firstuseauthenticator](https://github.com/jupyterhub/firstuseauthenticator)   | 1.\*             | >=1.0.0,<2         | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html)    | An optional way to authenticate users                |
-| [tmpauthenticator](https://github.com/jupyterhub/tmpauthenticator)             | 0.6.\*           | >=1.0.0,<2         | [Changelog](https://github.com/jupyterhub/tmpauthenticator/blob/HEAD/CHANGELOG.md)       | An optional way to authenticate users                |
-| [nativeauthenticator](https://github.com/jupyterhub/nativeauthenticator)       | 1.\*             | >=1.2.0,<2         | [Changelog](https://github.com/jupyterhub/nativeauthenticator/blob/HEAD/CHANGELOG.md)    | An optional way to authenticate users                |
-| [oauthenticator](https://github.com/jupyterhub/oauthenticator)                 | 14.\*            | >=16.0.2,<17       | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html)    | An optional way to authenticate users                |
-| [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator)           | 1.\*             | >=1.3.2,<2         | [Changelog](https://github.com/jupyterhub/ldapauthenticator/blob/HEAD/CHANGELOG.md)      | An optional way to authenticate users                |
-| [pip](https://github.com/pypa/pip)                                             | 21.3.\*          | >=23.1.2           | [Changelog](https://pip.pypa.io/en/stable/news/)                                         | -                                                    |
+| Dependency changes in the _hub environment_                                    | Version in 0.2.0 | Version in 1.0.0 | Changelog link                                                                           | Note                                                 |
+| ------------------------------------------------------------------------------ | ---------------- | ---------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- |
+| [jupyterhub](https://github.com/jupyterhub/jupyterhub)                         | 1.\*             | >=4.0.2,<5       | [Changelog](https://jupyterhub.readthedocs.io/en/stable/reference/changelog.html)        | Running in the `jupyterhub` systemd service          |
+| [traefik](https://github.com/traefik/traefik)                                  | 1.7.33           | 2.10.1           | [Changelog](https://github.com/traefik/traefik/blob/master/CHANGELOG.md)                 | Running in the `traefik` systemd service             |
+| [traefik-proxy](https://github.com/jupyterhub/traefik-proxy)                   | 0.3.\*           | >=1.1.0,<2       | [Changelog](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/changelog.html)    | Run by jupyterhub, controls `traefik`                |
+| [systemdspawner](https://github.com/jupyterhub/systemdspawner)                 | 0.16.\*          | >=1.0.1,<2       | [Changelog](https://github.com/jupyterhub/systemdspawner/blob/master/CHANGELOG.md)       | Run by jupyterhub, controls user servers via systemd |
+| [jupyterhub-idle-culler](https://github.com/jupyterhub/jupyterhub-idle-culler) | 1.\*             | >=1.2.1,<2       | [Changelog](https://github.com/jupyterhub/jupyterhub-idle-culler/blob/main/CHANGELOG.md) | Run by jupyterhub, stops inactivate servers etc.     |
+| [firstuseauthenticator](https://github.com/jupyterhub/firstuseauthenticator)   | 1.\*             | >=1.0.0,<2       | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html)    | An optional way to authenticate users                |
+| [tmpauthenticator](https://github.com/jupyterhub/tmpauthenticator)             | 0.6.\*           | >=1.0.0,<2       | [Changelog](https://github.com/jupyterhub/tmpauthenticator/blob/HEAD/CHANGELOG.md)       | An optional way to authenticate users                |
+| [nativeauthenticator](https://github.com/jupyterhub/nativeauthenticator)       | 1.\*             | >=1.2.0,<2       | [Changelog](https://github.com/jupyterhub/nativeauthenticator/blob/HEAD/CHANGELOG.md)    | An optional way to authenticate users                |
+| [oauthenticator](https://github.com/jupyterhub/oauthenticator)                 | 14.\*            | >=16.0.4,<17     | [Changelog](https://oauthenticator.readthedocs.io/en/latest/reference/changelog.html)    | An optional way to authenticate users                |
+| [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator)           | 1.\*             | >=1.3.2,<2       | [Changelog](https://github.com/jupyterhub/ldapauthenticator/blob/HEAD/CHANGELOG.md)      | An optional way to authenticate users                |
+| [pip](https://github.com/pypa/pip)                                             | 21.3.\*          | >=23.1.2         | [Changelog](https://pip.pypa.io/en/stable/news/)                                         | -                                                    |
 
 | Dependency changes in the _user environment_             | Version in 0.2.0 | Version in 1.0.0 | Changelog link                                                                    | Note                     |
 | -------------------------------------------------------- | ---------------- | ---------------- | --------------------------------------------------------------------------------- | ------------------------ |
@@ -106,6 +92,8 @@ The changes in the respective environments between TLJH version 0.2.0 and
 
 #### Maintenance and upkeep improvements
 
+- Update Notebook, JupyterLab, Jupyter Resource Usage [#928](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/928) ([@jtpio](https://github.com/jtpio), [@consideRatio](https://github.com/consideRatio))
+- Launch into `/lab` by default by changing TLJH config's default value [#775](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/775) ([@raybellwaves](https://github.com/raybellwaves), [@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
 - breaking: update oauthenticator from 15.1.0 to >=16.0.2,<17, make tljh auth docs link out [#924](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/924) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
 - test refactor: add comment about python/conda/mamba [#921](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/921) ([@consideRatio](https://github.com/consideRatio))
 - --force-reinstall old conda to ensure it's working before we try to install conda packages [#920](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/920) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
@@ -134,6 +122,8 @@ The changes in the respective environments between TLJH version 0.2.0 and
 
 #### Documentation improvements
 
+- docs: add docs about environments and upgrades [#932](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/932) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk))
+- Add `JupyterLab` setting overrides docs [#922](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/922) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio))
 - Quote `pwd` to prevent error if dir has spaces [#917](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/917) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio))
 - Google Cloud troubleshooting and configuration updates [#906](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/906) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio))
 - Add user env doc files [#902](https://github.com/jupyterhub/the-littlest-jupyterhub/pull/902) ([@jrdnbradford](https://github.com/jrdnbradford), [@consideRatio](https://github.com/consideRatio))
@@ -153,9 +143,9 @@ The changes in the respective environments between TLJH version 0.2.0 and
 The following people contributed discussions, new ideas, code and documentation contributions, and review.
 See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
 
-([GitHub contributors page for this release](https://github.com/jupyterhub/the-littlest-jupyterhub/graphs/contributors?from=2023-02-27&to=2023-06-09&type=c))
+([GitHub contributors page for this release](https://github.com/jupyterhub/the-littlest-jupyterhub/graphs/contributors?from=2023-02-27&to=2023-08-11&type=c))
 
-@adonm ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aadonm+updated%3A2023-02-27..2023-06-09&type=Issues)) | @audiodude ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaudiodude+updated%3A2023-02-27..2023-06-09&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AconsideRatio+updated%3A2023-02-27..2023-06-09&type=Issues)) | @eingemaischt ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aeingemaischt+updated%3A2023-02-27..2023-06-09&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGeorgianaElena+updated%3A2023-02-27..2023-06-09&type=Issues)) | @Hannnsen ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AHannnsen+updated%3A2023-02-27..2023-06-09&type=Issues)) | @jawiv ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajawiv+updated%3A2023-02-27..2023-06-09&type=Issues)) | @jochym ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajochym+updated%3A2023-02-27..2023-06-09&type=Issues)) | @jrdnbradford ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajrdnbradford+updated%3A2023-02-27..2023-06-09&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amanics+updated%3A2023-02-27..2023-06-09&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aminrk+updated%3A2023-02-27..2023-06-09&type=Issues)) | @MridulS ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMridulS+updated%3A2023-02-27..2023-06-09&type=Issues)) | @nsurleraux-railnova ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ansurleraux-railnova+updated%3A2023-02-27..2023-06-09&type=Issues)) | @Rom1deTroyes ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ARom1deTroyes+updated%3A2023-02-27..2023-06-09&type=Issues)) | @wjcapehart ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awjcapehart+updated%3A2023-02-27..2023-06-09&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ayuvipanda+updated%3A2023-02-27..2023-06-09&type=Issues))
+@adonm ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aadonm+updated%3A2023-02-27..2023-08-11&type=Issues)) | @audiodude ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aaudiodude+updated%3A2023-02-27..2023-08-11&type=Issues)) | @choldgraf ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Acholdgraf+updated%3A2023-02-27..2023-08-11&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AconsideRatio+updated%3A2023-02-27..2023-08-11&type=Issues)) | @eingemaischt ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aeingemaischt+updated%3A2023-02-27..2023-08-11&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AGeorgianaElena+updated%3A2023-02-27..2023-08-11&type=Issues)) | @Hannnsen ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AHannnsen+updated%3A2023-02-27..2023-08-11&type=Issues)) | @jawiv ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajawiv+updated%3A2023-02-27..2023-08-11&type=Issues)) | @jochym ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajochym+updated%3A2023-02-27..2023-08-11&type=Issues)) | @jrdnbradford ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajrdnbradford+updated%3A2023-02-27..2023-08-11&type=Issues)) | @jtpio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ajtpio+updated%3A2023-02-27..2023-08-11&type=Issues)) | @kevmk04 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Akevmk04+updated%3A2023-02-27..2023-08-11&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Amanics+updated%3A2023-02-27..2023-08-11&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Aminrk+updated%3A2023-02-27..2023-08-11&type=Issues)) | @MridulS ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3AMridulS+updated%3A2023-02-27..2023-08-11&type=Issues)) | @nsurleraux-railnova ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ansurleraux-railnova+updated%3A2023-02-27..2023-08-11&type=Issues)) | @raybellwaves ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Araybellwaves+updated%3A2023-02-27..2023-08-11&type=Issues)) | @Rom1deTroyes ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3ARom1deTroyes+updated%3A2023-02-27..2023-08-11&type=Issues)) | @wjcapehart ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Awjcapehart+updated%3A2023-02-27..2023-08-11&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fthe-littlest-jupyterhub+involves%3Ayuvipanda+updated%3A2023-02-27..2023-08-11&type=Issues))
 
 ## 0.2.0
 
diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt
index f8e7efd..2a9324d 100644
--- a/tljh/requirements-hub-env.txt
+++ b/tljh/requirements-hub-env.txt
@@ -14,7 +14,7 @@ jupyterhub-firstuseauthenticator>=1.0.0,<2
 jupyterhub-nativeauthenticator>=1.2.0,<2
 jupyterhub-ldapauthenticator>=1.3.2,<2
 jupyterhub-tmpauthenticator>=1.0.0,<2
-oauthenticator>=16.0.2,<17
+oauthenticator>=16.0.4,<17
 jupyterhub-idle-culler>=1.2.1,<2
 
 # pycurl is installed to improve reliability and performance for when JupyterHub

From fc8e19b1b5663f58f0e7b089903d1d1769db06b8 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Fri, 11 Aug 2023 14:46:14 +0200
Subject: [PATCH 229/232] Bump to 1.0.0

---
 pyproject.toml | 2 +-
 setup.py       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index e8f2db9..368fd97 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,7 +67,7 @@ omit = [
 github_url = "https://github.com/jupyterhub/the-littlest-jupyterhub"
 
 [tool.tbump.version]
-current = "1.0.0b1"
+current = "1.0.0"
 regex = '''
     (?P\d+)
     \.
diff --git a/setup.py b/setup.py
index 0ff550d..347b618 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
 
 setup(
     name="the-littlest-jupyterhub",
-    version="1.0.0b1",
+    version="1.0.0",
     description="A small JupyterHub distribution",
     url="https://github.com/jupyterhub/the-littlest-jupyterhub",
     author="Jupyter Development Team",

From c7507fd799aef12bdb32577e6d25c865ede5eea5 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Fri, 11 Aug 2023 14:46:52 +0200
Subject: [PATCH 230/232] Bump to 1.0.1.dev

---
 pyproject.toml | 2 +-
 setup.py       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 368fd97..d90078d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,7 +67,7 @@ omit = [
 github_url = "https://github.com/jupyterhub/the-littlest-jupyterhub"
 
 [tool.tbump.version]
-current = "1.0.0"
+current = "1.0.1.dev"
 regex = '''
     (?P\d+)
     \.
diff --git a/setup.py b/setup.py
index 347b618..4c22da6 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
 
 setup(
     name="the-littlest-jupyterhub",
-    version="1.0.0",
+    version="1.0.1.dev",
     description="A small JupyterHub distribution",
     url="https://github.com/jupyterhub/the-littlest-jupyterhub",
     author="Jupyter Development Team",

From 46b49c819c20887f384580095fb81b701c5690bd Mon Sep 17 00:00:00 2001
From: "Kai G. Schwebke" 
Date: Sat, 12 Aug 2023 08:47:23 +0200
Subject: [PATCH 231/232] adapt install documentation for new /lab default
 interface

---
 docs/images/control-panel-menu.png | Bin 0 -> 13586 bytes
 docs/install/add-users.md          |   7 +++----
 2 files changed, 3 insertions(+), 4 deletions(-)
 create mode 100644 docs/images/control-panel-menu.png

diff --git a/docs/images/control-panel-menu.png b/docs/images/control-panel-menu.png
new file mode 100644
index 0000000000000000000000000000000000000000..893702c4a3efc5f3e550f280b9ffe317c802400d
GIT binary patch
literal 13586
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
Date: Tue, 5 Sep 2023 08:11:29 +0000
Subject: [PATCH 232/232] [pre-commit.ci] pre-commit autoupdate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

updates:
- [github.com/PyCQA/autoflake: v2.2.0 → v2.2.1](https://github.com/PyCQA/autoflake/compare/v2.2.0...v2.2.1)
- [github.com/pre-commit/mirrors-prettier: v3.0.0 → v3.0.3](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.0...v3.0.3)
---
 .pre-commit-config.yaml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index dc47edb..ec7d213 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
 
   # Autoformat: Python code
   - repo: https://github.com/PyCQA/autoflake
-    rev: v2.2.0
+    rev: v2.2.1
     hooks:
       - id: autoflake
         # args ref: https://github.com/PyCQA/autoflake#advanced-usage
@@ -43,7 +43,7 @@ repos:
 
   # Autoformat: markdown, yaml
   - repo: https://github.com/pre-commit/mirrors-prettier
-    rev: v3.0.0
+    rev: v3.0.3
     hooks:
       - id: prettier