mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
Merge pull request #59 from minrk/state-outside
move state outside envs
This commit is contained in:
@@ -76,6 +76,13 @@ def install_miniconda(installer_path, prefix):
|
|||||||
'-u', '-b',
|
'-u', '-b',
|
||||||
'-p', prefix
|
'-p', prefix
|
||||||
], stderr=subprocess.STDOUT)
|
], stderr=subprocess.STDOUT)
|
||||||
|
# fix permissions on initial install
|
||||||
|
# a few files have the wrong ownership and permissions initially
|
||||||
|
# when the installer is run as root
|
||||||
|
subprocess.check_call(
|
||||||
|
["chown", "-R", "{}:{}".format(os.getuid(), os.getgid()), prefix]
|
||||||
|
)
|
||||||
|
subprocess.check_call(["chmod", "-R", "o-w", prefix])
|
||||||
|
|
||||||
|
|
||||||
def pip_install(prefix, packages, editable=False):
|
def pip_install(prefix, packages, editable=False):
|
||||||
|
|||||||
206
integration-tests/test_install.py
Normal file
206
integration-tests/test_install.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from functools import partial
|
||||||
|
import grp
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_GROUP = "jupyterhub-admins"
|
||||||
|
USER_GROUP = "jupyterhub-users"
|
||||||
|
INSTALL_PREFIX = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh")
|
||||||
|
HUB_PREFIX = os.path.join(INSTALL_PREFIX, "hub")
|
||||||
|
USER_PREFIX = os.path.join(INSTALL_PREFIX, "user")
|
||||||
|
STATE_DIR = os.path.join(INSTALL_PREFIX, "state")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def noop():
|
||||||
|
"""no-op context manager
|
||||||
|
|
||||||
|
for parametrized tests
|
||||||
|
"""
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def setgroup(group):
|
||||||
|
"""Become the user nobody:group
|
||||||
|
|
||||||
|
Only call in a subprocess because there's no turning back
|
||||||
|
"""
|
||||||
|
gid = grp.getgrnam(group).gr_gid
|
||||||
|
uid = pwd.getpwnam("nobody").pw_uid
|
||||||
|
os.setgid(gid)
|
||||||
|
os.setuid(uid)
|
||||||
|
os.environ["HOME"] = "/tmp/test-home-%i-%i" % (uid, gid)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("group", [ADMIN_GROUP, USER_GROUP])
|
||||||
|
def test_groups_exist(group):
|
||||||
|
"""Verify that groups exist"""
|
||||||
|
grp.getgrnam(group)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
pool = ProcessPoolExecutor(1)
|
||||||
|
pool.submit(setgroup, group)
|
||||||
|
|
||||||
|
def access(path, flag):
|
||||||
|
"""Run access test in subproccess as nobody:group"""
|
||||||
|
return pool.submit(os.access, path, flag).result()
|
||||||
|
|
||||||
|
total_tested = 0
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
# walk the directory and check permissions
|
||||||
|
for root, dirs, files in os.walk(path):
|
||||||
|
to_test = dirs
|
||||||
|
if not dirs_only:
|
||||||
|
to_test += files
|
||||||
|
total_tested += len(to_test)
|
||||||
|
for name in to_test:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
if os.path.islink(path):
|
||||||
|
# skip links
|
||||||
|
continue
|
||||||
|
st = os.lstat(path)
|
||||||
|
try:
|
||||||
|
user = pwd.getpwuid(st.st_uid).pw_name
|
||||||
|
except KeyError:
|
||||||
|
# uid may not exist
|
||||||
|
user = st.st_uid
|
||||||
|
try:
|
||||||
|
groupname = grp.getgrgid(st.st_gid).gr_name
|
||||||
|
except KeyError:
|
||||||
|
# gid may not exist
|
||||||
|
groupname = st.st_gid
|
||||||
|
stat_str = "{perm:04o} {user} {group}".format(
|
||||||
|
perm=st.st_mode, user=user, group=groupname
|
||||||
|
)
|
||||||
|
|
||||||
|
# check if the path should be writable
|
||||||
|
if writable is not None:
|
||||||
|
if access(path, os.W_OK) != writable:
|
||||||
|
failures.append(
|
||||||
|
"{} {} should {}be writable by {}".format(
|
||||||
|
stat_str, path, "" if writable else "not ", group
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# check if the path should be readable
|
||||||
|
if readable is not None:
|
||||||
|
if access(path, os.R_OK) != readable:
|
||||||
|
failures.append(
|
||||||
|
"{} {} should {}be readable by {}".format(
|
||||||
|
stat_str, path, "" if readable else "not ", group
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# verify that we actually tested some files
|
||||||
|
# (path typos)
|
||||||
|
assert total_tested > 0, "No files to test in %r" % path
|
||||||
|
# raise a nice summary of the failures:
|
||||||
|
if failures:
|
||||||
|
if len(failures) > 50:
|
||||||
|
failures = failures[:32] + ["...%i total" % len(failures)]
|
||||||
|
assert False, "\n".join(failures)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(reason="admin-write permissions is not implemented")
|
||||||
|
def test_admin_writable():
|
||||||
|
permissions_test(ADMIN_GROUP, sys.prefix, writable=True, dirs_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("group", [ADMIN_GROUP, USER_GROUP])
|
||||||
|
def test_user_env_readable(group):
|
||||||
|
# every file in user env should be readable by everyone
|
||||||
|
permissions_test(group, USER_PREFIX, readable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_user_writable():
|
||||||
|
# nothing in the install directory should be writable by users
|
||||||
|
permissions_test(USER_GROUP, INSTALL_PREFIX, writable=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"group, readwrite", [(ADMIN_GROUP, False), (USER_GROUP, False)]
|
||||||
|
)
|
||||||
|
def test_state_permissions(group, readwrite):
|
||||||
|
state_dir = os.path.abspath(os.path.join(sys.prefix, os.pardir, "state"))
|
||||||
|
permissions_test(group, state_dir, writable=readwrite, readable=readwrite)
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: admin-group should have install permissions
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"group, allowed",
|
||||||
|
[
|
||||||
|
(USER_GROUP, False),
|
||||||
|
pytest.param(
|
||||||
|
ADMIN_GROUP,
|
||||||
|
True,
|
||||||
|
marks=pytest.mark.xfail(reason="admin-permissions not implemented"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pip_install(group, allowed):
|
||||||
|
if allowed:
|
||||||
|
context = noop()
|
||||||
|
else:
|
||||||
|
context = pytest.raises(subprocess.CalledProcessError)
|
||||||
|
|
||||||
|
python = os.path.join(USER_PREFIX, "bin", "python")
|
||||||
|
|
||||||
|
with context:
|
||||||
|
subprocess.check_call(
|
||||||
|
[python, "-m", "pip", "install", "--ignore-installed", "--no-deps", "flit"],
|
||||||
|
preexec_fn=partial(setgroup, group),
|
||||||
|
)
|
||||||
|
if allowed:
|
||||||
|
subprocess.check_call(
|
||||||
|
[python, "-m", "pip", "uninstall", "-y", "flit"],
|
||||||
|
preexec_fn=partial(setgroup, group),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"group, allowed",
|
||||||
|
[
|
||||||
|
(USER_GROUP, False),
|
||||||
|
pytest.param(
|
||||||
|
ADMIN_GROUP,
|
||||||
|
True,
|
||||||
|
marks=pytest.mark.xfail(reason="admin-permissions not implemented"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pip_upgrade(group, allowed):
|
||||||
|
if allowed:
|
||||||
|
context = noop()
|
||||||
|
pytest.skip("admin-install permissions is not implemented")
|
||||||
|
else:
|
||||||
|
context = pytest.raises(subprocess.CalledProcessError)
|
||||||
|
python = os.path.join(USER_PREFIX, "bin", "python")
|
||||||
|
with context:
|
||||||
|
subprocess.check_call(
|
||||||
|
[
|
||||||
|
python,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--ignore-installed",
|
||||||
|
"--no-deps",
|
||||||
|
"testpath==0.3.0",
|
||||||
|
],
|
||||||
|
preexec_fn=partial(setgroup, group),
|
||||||
|
)
|
||||||
|
if allowed:
|
||||||
|
subprocess.check_call(
|
||||||
|
[python, "-m", "pip", "install", "--upgrade", "testpath"],
|
||||||
|
preexec_fn=partial(setgroup, group),
|
||||||
|
)
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
import sys
|
import argparse
|
||||||
import os
|
import os
|
||||||
import tljh.systemd as systemd
|
import secrets
|
||||||
import tljh.conda as conda
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.request import urlopen, URLError
|
from urllib.request import urlopen, URLError
|
||||||
from tljh import user
|
|
||||||
import secrets
|
|
||||||
import argparse
|
|
||||||
import time
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from tljh import conda, systemd, user
|
||||||
|
|
||||||
INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh')
|
INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh')
|
||||||
HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub')
|
HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub')
|
||||||
USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user')
|
USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user')
|
||||||
|
STATE_DIR = os.path.join(INSTALL_PREFIX, 'state')
|
||||||
|
|
||||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
@@ -38,16 +40,16 @@ def ensure_jupyterhub_service(prefix):
|
|||||||
systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params))
|
systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params))
|
||||||
systemd.reload_daemon()
|
systemd.reload_daemon()
|
||||||
|
|
||||||
|
os.makedirs(STATE_DIR, mode=0o700, exist_ok=True)
|
||||||
|
|
||||||
# Set up proxy / hub secret oken if it is not already setup
|
# Set up proxy / hub secret oken if it is not already setup
|
||||||
# FIXME: Check umask here properly
|
proxy_secret_path = os.path.join(STATE_DIR, 'configurable-http-proxy.secret')
|
||||||
proxy_secret_path = os.path.join(INSTALL_PREFIX, 'configurable-http-proxy.secret')
|
|
||||||
if not os.path.exists(proxy_secret_path):
|
if not os.path.exists(proxy_secret_path):
|
||||||
with open(proxy_secret_path, 'w') as f:
|
with open(proxy_secret_path, 'w') as f:
|
||||||
f.write('CONFIGPROXY_AUTH_TOKEN=' + secrets.token_hex(32))
|
f.write('CONFIGPROXY_AUTH_TOKEN=' + secrets.token_hex(32))
|
||||||
# If we are changing CONFIGPROXY_AUTH_TOKEN, restart configurable-http-proxy!
|
# If we are changing CONFIGPROXY_AUTH_TOKEN, restart configurable-http-proxy!
|
||||||
systemd.restart_service('configurable-http-proxy')
|
systemd.restart_service('configurable-http-proxy')
|
||||||
|
|
||||||
os.makedirs(os.path.join(INSTALL_PREFIX, 'hub', 'state'), mode=0o700, exist_ok=True)
|
|
||||||
# Start CHP if it has already not been started
|
# Start CHP if it has already not been started
|
||||||
systemd.start_service('configurable-http-proxy')
|
systemd.start_service('configurable-http-proxy')
|
||||||
# If JupyterHub is running, we want to restart it.
|
# If JupyterHub is running, we want to restart it.
|
||||||
@@ -192,6 +194,9 @@ def main():
|
|||||||
|
|
||||||
ensure_usergroups()
|
ensure_usergroups()
|
||||||
ensure_user_environment(args.user_requirements_txt_url)
|
ensure_user_environment(args.user_requirements_txt_url)
|
||||||
|
# Weird setuptools issue creates a few world-writable metadata files.
|
||||||
|
# Fix it:
|
||||||
|
subprocess.check_call(["chmod", "-R", "o-w", os.path.join(HUB_ENV_PREFIX, "pkgs")])
|
||||||
|
|
||||||
print("Setting up JupyterHub...")
|
print("Setting up JupyterHub...")
|
||||||
ensure_jupyterhub_package(HUB_ENV_PREFIX)
|
ensure_jupyterhub_package(HUB_ENV_PREFIX)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ PrivateTmp=yes
|
|||||||
PrivateDevices=yes
|
PrivateDevices=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
EnvironmentFile={install_prefix}/configurable-http-proxy.secret
|
EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret
|
||||||
# Set PATH so env can find correct node
|
# Set PATH so env can find correct node
|
||||||
Environment=PATH=$PATH:{install_prefix}/hub/bin
|
Environment=PATH=$PATH:{install_prefix}/hub/bin
|
||||||
ExecStart={install_prefix}/hub/bin/configurable-http-proxy \
|
ExecStart={install_prefix}/hub/bin/configurable-http-proxy \
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ User=root
|
|||||||
Restart=always
|
Restart=always
|
||||||
# jupyterhub process should have no access to home directories
|
# jupyterhub process should have no access to home directories
|
||||||
ProtectHome=tmpfs
|
ProtectHome=tmpfs
|
||||||
WorkingDirectory={install_prefix}/hub/state
|
WorkingDirectory={install_prefix}/state
|
||||||
# Protect bits that are normally shared across the system
|
# Protect bits that are normally shared across the system
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
PrivateDevices=yes
|
PrivateDevices=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
# Source CONFIGPROXY_AUTH_TOKEN from here!
|
# Source CONFIGPROXY_AUTH_TOKEN from here!
|
||||||
EnvironmentFile={install_prefix}/configurable-http-proxy.secret
|
EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret
|
||||||
Environment=TLJH_INSTALL_PREFIX={install_prefix}
|
Environment=TLJH_INSTALL_PREFIX={install_prefix}
|
||||||
ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path}
|
ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user