├── .flake8 ├── .github ├── dependabot.yaml └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── check-kernel.bash ├── examples ├── README.md └── jupyterhub.service ├── pyproject.toml ├── setup.py ├── systemdspawner ├── __init__.py ├── systemd.py └── systemdspawner.py └── tests ├── conftest.py ├── test_systemd.py └── test_systemdspawner.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Ignore style and complexity 3 | # E: style errors 4 | # W: style warnings 5 | # C: complexity 6 | # D: docstring warnings (unused pydocstyle extension) 7 | ignore = E, C, W, D 8 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # dependabot.yaml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | # 3 | # Notes: 4 | # - Status and logs from dependabot are provided at 5 | # https://github.com/jupyterhub/systemdspawner/network/updates. 6 | # 7 | version: 2 8 | updates: 9 | # Maintain dependencies in our GitHub Workflows 10 | - package-ecosystem: github-actions 11 | directory: / 12 | labels: [ci] 13 | schedule: 14 | interval: monthly 15 | time: "05:00" 16 | timezone: Etc/UTC 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This is a GitHub workflow defining a set of jobs with a set of steps. 2 | # ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | # 4 | name: Release 5 | 6 | # Always tests wheel building, but only publish to PyPI on pushed tags. 7 | on: 8 | pull_request: 9 | paths-ignore: 10 | - "docs/**" 11 | - ".github/workflows/*.yaml" 12 | - "!.github/workflows/release.yaml" 13 | push: 14 | paths-ignore: 15 | - "docs/**" 16 | - ".github/workflows/*.yaml" 17 | - "!.github/workflows/release.yaml" 18 | branches-ignore: 19 | - "dependabot/**" 20 | - "pre-commit-ci-update-config" 21 | tags: ["**"] 22 | workflow_dispatch: 23 | 24 | jobs: 25 | build-release: 26 | runs-on: ubuntu-22.04 27 | permissions: 28 | # id-token=write is required for pypa/gh-action-pypi-publish, and the PyPI 29 | # project needs to be configured to trust this workflow. 30 | # 31 | # ref: https://github.com/jupyterhub/team-compass/issues/648 32 | # 33 | id-token: write 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-python@v5 38 | with: 39 | python-version: "3.11" 40 | 41 | - name: install build package 42 | run: | 43 | pip install --upgrade pip 44 | pip install build 45 | pip freeze 46 | 47 | - name: build release 48 | run: | 49 | python -m build --sdist --wheel . 50 | ls -l dist 51 | 52 | - name: publish to pypi 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | if: startsWith(github.ref, 'refs/tags/') 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # This is a GitHub workflow defining a set of jobs with a set of steps. 2 | # ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | # 4 | name: Tests 5 | 6 | on: 7 | pull_request: 8 | paths-ignore: 9 | - "docs/**" 10 | - "**.md" 11 | - ".github/workflows/*.yaml" 12 | - "!.github/workflows/test.yaml" 13 | push: 14 | paths-ignore: 15 | - "docs/**" 16 | - "**.md" 17 | - ".github/workflows/*.yaml" 18 | - "!.github/workflows/test.yaml" 19 | branches-ignore: 20 | - "dependabot/**" 21 | - "pre-commit-ci-update-config" 22 | tags: ["**"] 23 | workflow_dispatch: 24 | 25 | jobs: 26 | test: 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | include: 31 | # oldest supported python and jupyterhub version 32 | - python-version: "3.8" 33 | pip-install-spec: "jupyterhub==2.3.0 tornado==5.1.0 sqlalchemy==1.*" 34 | runs-on: ubuntu-20.04 35 | - python-version: "3.9" 36 | pip-install-spec: "jupyterhub==2.* sqlalchemy==1.*" 37 | runs-on: ubuntu-22.04 38 | - python-version: "3.10" 39 | pip-install-spec: "jupyterhub==3.*" 40 | runs-on: ubuntu-22.04 41 | - python-version: "3.11" 42 | pip-install-spec: "jupyterhub==4.*" 43 | runs-on: ubuntu-22.04 44 | - python-version: "3.12" 45 | pip-install-spec: "jupyterhub==5.*" 46 | runs-on: ubuntu-24.04 47 | 48 | # latest version of python and jupyterhub (including pre-releases) 49 | - python-version: "3.x" 50 | pip-install-spec: "--pre jupyterhub" 51 | runs-on: ubuntu-latest 52 | 53 | runs-on: ${{ matrix.runs-on }} 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: actions/setup-node@v4 57 | with: 58 | node-version: "lts/*" 59 | - uses: actions/setup-python@v5 60 | with: 61 | python-version: "${{ matrix.python-version }}" 62 | 63 | - name: Install Node dependencies 64 | run: | 65 | npm install -g configurable-http-proxy 66 | 67 | - name: Install Python dependencies 68 | run: | 69 | pip install ${{ matrix.pip-install-spec }} 70 | pip install -e ".[test]" 71 | pip freeze 72 | 73 | - name: Run tests 74 | # Tests needs to be run as root and we have to specify a non-root 75 | # non-nobody system user to test with. We also need to preserve the PATH 76 | # when running as root. 77 | run: | 78 | sudo -E "PATH=$PATH" bash -c "pytest --cov=systemdspawner --system-test-user=$(whoami)" 79 | 80 | # GitHub action reference: https://github.com/codecov/codecov-action 81 | - uses: codecov/codecov-action@v4 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit is a tool to perform a predefined set of tasks manually and/or 2 | # automatically before git commits are made. 3 | # 4 | # Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level 5 | # 6 | # Common tasks 7 | # 8 | # - Run on all files: pre-commit run --all-files 9 | # - Register git hooks: pre-commit install --install-hooks 10 | # 11 | repos: 12 | # Autoformat: Python code, syntax patterns are modernized 13 | - repo: https://github.com/asottile/pyupgrade 14 | rev: v3.15.0 15 | hooks: 16 | - id: pyupgrade 17 | args: 18 | - --py38-plus 19 | 20 | # Autoformat: Python code 21 | - repo: https://github.com/PyCQA/autoflake 22 | rev: v2.2.1 23 | hooks: 24 | - id: autoflake 25 | # args ref: https://github.com/PyCQA/autoflake#advanced-usage 26 | args: 27 | - --in-place 28 | 29 | # Autoformat: Python code 30 | - repo: https://github.com/pycqa/isort 31 | rev: 5.12.0 32 | hooks: 33 | - id: isort 34 | 35 | # Autoformat: Python code 36 | - repo: https://github.com/psf/black 37 | rev: 23.10.1 38 | hooks: 39 | - id: black 40 | 41 | # Autoformat: markdown, yaml 42 | - repo: https://github.com/pre-commit/mirrors-prettier 43 | rev: v3.0.3 44 | hooks: 45 | - id: prettier 46 | 47 | # Misc... 48 | - repo: https://github.com/pre-commit/pre-commit-hooks 49 | rev: v4.5.0 50 | # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available 51 | hooks: 52 | - id: end-of-file-fixer 53 | - id: check-case-conflict 54 | - id: check-executables-have-shebangs 55 | 56 | # Lint: Python code 57 | - repo: https://github.com/pycqa/flake8 58 | rev: "6.1.0" 59 | hooks: 60 | - id: flake8 61 | 62 | # pre-commit.ci config reference: https://pre-commit.ci/#configuration 63 | ci: 64 | autoupdate_schedule: monthly 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.0 4 | 5 | ### v1.0.2 - 2024-10-20 6 | 7 | #### Bugs fixed 8 | 9 | - Stop assuming PATH env is defined when extra_paths is set, and then initialize to os.defpath [#144](https://github.com/jupyterhub/systemdspawner/pull/144) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 10 | 11 | #### Continuous integration improvements 12 | 13 | - ci: test jupyterhub 5, python 3.12, ubuntu 24.04 [#143](https://github.com/jupyterhub/systemdspawner/pull/143) ([@consideRatio](https://github.com/consideRatio)) 14 | - Bump codecov/codecov-action from 3 to 4 [#140](https://github.com/jupyterhub/systemdspawner/pull/140) ([@consideRatio](https://github.com/consideRatio)) 15 | - Bump actions/setup-python from 4 to 5 [#139](https://github.com/jupyterhub/systemdspawner/pull/139) ([@consideRatio](https://github.com/consideRatio)) 16 | - Bump actions/setup-node from 3 to 4 [#137](https://github.com/jupyterhub/systemdspawner/pull/137) ([@consideRatio](https://github.com/consideRatio)) 17 | - Bump actions/checkout from 3 to 4 [#136](https://github.com/jupyterhub/systemdspawner/pull/136) ([@consideRatio](https://github.com/consideRatio)) 18 | 19 | #### Contributors to this release 20 | 21 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 22 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 23 | 24 | ([GitHub contributors page for this release](https://github.com/jupyterhub/systemdspawner/graphs/contributors?from=2023-06-08&to=2024-10-20&type=c)) 25 | 26 | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3AconsideRatio+updated%3A2023-06-08..2024-10-20&type=Issues)) | @Frank-Steiner ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3AFrank-Steiner+updated%3A2023-06-08..2024-10-20&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Amanics+updated%3A2023-06-08..2024-10-20&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Aminrk+updated%3A2023-06-08..2024-10-20&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Ayuvipanda+updated%3A2023-06-08..2024-10-20&type=Issues)) 27 | 28 | ### v1.0.1 - 2023-06-08 29 | 30 | #### Bugs fixed 31 | 32 | - ensure executable paths are absolute [#129](https://github.com/jupyterhub/systemdspawner/pull/129) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@behrmann](https://github.com/behrmann), [@manics](https://github.com/manics)) 33 | 34 | #### Maintenance and upkeep improvements 35 | 36 | - Use warnings.warn instead of self.log.warning to help avoid duplications [#133](https://github.com/jupyterhub/systemdspawner/pull/133) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 37 | - Cache check of systemd version [#132](https://github.com/jupyterhub/systemdspawner/pull/132) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) 38 | 39 | #### Contributors to this release 40 | 41 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 42 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 43 | 44 | @behrmann ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Abehrmann+updated%3A2023-06-01..2023-06-08&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3AconsideRatio+updated%3A2023-06-01..2023-06-08&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Amanics+updated%3A2023-06-01..2023-06-08&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Aminrk+updated%3A2023-06-01..2023-06-08&type=Issues)) 45 | 46 | ### v1.0.0 - 2023-06-01 47 | 48 | #### Breaking changes 49 | 50 | - Systemd v243+ is now required, and v245+ is recommended. Systemd v245 is 51 | available in for example Ubuntu 20.04+, Debian 11+, and Rocky/CentOS 9+. 52 | - Python 3.8+, JupyterHub 2.3.0+, and Tornado 5.1+ is now required. 53 | - `SystemdSpawner.disable_user_sudo` (influences systemd's `NoNewPrivileges`) 54 | now defaults to `True`, making the installation more secure by default. 55 | 56 | #### Maintenance and upkeep improvements 57 | 58 | - Replace deprecated MemoryLimit with MemoryMax, remove fixme notes [#127](https://github.com/jupyterhub/systemdspawner/pull/127) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda), [@behrmann](https://github.com/behrmann)) 59 | - Rely on systemd-run's --working-directory, and refactor for readability [#124](https://github.com/jupyterhub/systemdspawner/pull/124) ([@consideRatio](https://github.com/consideRatio), [@behrmann](https://github.com/behrmann), [@minrk](https://github.com/minrk)) 60 | - Add MANIFEST.in to bundle LICENSE in source distribution [#122](https://github.com/jupyterhub/systemdspawner/pull/122) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 61 | - Add basic start/stop test against a jupyterhub [#120](https://github.com/jupyterhub/systemdspawner/pull/120) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk), [@yuvipanda](https://github.com/yuvipanda)) 62 | - refactor: remove no longer needed pytest.mark.asyncio [#119](https://github.com/jupyterhub/systemdspawner/pull/119) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 63 | - Require systemd v243+, recommend systemd v245+, test against systemd v245 [#117](https://github.com/jupyterhub/systemdspawner/pull/117) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda), [@minrk](https://github.com/minrk)) 64 | - Add test and release automation [#115](https://github.com/jupyterhub/systemdspawner/pull/115) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 65 | - maint, breaking: require python 3.8+, jupyterhub 2.3.0+, tornado 5.1+ [#114](https://github.com/jupyterhub/systemdspawner/pull/114) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 66 | - Add pre-commit for automated formatting [#108](https://github.com/jupyterhub/systemdspawner/pull/108) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 67 | - Disable user sudo by default [#91](https://github.com/jupyterhub/systemdspawner/pull/91) ([@yuvipanda](https://github.com/yuvipanda), [@consideRatio](https://github.com/consideRatio)) 68 | 69 | #### Documentation improvements 70 | 71 | - docs: add some explanatory notes in files, and small details [#118](https://github.com/jupyterhub/systemdspawner/pull/118) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 72 | - readme: add badges for releases/tests/coverage/issues/discourse [#112](https://github.com/jupyterhub/systemdspawner/pull/112) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 73 | - readme: remove resources section and link to discourse forum instead of mailing list [#111](https://github.com/jupyterhub/systemdspawner/pull/111) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 74 | 75 | #### Continuous integration improvements 76 | 77 | - ci: add dependabot to bump future github actions [#113](https://github.com/jupyterhub/systemdspawner/pull/113) ([@consideRatio](https://github.com/consideRatio), [@yuvipanda](https://github.com/yuvipanda)) 78 | 79 | #### Contributors to this release 80 | 81 | The following people contributed discussions, new ideas, code and documentation contributions, and review. 82 | See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). 83 | 84 | ([GitHub contributors page for this release](https://github.com/jupyterhub/systemdspawner/graphs/contributors?from=2023-01-11&to=2023-06-01&type=c)) 85 | 86 | @astro-arphid ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Aastro-arphid+updated%3A2023-01-11..2023-06-01&type=Issues)) | @behrmann ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Abehrmann+updated%3A2023-01-11..2023-06-01&type=Issues)) | @clhedrick ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Aclhedrick+updated%3A2023-01-11..2023-06-01&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3AconsideRatio+updated%3A2023-01-11..2023-06-01&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Amanics+updated%3A2023-01-11..2023-06-01&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Aminrk+updated%3A2023-01-11..2023-06-01&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fsystemdspawner+involves%3Ayuvipanda+updated%3A2023-01-11..2023-06-01&type=Issues)) 87 | 88 | ## v0.17 - 2023-01-10 89 | 90 | - Don't kill whole server when a single process OOMs, 91 | thanks to [@dragz](https://github.com/dragz) - [PR #101](https://github.com/jupyterhub/systemdspawner/pull/101) 92 | 93 | ## v0.16 - 2022-04-22 94 | 95 | - User variables (like `{USERNAME}`) are expanded in `unit_extra_parameters`, 96 | thanks to [@tullis](https://github.com/tullis) - [PR #83](https://github.com/jupyterhub/systemdspawner/pull/83) 97 | - Some cleanup of packaging metadata, thanks to [@minrk](https://github.com/minrk) - 98 | [PR #75](https://github.com/jupyterhub/systemdspawner/pull/75) 99 | 100 | ## v0.15 - 2020-12-07 101 | 102 | Fixes vulnerability [GHSA-cg54-gpgr-4rm6](https://github.com/jupyterhub/systemdspawner/security/advisories/GHSA-cg54-gpgr-4rm6) affecting all previous releases. 103 | 104 | - Use EnvironmentFile to pass environment variables to units. 105 | 106 | ## v0.14 - 2020-07-20 107 | 108 | - define entrypoints for JupyterHub spawner configuration 109 | - Fixes for CentOS 7 110 | 111 | ## v0.13 - 2019-04-28 112 | 113 | ### Bug Fixes 114 | 115 | - Fix `slice` support by making it a configurable option 116 | 117 | ## v0.12 - 2019-04-17 118 | 119 | ### New Features 120 | 121 | - Allow setting which **Systemd Slice** users' services should belong to. 122 | This lets admins set policy for all JupyterHub users in one go. 123 | [Thanks to [@mariusvniekerk](https://github.com/mariusvniekerk)] 124 | 125 | ### Bug Fixes 126 | 127 | - Handle failed units that need reset. 128 | [thanks to [@RohitK89](https://github.com/RohitK89)] 129 | - Fix bug in cleaning up services from a previously running 130 | JupyterHub. [thanks to [@minrk](https://github.com/minrk)] 131 | 132 | ## v0.11 - 2018-07-12 133 | 134 | ### New Features 135 | 136 | - **Username templates** let you map jupyterhub usernames to different system usernames. Extremely 137 | useful for prefixing usernames to prevent collisions. 138 | 139 | ### Bug fixes 140 | 141 | - Users' home directories now properly read from pwd database, rather than assumed to be under `/home`. 142 | Thanks to [@cpainterwakefield](https://github.com/cpainterwakefield) for reporting & suggested PR! 143 | 144 | ## v0.10 - 2018-07-11 145 | 146 | ### Breaking changes 147 | 148 | - `use_sudo` option is no longer supported. It offered questionable security, 149 | and complicated the code unnecessarily. If 'securely run as normal user with 150 | sudo' is a required feature, we can re-implement it securely later. 151 | - If a path in `readonly_paths` does not exist, spawning user will now fail. 152 | 153 | ### New features 154 | 155 | - **Dynamic users** support, creating users as required with their own 156 | persistent homes with systemd's [dynamic users](http://0pointer.net/blog/dynamic-users-with-systemd.html) 157 | feature. Useful for using with tmpnb. 158 | - **Add additional properties** to the user's systemd unit with `unit_extra_properties`. 159 | Thanks to [@kfix](https://github.com/kfix) for most of the work! 160 | 161 | ### Bug fixes 162 | 163 | - If a user's notebook server service is already running, kill it before 164 | attempting to start a new one. [GitHub Issue](https://github.com/jupyterhub/systemdspawner/issues/7) 165 | 166 | ### Dependency changes 167 | 168 | - Python 3.5 is the minimum supported Python version. 169 | - JupyterHub 0.9 is the minimum supported JupyterHub version. 170 | - Tornado 5.0 is the minimum supported Tornado version. 171 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome! As a [Jupyter] project, you can follow the [Jupyter contributor guide]. 4 | 5 | Make sure to also follow [Project Jupyter's Code of Conduct] for a friendly and 6 | welcoming collaborative environment. 7 | 8 | [jupyter]: https://jupyter.org 9 | [project jupyter's code of conduct]: https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md 10 | [jupyter contributor guide]: https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html 11 | 12 | ## Setting up a local development environment 13 | 14 | To setup a local development environment to test changes to systemdspawner 15 | locally, a pre-requisite is to have systemd running in your system environment. 16 | You can check if you do by running `systemctl --version` in a terminal. 17 | 18 | Start by setting up Python, Node, and Git by reading the _System requirements_ 19 | section in [jupyterhub's contribution guide]. 20 | 21 | Then do the following: 22 | 23 | ```shell 24 | # install configurable-http-proxy, a dependency for running a jupyterhub 25 | npm install -g configurable-http-proxy 26 | ``` 27 | 28 | ```shell 29 | # clone the systemdspawner github repository to your local computer 30 | git clone https://github.com/jupyterhub/systemdspawner 31 | cd systemdspawner 32 | ``` 33 | 34 | ```shell 35 | # install systemdspawner and test dependencies based on code in this folder 36 | pip install --editable ".[test]" 37 | ``` 38 | 39 | We recommend installing `pre-commit` and configuring it to automatically run 40 | autoformatting before you make a git commit. This can be done by: 41 | 42 | ```shell 43 | # configure pre-commit to help with autoformatting checks before commits are made 44 | pip install pre-commit 45 | pre-commit install --install-hooks 46 | ``` 47 | 48 | [jupyterhub's contribution guide]: https://jupyterhub.readthedocs.io/en/stable/contributing/setup.html#system-requirements 49 | 50 | ## Running tests 51 | 52 | A JupyterHub configured to use SystemdSpawner needs to be run as root, so due to 53 | that we need to run tests as root as well. To still have Python available, we 54 | may need to preserve the PATH when switching to root by using sudo as well, and 55 | perhaps also other environment variables. 56 | 57 | ```shell 58 | # run pytest as root, preserving environment variables, including PATH 59 | sudo -E "PATH=$PATH" bash -c "pytest" 60 | ``` 61 | 62 | To run all tests, there needs to be a non-root and non-nobody user specified 63 | explicitly via the systemdspawner defined `--system-test-user=USERNAME` flag for 64 | pytest. 65 | 66 | ```shell 67 | # --system-test-user allows a user server to be started by SystemdSpawner as this 68 | # existing system user, which involves running a user server in the user's home 69 | # directory 70 | sudo -E "PATH=$PATH" bash -c "pytest --system-test-user=USERNAME" 71 | ``` 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Project Jupyter Contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **[Features](#features)** | 2 | **[Requirements](#requirements)** | 3 | **[Installation](#installation)** | 4 | **[Configuration](#configuration)** | 5 | **[Getting help](#getting-help)** | 6 | **[License](#license)** 7 | 8 | # systemdspawner 9 | 10 | [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub-systemdspawner?logo=pypi)](https://pypi.python.org/pypi/jupyterhub-systemdspawner) 11 | [![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub-systemdspawner?logo=conda-forge)](https://anaconda.org/conda-forge/jupyterhub-systemdspawner) 12 | [![GitHub Workflow Status - Test](https://img.shields.io/github/actions/workflow/status/jupyterhub/systemdspawner/test.yaml?logo=github&label=tests)](https://github.com/jupyterhub/systemdspawner/actions) 13 | [![Test coverage of code](https://codecov.io/gh/jupyterhub/systemdspawner/branch/main/graph/badge.svg)](https://codecov.io/gh/jupyterhub/systemdspawner) 14 | [![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/systemdspawner/issues) 15 | [![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) 16 | 17 | The **systemdspawner** enables JupyterHub to spawn single-user 18 | notebook servers using [systemd](https://www.freedesktop.org/wiki/Software/systemd/). 19 | 20 | ## Features 21 | 22 | If you want to use Linux Containers (Docker, rkt, etc) for isolation and 23 | security benefits, but don't want the headache and complexity of 24 | container image management, then you should use the SystemdSpawner. 25 | 26 | With the **systemdspawner**, you get to use the familiar, traditional system 27 | administration tools, whether you love or meh them, without having to learn an 28 | extra layer of container related tooling. 29 | 30 | The following features are currently available: 31 | 32 | 1. Limit maximum memory permitted to each user. 33 | 34 | If they request more memory than this, it will not be granted (`malloc` 35 | will fail, which will manifest in different ways depending on the 36 | programming language you are using). 37 | 38 | 2. Limit maximum CPU available to each user. 39 | 40 | 3. Provide fair scheduling to users independent of the number of processes they 41 | are running. 42 | 43 | For example, if User A is running 100 CPU hogging processes, it will usually 44 | mean User B's 2 CPU hogging processes will never get enough CPU time as scheduling 45 | is traditionally per-process. With Systemd Spawner, both these users' processes 46 | will as a whole get the same amount of CPU time, regardless of number of processes 47 | being run. Good news if you are User B. 48 | 49 | 4. Accurate accounting of memory and CPU usage (via cgroups, which systemd uses internally). 50 | 51 | You can check this out with `systemd-cgtop`. 52 | 53 | 5. `/tmp` isolation. 54 | 55 | Each user gets their own `/tmp`, to prevent accidental information 56 | leakage. 57 | 58 | 6. Spawn notebook servers as specific local users on the system. 59 | 60 | This can replace the need for using SudoSpawner. 61 | 62 | 7. Restrict users from being able to sudo to root (or as other users) from within the 63 | notebook. 64 | 65 | This is an additional security measure to make sure that a compromise of 66 | a jupyterhub notebook instance doesn't allow root access. 67 | 68 | 8. Restrict what paths users can write to. 69 | 70 | This allows making `/` read only and only granting write privileges to 71 | specific paths, for additional security. 72 | 73 | 9. Automatically collect logs from each individual user notebook into 74 | `journald`, which also handles log rotation. 75 | 76 | 10. Dynamically allocate users with Systemd's [dynamic users](http://0pointer.net/blog/dynamic-users-with-systemd.html) 77 | facility. Very useful in conjunction with [tmpauthenticator](https://github.com/jupyterhub/tmpauthenticator). 78 | 79 | ## Requirements 80 | 81 | ### Systemd and Linux distributions 82 | 83 | SystemdSpawner 1 is recommended to be used with systemd version 245 or higher, 84 | but _may_ work with systemd version 243-244 as well. Below are examples of Linux 85 | distributions that use systemd and has a recommended version. 86 | 87 | - Ubuntu 20.04+ 88 | - Debian 11+ 89 | - Rocky 9+ / CentOS 9+ 90 | 91 | The command `systemctl --version` can be used to verify that systemd is used, 92 | and what version is used. 93 | 94 | ### Kernel Configuration 95 | 96 | Certain kernel options need to be enabled for the CPU / Memory limiting features 97 | to work. If these are not enabled, CPU / Memory limiting will just fail 98 | silently. You can check if your kernel supports these features by running 99 | the [`check-kernel.bash`](check-kernel.bash) script. 100 | 101 | ### Root access 102 | 103 | Currently, JupyterHub must be run as root to use Systemd Spawner. `systemd-run` 104 | needs to be run as root to be able to set memory & cpu limits. Simple sudo rules 105 | do not help, since unrestricted access to `systemd-run` is equivalent to root. We 106 | will explore hardening approaches soon. 107 | 108 | ### Local Users 109 | 110 | If running with `c.SystemdSpawner.dynamic_users = False` (the default), each user's 111 | server is spawned to run as a local unix user account. Hence this spawner 112 | requires that all users who authenticate have a local account already present on the 113 | machine. 114 | 115 | If running with `c.SystemdSpawner.dynamic_users = True`, no local user accounts 116 | are required. Systemd will automatically create dynamic users as required. 117 | See [this blog post](http://0pointer.net/blog/dynamic-users-with-systemd.html) for 118 | details. 119 | 120 | ## Installation 121 | 122 | You can install it from PyPI with: 123 | 124 | ```bash 125 | pip install jupyterhub-systemdspawner 126 | ``` 127 | 128 | You can enable it for your jupyterhub with the following lines in your 129 | `jupyterhub_config.py` file 130 | 131 | ```python 132 | c.JupyterHub.spawner_class = "systemd" 133 | ``` 134 | 135 | Note that to confirm systemdspawner has been installed in the correct jupyterhub 136 | environment, a newly generated config file should list `systemdspawner` as one of the 137 | available spawner classes in the comments above the configuration line. 138 | 139 | ## Configuration 140 | 141 | Lots of configuration options for you to choose! You should put all of these 142 | in your `jupyterhub_config.py` file: 143 | 144 | - **[`mem_limit`](#mem_limit)** 145 | - **[`cpu_limit`](#cpu_limit)** 146 | - **[`user_workingdir`](#user_workingdir)** 147 | - **[`username_template`](#username_template)** 148 | - **[`default_shell`](#default_shell)** 149 | - **[`extra_paths`](#extra_paths)** 150 | - **[`unit_name_template`](#unit_name_template)** 151 | - **[`unit_extra_properties`](#unit_extra_properties)** 152 | - **[`isolate_tmp`](#isolate_tmp)** 153 | - **[`isolate_devices`](#isolate_devices)** 154 | - **[`disable_user_sudo`](#disable_user_sudo)** 155 | - **[`readonly_paths`](#readonly_paths)** 156 | - **[`readwrite_paths`](#readwrite_paths)** 157 | - **[`dynamic_users`](#dynamic_users)** 158 | 159 | ### `mem_limit` 160 | 161 | Specifies the maximum memory that can be used by each individual user. It can be 162 | specified as an absolute byte value. You can use the suffixes `K`, `M`, `G` or `T` to 163 | mean Kilobyte, Megabyte, Gigabyte or Terabyte respectively. Setting it to `None` disables 164 | memory limits. 165 | 166 | Even if you want individual users to use as much memory as possible, it is still good 167 | practice to set a memory limit of 80-90% of total physical memory. This prevents one 168 | user from being able to single handedly take down the machine accidentally by OOMing it. 169 | 170 | ```python 171 | c.SystemdSpawner.mem_limit = '4G' 172 | ``` 173 | 174 | Defaults to `None`, which provides no memory limits. 175 | 176 | This info is exposed to the single-user server as the environment variable 177 | `MEM_LIMIT` as integer bytes. 178 | 179 | ### `cpu_limit` 180 | 181 | A float representing the total CPU-cores each user can use. `1` represents one 182 | full CPU, `4` represents 4 full CPUs, `0.5` represents half of one CPU, etc. 183 | This value is ultimately converted to a percentage and rounded down to the 184 | nearest integer percentage, i.e. `1.5` is converted to 150%, `0.125` is 185 | converted to 12%, etc. 186 | 187 | ```python 188 | c.SystemdSpawner.cpu_limit = 4.0 189 | ``` 190 | 191 | Defaults to `None`, which provides no CPU limits. 192 | 193 | This info is exposed to the single-user server as the environment variable 194 | `CPU_LIMIT` as a float. 195 | 196 | Note: there is [a bug](https://github.com/systemd/systemd/issues/3851) in 197 | systemd v231 which prevents the CPU limit from being set to a value greater 198 | than 100%. 199 | 200 | #### CPU fairness 201 | 202 | Completely unrelated to `cpu_limit` is the concept of CPU fairness - that each 203 | user should have equal access to all the CPUs in the absense of limits. This 204 | does not entirely work in the normal case for Jupyter Notebooks, since CPU 205 | scheduling happens on a per-process level, rather than per-user. This means 206 | a user running 100 processes has 100x more access to the CPU than a user running 207 | one. This is far from an ideal situation. 208 | 209 | Since each user's notebook server runs in its own Systemd Service, this problem 210 | is mitigated - all the processes spawned from a user's notebook server are run 211 | in one cgroup, and cgroups are treated equally for CPU scheduling. So independent 212 | of how many processes each user is running, they all get equal access to the CPU. 213 | This works out perfect for most cases, since this allows users to burst up and 214 | use all CPU when nobody else is using CPU & forces them to automatically yield 215 | when other users want to use the CPU. 216 | 217 | ### `user_workingdir` 218 | 219 | The directory to spawn each user's notebook server in. This directory is what users 220 | see when they open their notebooks servers. Usually this is the user's home directory. 221 | 222 | `{USERNAME}` and `{USERID}` in this configuration value will be expanded to the 223 | appropriate values for the user being spawned. 224 | 225 | ```python 226 | c.SystemdSpawner.user_workingdir = '/home/{USERNAME}' 227 | ``` 228 | 229 | Defaults to the home directory of the user. Not respected if `dynamic_users` is true. 230 | 231 | ### `username_template` 232 | 233 | Template for unix username each user should be spawned as. 234 | 235 | `{USERNAME}` and `{USERID}` in this configuration value will be expanded to the 236 | appropriate values for the user being spawned. 237 | 238 | This user should already exist in the system. 239 | 240 | ```python 241 | c.SystemdSpawner.username_template = 'jupyter-{USERNAME}' 242 | ``` 243 | 244 | Not respected if `dynamic_users` is set to True 245 | 246 | ### `default_shell` 247 | 248 | The default shell to use for the terminal in the notebook. Sets the `SHELL` environment 249 | variable to this. 250 | 251 | ```python 252 | c.SystemdSpawner.default_shell = '/bin/bash' 253 | ``` 254 | 255 | Defaults to whatever the value of the `SHELL` environment variable is in the JupyterHub 256 | process, or `/bin/bash` if `SHELL` isn't set. 257 | 258 | ### `extra_paths` 259 | 260 | List of paths that should be prepended to the `PATH` environment variable for the spawned 261 | notebook server. This is easier than setting the `env` property, since you want to 262 | add to PATH, not completely replace it. Very useful when you want to add a virtualenv 263 | or conda install onto the user's `PATH` by default. 264 | 265 | ```python 266 | c.SystemdSpawner.extra_paths = ['/home/{USERNAME}/conda/bin'] 267 | ``` 268 | 269 | `{USERNAME}` and `{USERID}` in this configuration value will be expanded to the 270 | appropriate values for the user being spawned. 271 | 272 | Defaults to `[]` which doesn't add any extra paths to `PATH` 273 | 274 | ### `unit_name_template` 275 | 276 | Template to form the Systemd Service unit name for each user notebook server. This 277 | allows differentiating between multiple jupyterhubs with Systemd Spawner on the same 278 | machine. Should contain only [a-zA-Z0-9_-]. 279 | 280 | ```python 281 | c.SystemdSpawner.unit_name_template = 'jupyter-{USERNAME}-singleuser' 282 | ``` 283 | 284 | `{USERNAME}` and `{USERID}` in this configuration value will be expanded to the 285 | appropriate values for the user being spawned. 286 | 287 | Defaults to `jupyter-{USERNAME}-singleuser` 288 | 289 | ### `unit_extra_properties` 290 | 291 | Dict of key-value pairs used to add arbitrary properties to the spawned Jupyerhub units. 292 | 293 | ```python 294 | c.SystemdSpawner.unit_extra_properties = {'LimitNOFILE': '16384'} 295 | ``` 296 | 297 | Read `man systemd-run` for details on per-unit properties available in transient units. 298 | 299 | `{USERNAME}` and `{USERID}` in each parameter value will be expanded to the 300 | appropriate values for the user being spawned. 301 | 302 | Defaults to `{}` which doesn't add any extra properties to the transient scope. 303 | 304 | ### `isolate_tmp` 305 | 306 | Setting this to true provides a separate, private `/tmp` for each user. This is very 307 | useful to protect against accidental leakage of otherwise private information - it is 308 | possible that libraries / tools you are using create /tmp files without you knowing and 309 | this is leaking info. 310 | 311 | ```python 312 | c.SystemdSpawner.isolate_tmp = True 313 | ``` 314 | 315 | Defaults to false. 316 | 317 | ### `isolate_devices` 318 | 319 | Setting this to true provides a separate, private `/dev` for each user. This prevents the 320 | user from directly accessing hardware devices, which could be a potential source of 321 | security issues. `/dev/null`, `/dev/zero`, `/dev/random` and the ttyp pseudo-devices will 322 | be mounted already, so most users should see no change when this is enabled. 323 | 324 | ```python 325 | c.SystemdSpawner.isolate_devices = True 326 | ``` 327 | 328 | Defaults to false. 329 | 330 | ### `disable_user_sudo` 331 | 332 | Set to true, this prevents users from being able to use `sudo` (or any other means) to 333 | become other users (including root). This helps contain damage from a compromise of a user's 334 | credentials if they also have sudo rights on the machine - a web based exploit will now only 335 | be able to damage the user's own stuff, rather than have complete root access. 336 | 337 | ```python 338 | c.SystemdSpawner.disable_user_sudo = True 339 | ``` 340 | 341 | Defaults to True. 342 | 343 | ### `readonly_paths` 344 | 345 | List of filesystem paths that should be mounted readonly for the users' notebook server. This 346 | will override any filesystem permissions that might exist. Subpaths of paths that are mounted 347 | readonly can be marked readwrite with `readwrite_paths`. This is useful for marking `/` as 348 | readonly & only whitelisting the paths where notebook users can write. If paths listed here 349 | do not exist, you will get an error. 350 | 351 | ```python 352 | c.SystemdSpawner.readonly_paths = ['/'] 353 | ``` 354 | 355 | `{USERNAME}` and `{USERID}` in this configuration value will be expanded to the 356 | appropriate values for the user being spawned. 357 | 358 | Defaults to `None` which disables this feature. 359 | 360 | ### `readwrite_paths` 361 | 362 | List of filesystem paths that should be mounted readwrite for the users' notebook server. This 363 | only makes sense if `readonly_paths` is used to make some paths readonly - this can then be 364 | used to make specific paths readwrite. This does _not_ override filesystem permissions - the 365 | user needs to have appropriate rights to write to these paths. 366 | 367 | ```python 368 | c.SystemdSpawner.readwrite_paths = ['/home/{USERNAME}'] 369 | ``` 370 | 371 | `{USERNAME}` and `{USERID}` in this configuration value will be expanded to the 372 | appropriate values for the user being spawned. 373 | 374 | Defaults to `None` which disables this feature. 375 | 376 | ### `dynamic_users` 377 | 378 | Allocate system users dynamically for each user. 379 | 380 | Uses the DynamicUser= feature of Systemd to make a new system user 381 | for each hub user dynamically. Their home directories are set up 382 | under /var/lib/{USERNAME}, and persist over time. The system user 383 | is deallocated whenever the user's server is not running. 384 | 385 | See http://0pointer.net/blog/dynamic-users-with-systemd.html for more 386 | information. 387 | 388 | ### `slice` 389 | 390 | Run the spawned notebook in a given systemd slice. This allows aggregate configuration that 391 | will apply to all the units that are launched. This can be used (for example) to control 392 | the total amount of memory that all of the notebook users can use. 393 | 394 | See https://samthursfield.wordpress.com/2015/05/07/running-firefox-in-a-cgroup-using-systemd/ for 395 | an example of how this could look. 396 | 397 | For detailed configuration see the [manpage](http://man7.org/linux/man-pages/man5/systemd.slice.5.html) 398 | 399 | ## Getting help 400 | 401 | We encourage you to ask questions in the [Jupyter Discourse forum](https://discourse.jupyter.org/c/jupyterhub). 402 | 403 | ## License 404 | 405 | We use a shared copyright model that enables all contributors to maintain the 406 | copyright on their contributions. 407 | 408 | All code is licensed under the terms of the revised BSD license. 409 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to make a release 2 | 3 | `jupyterhub-systemdspawner` is a package available on [PyPI] and on 4 | [conda-forge]. 5 | 6 | These are the instructions on how to make a release. 7 | 8 | ## Pre-requisites 9 | 10 | - Push rights to this GitHub repository 11 | 12 | ## Steps to make a release 13 | 14 | 1. Create a PR updating `CHANGELOG.md` with [github-activity] and continue when 15 | its merged. 16 | 17 | Advice on this procedure can be found in [this team compass 18 | issue](https://github.com/jupyterhub/team-compass/issues/563). 19 | 20 | 2. Checkout main and make sure it is up to date. 21 | 22 | ```shell 23 | git checkout main 24 | git fetch origin main 25 | git reset --hard origin/main 26 | ``` 27 | 28 | 3. Update the version, make commits, and push a git tag with `tbump`. 29 | 30 | ```shell 31 | pip install tbump 32 | ``` 33 | 34 | `tbump` will ask for confirmation before doing anything. 35 | 36 | ```shell 37 | # Example versions to set: 1.0.0, 1.0.0b1 38 | VERSION= 39 | tbump ${VERSION} 40 | ``` 41 | 42 | Following this, the [CI system] will build and publish a release. 43 | 44 | 4. Reset the version back to dev, e.g. `1.0.1.dev` after releasing `1.0.0`. 45 | 46 | ```shell 47 | # Example version to set: 1.0.1.dev 48 | NEXT_VERSION= 49 | tbump --no-tag ${NEXT_VERSION}.dev 50 | ``` 51 | 52 | 5. Following the release to PyPI, an automated PR should arrive within 24 hours 53 | to [conda-forge/jupyterhub-systemdspawner-feedstock] with instructions on 54 | releasing to conda-forge. You are welcome to volunteer doing this, but aren't 55 | required as part of making this release to PyPI. 56 | 57 | [github-activity]: https://github.com/executablebooks/github-activity 58 | [pypi]: https://pypi.org/project/jupyterhub-systemdspawner/ 59 | [ci system]: https://github.com/jupyterhub/jupyterhub-systemdspawner/actions/workflows/release.yaml 60 | [conda-forge]: https://anaconda.org/conda-forge/jupyterhub-systemdspawner 61 | [conda-forge/jupyterhub-systemdspawner-feedstock]: https://github.com/conda-forge/jupyterhub-systemdspawner-feedstock 62 | -------------------------------------------------------------------------------- /check-kernel.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # SystemdSpawner's config cpu_limit and mem_limit needs certain linux kernel 4 | # options enabled, otherwise they will fail silently. Running this script checks 5 | # if they are. 6 | # 7 | # Partially stolen from https://github.com/docker/docker/blob/master/contrib/check-config.sh 8 | # 9 | possibleConfigs=( 10 | '/proc/config.gz' 11 | "/boot/config-$(uname -r)" 12 | "/usr/src/linux-$(uname -r)/.config" 13 | '/usr/src/linux/.config' 14 | ) 15 | 16 | for tryConfig in "${possibleConfigs[@]}"; do 17 | if [ -e "$tryConfig" ]; then 18 | CONFIG="$tryConfig" 19 | break 20 | fi 21 | done 22 | if [ ! -e "$CONFIG" ]; then 23 | echo "error: cannot find kernel config" 24 | echo "please file an issue at https://github.com/jupyterhub/systemdspawner to help us fix this!" 25 | exit -1 26 | fi 27 | 28 | # Check if memory cgroups are enabled 29 | if zgrep -q 'CONFIG_MEMCG=y' "$CONFIG"; then 30 | echo "Memory Limiting: Enabled" 31 | else 32 | echo "Memory Limiting: Disabled" 33 | fi 34 | 35 | # Check if cfs scheduling is enabled 36 | if zgrep -q 'CONFIG_FAIR_GROUP_SCHED=y' "$CONFIG"; then 37 | echo "CPU Limiting: Enabled" 38 | else 39 | echo "CPU Limiting: Disabled" 40 | fi 41 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | This file should describe how to use the jupyterhub.service file together with a 2 | basic jupyterhub_config.py yet to be provided. 3 | -------------------------------------------------------------------------------- /examples/jupyterhub.service: -------------------------------------------------------------------------------- 1 | # This is a systemd unit file describing a how to start a jupyterhub, provided 2 | # as an example. 3 | # 4 | # systemd ref: https://www.freedesktop.org/wiki/Software/systemd/ 5 | # unit file ref: https://www.freedesktop.org/software/systemd/man/systemd.unit.html 6 | # syntax ref: https://www.freedesktop.org/software/systemd/man/systemd.syntax.html 7 | # 8 | [Service] 9 | ExecStart=/usr/local/bin/jupyterhub --no-ssl --config /etc/local/jupyterhub/jupyterhub_config.py 10 | ReadOnlyDirectories=/ 11 | ReadWriteDirectories=/var/lib/jupyterhub /var/log/ /proc/self/ 12 | WorkingDirectory=/var/local/lib/jupyterhub 13 | CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_AUDIT_WRITE CAP_SETGID CAP_SETUID 14 | PrivateDevices=yes 15 | PrivateTmp=yes 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # autoflake is used for autoformatting Python code 2 | # 3 | # ref: https://github.com/PyCQA/autoflake#readme 4 | # 5 | [tool.autoflake] 6 | ignore-init-module-imports = true 7 | remove-all-unused-imports = true 8 | remove-duplicate-keys = true 9 | remove-unused-variables = true 10 | 11 | 12 | # isort is used for autoformatting Python code 13 | # 14 | # ref: https://pycqa.github.io/isort/ 15 | # 16 | [tool.isort] 17 | profile = "black" 18 | 19 | 20 | # black is used for autoformatting Python code 21 | # 22 | # ref: https://black.readthedocs.io/en/stable/ 23 | # 24 | [tool.black] 25 | # target-version should be all supported versions, see 26 | # https://github.com/psf/black/issues/751#issuecomment-473066811 27 | target_version = [ 28 | "py38", 29 | "py39", 30 | "py310", 31 | "py311", 32 | ] 33 | 34 | 35 | # pytest is used for running Python based tests 36 | # 37 | # ref: https://docs.pytest.org/en/stable/ 38 | # 39 | [tool.pytest.ini_options] 40 | addopts = "--verbose --color=yes --durations=10" 41 | asyncio_mode = "auto" 42 | testpaths = ["tests"] 43 | # warnings we can safely ignore stemming from jupyterhub 3 + sqlalchemy 2 44 | filterwarnings = [ 45 | 'ignore:.*The new signature is "def engine_connect\(conn\)"*:sqlalchemy.exc.SADeprecationWarning', 46 | 'ignore:.*The new signature is "def engine_connect\(conn\)"*:sqlalchemy.exc.SAWarning', 47 | ] 48 | 49 | 50 | # tbump is used to simplify and standardize the release process when updating 51 | # the version, making a git commit and tag, and pushing changes. 52 | # 53 | # ref: https://github.com/your-tools/tbump#readme 54 | # 55 | [tool.tbump] 56 | github_url = "https://github.com/jupyterhub/systemdspawner" 57 | 58 | [tool.tbump.version] 59 | current = "1.0.3.dev" 60 | regex = ''' 61 | (?P\d+) 62 | \. 63 | (?P\d+) 64 | \. 65 | (?P\d+) 66 | (?P
((a|b|rc)\d+)|)
67 |     \.?
68 |     (?P(?<=\.)dev\d*|)
69 | '''
70 | 
71 | [tool.tbump.git]
72 | message_template = "Bump to {new_version}"
73 | tag_template = "v{new_version}"
74 | 
75 | [[tool.tbump.file]]
76 | src = "setup.py"
77 | 


--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
 1 | from setuptools import setup
 2 | 
 3 | with open("README.md") as f:
 4 |     long_description = f.read()
 5 | 
 6 | setup(
 7 |     name="jupyterhub-systemdspawner",
 8 |     version="1.0.3.dev",
 9 |     description="JupyterHub Spawner using systemd for resource isolation",
10 |     long_description=long_description,
11 |     long_description_content_type="text/markdown",
12 |     url="https://github.com/jupyterhub/systemdspawner",
13 |     author="Yuvi Panda",
14 |     author_email="yuvipanda@gmail.com",
15 |     license="3 Clause BSD",
16 |     packages=["systemdspawner"],
17 |     entry_points={
18 |         "jupyterhub.spawners": [
19 |             "systemd = systemdspawner:SystemdSpawner",
20 |             "systemdspawner = systemdspawner:SystemdSpawner",
21 |         ],
22 |     },
23 |     python_requires=">=3.8",
24 |     install_requires=[
25 |         "jupyterhub>=2.3.0",
26 |         "tornado>=5.1",
27 |     ],
28 |     extras_require={
29 |         "test": [
30 |             "pytest",
31 |             "pytest-asyncio",
32 |             "pytest-cov",
33 |             "pytest-jupyterhub",
34 |         ],
35 |     },
36 | )
37 | 


--------------------------------------------------------------------------------
/systemdspawner/__init__.py:
--------------------------------------------------------------------------------
1 | from systemdspawner.systemdspawner import SystemdSpawner
2 | 
3 | __all__ = [SystemdSpawner]
4 | 


--------------------------------------------------------------------------------
/systemdspawner/systemd.py:
--------------------------------------------------------------------------------
  1 | """
  2 | Systemd service utilities.
  3 | 
  4 | Contains functions to start, stop & poll systemd services.
  5 | Probably not very useful outside this spawner.
  6 | """
  7 | 
  8 | import asyncio
  9 | import functools
 10 | import os
 11 | import re
 12 | import shlex
 13 | import shutil
 14 | import subprocess
 15 | import warnings
 16 | 
 17 | # light validation of environment variable keys
 18 | env_pat = re.compile("[A-Za-z_]+")
 19 | 
 20 | RUN_ROOT = "/run"
 21 | 
 22 | 
 23 | def ensure_environment_directory(environment_file_directory):
 24 |     """Ensure directory for environment files exists and is private"""
 25 |     # ensure directory exists
 26 |     os.makedirs(environment_file_directory, mode=0o700, exist_ok=True)
 27 |     # validate permissions
 28 |     mode = os.stat(environment_file_directory).st_mode
 29 |     if mode & 0o077:
 30 |         warnings.warn(
 31 |             f"Fixing permissions on environment directory {environment_file_directory}: {oct(mode)}",
 32 |             RuntimeWarning,
 33 |         )
 34 |         os.chmod(environment_file_directory, 0o700)
 35 |     else:
 36 |         return
 37 |     # Check again after supposedly fixing.
 38 |     # Some filesystems can have weird issues, preventing this from having desired effect
 39 |     mode = os.stat(environment_file_directory).st_mode
 40 |     if mode & 0o077:
 41 |         warnings.warn(
 42 |             f"Bad permissions on environment directory {environment_file_directory}: {oct(mode)}",
 43 |             RuntimeWarning,
 44 |         )
 45 | 
 46 | 
 47 | def make_environment_file(environment_file_directory, unit_name, environment_variables):
 48 |     """Make a systemd environment file
 49 | 
 50 |     - ensures environment directory exists and is private
 51 |     - writes private environment file
 52 |     - returns path to created environment file
 53 |     """
 54 |     ensure_environment_directory(environment_file_directory)
 55 |     env_file = os.path.join(environment_file_directory, f"{unit_name}.env")
 56 |     env_lines = []
 57 |     for key, value in sorted(environment_variables.items()):
 58 |         assert env_pat.match(key), f"{key} not a valid environment variable"
 59 |         env_lines.append(f"{key}={shlex.quote(value)}")
 60 |     env_lines.append("")  # trailing newline
 61 |     with open(env_file, mode="w") as f:
 62 |         # make the file itself private as well
 63 |         os.fchmod(f.fileno(), 0o400)
 64 |         f.write("\n".join(env_lines))
 65 | 
 66 |     return env_file
 67 | 
 68 | 
 69 | async def start_transient_service(
 70 |     unit_name,
 71 |     cmd,
 72 |     args,
 73 |     working_dir,
 74 |     environment_variables=None,
 75 |     properties=None,
 76 |     uid=None,
 77 |     gid=None,
 78 |     slice=None,
 79 | ):
 80 |     """
 81 |     Start a systemd transient service using systemd-run with given command-line
 82 |     options and systemd unit directives (properties).
 83 | 
 84 |     systemd-run ref:             https://www.freedesktop.org/software/systemd/man/systemd-run.html
 85 |     systemd unit directives ref: https://www.freedesktop.org/software/systemd/man/systemd.directives.html
 86 |     """
 87 | 
 88 |     run_cmd = [
 89 |         "systemd-run",
 90 |         "--unit",
 91 |         unit_name,
 92 |         "--working-directory",
 93 |         working_dir,
 94 |     ]
 95 |     if uid is not None:
 96 |         run_cmd += [f"--uid={uid}"]
 97 |     if gid is not None:
 98 |         run_cmd += [f"--gid={gid}"]
 99 |     if slice:
100 |         run_cmd += [f"--slice={slice}"]
101 | 
102 |     properties = (properties or {}).copy()
103 | 
104 |     # Ensure there is a runtime directory where we can put our env file, make
105 |     # runtime directories private by default, and preserve runtime directories
106 |     # across restarts to allow `systemctl restart` to load the env.
107 |     #
108 |     # ref: https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectory=
109 |     # ref: https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectoryMode=
110 |     # ref: https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectoryPreserve=
111 |     #
112 |     properties.setdefault("RuntimeDirectory", unit_name)
113 |     properties.setdefault("RuntimeDirectoryMode", "700")
114 |     properties.setdefault("RuntimeDirectoryPreserve", "restart")
115 | 
116 |     # Ensure that out of memory killing of a process run inside the user server
117 |     # (systemd unit), such as a Jupyter kernel, doesn't result in stopping or
118 |     # killing the user server.
119 |     #
120 |     # ref: https://www.freedesktop.org/software/systemd/man/systemd.service.html#OOMPolicy=
121 |     #
122 |     properties.setdefault("OOMPolicy", "continue")
123 | 
124 |     # Pass configured properties via systemd-run's --property flag
125 |     for key, value in properties.items():
126 |         if isinstance(value, list):
127 |             # The properties dictionary is allowed to have a list of values for
128 |             # each of its keys as a way of allowing the same key to be passed
129 |             # multiple times.
130 |             run_cmd += [f"--property={key}={v}" for v in value]
131 |         else:
132 |             # A string!
133 |             run_cmd.append(f"--property={key}={value}")
134 | 
135 |     # Create and reference an environment variable file in the first
136 |     # RuntimeDirectory entry, which is a whitespace-separated list of directory
137 |     # names.
138 |     #
139 |     # ref: https://www.freedesktop.org/software/systemd/man/systemd.exec.html#EnvironmentFile=
140 |     #
141 |     if environment_variables:
142 |         runtime_dir = os.path.join(RUN_ROOT, properties["RuntimeDirectory"].split()[0])
143 |         environment_file = make_environment_file(
144 |             runtime_dir, unit_name, environment_variables
145 |         )
146 |         run_cmd.append(f"--property=EnvironmentFile={environment_file}")
147 | 
148 |     # make sure cmd[0] is absolute, taking $PATH into account.
149 |     # systemd-run does not use the unit's $PATH environment
150 |     # to resolve relative paths.
151 |     if not os.path.isabs(cmd[0]):
152 |         if environment_variables and "PATH" in environment_variables:
153 |             # if unit specifies a $PATH, use it
154 |             path = environment_variables["PATH"]
155 |         else:
156 |             # search current process $PATH by default.
157 |             # this is the default behavior of shutil.which(path=None)
158 |             # but we still need the value for the error message
159 |             path = os.getenv("PATH", os.defpath)
160 |         exe = cmd[0]
161 |         abs_exe = shutil.which(exe, path=path)
162 |         if not abs_exe:
163 |             raise FileNotFoundError(f"{exe} not found on {path}")
164 |         cmd[0] = abs_exe
165 | 
166 |     # Append typical Spawner "cmd" and "args" on how to start the user server
167 |     run_cmd += cmd + args
168 | 
169 |     proc = await asyncio.create_subprocess_exec(*run_cmd)
170 | 
171 |     return await proc.wait()
172 | 
173 | 
174 | async def service_running(unit_name):
175 |     """
176 |     Return true if service with given name is running (active).
177 |     """
178 |     proc = await asyncio.create_subprocess_exec(
179 |         "systemctl",
180 |         "is-active",
181 |         unit_name,
182 |         # hide stdout, but don't capture stderr at all
183 |         stdout=asyncio.subprocess.DEVNULL,
184 |     )
185 |     ret = await proc.wait()
186 | 
187 |     return ret == 0
188 | 
189 | 
190 | async def service_failed(unit_name):
191 |     """
192 |     Return true if service with given name is in a failed state.
193 |     """
194 |     proc = await asyncio.create_subprocess_exec(
195 |         "systemctl",
196 |         "is-failed",
197 |         unit_name,
198 |         # hide stdout, but don't capture stderr at all
199 |         stdout=asyncio.subprocess.DEVNULL,
200 |     )
201 |     ret = await proc.wait()
202 | 
203 |     return ret == 0
204 | 
205 | 
206 | async def stop_service(unit_name):
207 |     """
208 |     Stop service with given name.
209 | 
210 |     Throws CalledProcessError if stopping fails
211 |     """
212 |     proc = await asyncio.create_subprocess_exec("systemctl", "stop", unit_name)
213 |     await proc.wait()
214 | 
215 | 
216 | async def reset_service(unit_name):
217 |     """
218 |     Reset service with given name.
219 | 
220 |     Throws CalledProcessError if resetting fails
221 |     """
222 |     proc = await asyncio.create_subprocess_exec("systemctl", "reset-failed", unit_name)
223 |     await proc.wait()
224 | 
225 | 
226 | @functools.lru_cache
227 | def get_systemd_version():
228 |     """
229 |     Returns systemd's major version, or None if failing to do so.
230 |     """
231 |     try:
232 |         version_response = subprocess.check_output(["systemctl", "--version"])
233 |     except Exception as e:
234 |         warnings.warn(
235 |             f"Failed to run `systemctl --version` to get systemd version: {e}",
236 |             RuntimeWarning,
237 |             stacklevel=2,
238 |         )
239 | 
240 |     try:
241 |         # Example response from Ubuntu 22.04:
242 |         #
243 |         # systemd 249 (249.11-0ubuntu3.9)
244 |         # +PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT +GNUTLS +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 -PWQUALITY -P11KIT -QRENCODE +BZIP2 +LZ4 +XZ +ZLIB +ZSTD -XKBCOMMON +UTMP +SYSVINIT default-hierarchy=unified
245 |         #
246 |         version = int(float(version_response.split()[1]))
247 |         return version
248 |     except Exception as e:
249 |         warnings.warn(
250 |             f"Failed to parse systemd version from `systemctl --version`: {e}. output={version_response}",
251 |             RuntimeWarning,
252 |             stacklevel=2,
253 |         )
254 |         return None
255 | 


--------------------------------------------------------------------------------
/systemdspawner/systemdspawner.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import os
  3 | import pwd
  4 | import sys
  5 | import warnings
  6 | 
  7 | from jupyterhub.spawner import Spawner
  8 | from jupyterhub.utils import random_port
  9 | from traitlets import Bool, Dict, List, Unicode
 10 | 
 11 | from systemdspawner import systemd
 12 | 
 13 | SYSTEMD_REQUIRED_VERSION = 243
 14 | SYSTEMD_LOWEST_RECOMMENDED_VERSION = 245
 15 | 
 16 | 
 17 | class SystemdSpawner(Spawner):
 18 |     user_workingdir = Unicode(
 19 |         None,
 20 |         allow_none=True,
 21 |         help="""
 22 |         Path to start each notebook user on.
 23 | 
 24 |         {USERNAME} and {USERID} are expanded.
 25 | 
 26 |         Defaults to the home directory of the user.
 27 | 
 28 |         Not respected if dynamic_users is set to True.
 29 |         """,
 30 |     ).tag(config=True)
 31 | 
 32 |     username_template = Unicode(
 33 |         "{USERNAME}",
 34 |         help="""
 35 |         Template for unix username each user should be spawned as.
 36 | 
 37 |         {USERNAME} and {USERID} are expanded.
 38 | 
 39 |         This user should already exist in the system.
 40 | 
 41 |         Not respected if dynamic_users is set to True
 42 |         """,
 43 |     ).tag(config=True)
 44 | 
 45 |     default_shell = Unicode(
 46 |         os.environ.get("SHELL", "/bin/bash"),
 47 |         help="Default shell for users on the notebook terminal",
 48 |     ).tag(config=True)
 49 | 
 50 |     extra_paths = List(
 51 |         [],
 52 |         help="""
 53 |         Extra paths to prepend to the $PATH environment variable.
 54 | 
 55 |         {USERNAME} and {USERID} are expanded
 56 |         """,
 57 |     ).tag(config=True)
 58 | 
 59 |     unit_name_template = Unicode(
 60 |         "jupyter-{USERNAME}-singleuser",
 61 |         help="""
 62 |         Template to use to make the systemd service names.
 63 | 
 64 |         {USERNAME} and {USERID} are expanded}
 65 |         """,
 66 |     ).tag(config=True)
 67 | 
 68 |     isolate_tmp = Bool(
 69 |         False,
 70 |         help="""
 71 |         Give each notebook user their own /tmp, isolated from the system & each other
 72 |         """,
 73 |     ).tag(config=True)
 74 | 
 75 |     isolate_devices = Bool(
 76 |         False,
 77 |         help="""
 78 |         Give each notebook user their own /dev, with a very limited set of devices mounted
 79 |         """,
 80 |     ).tag(config=True)
 81 | 
 82 |     disable_user_sudo = Bool(
 83 |         True,
 84 |         help="""
 85 |         Set to true to disallow becoming root (or any other user) via sudo or other means from inside the notebook
 86 |         """,
 87 |     ).tag(config=True)
 88 | 
 89 |     readonly_paths = List(
 90 |         None,
 91 |         allow_none=True,
 92 |         help="""
 93 |         List of paths that should be marked readonly from the user notebook.
 94 | 
 95 |         Subpaths maybe be made writeable by setting readwrite_paths
 96 |         """,
 97 |     ).tag(config=True)
 98 | 
 99 |     readwrite_paths = List(
100 |         None,
101 |         allow_none=True,
102 |         help="""
103 |         List of paths that should be marked read-write from the user notebook.
104 | 
105 |         Used to make a subpath of a readonly path writeable
106 |         """,
107 |     ).tag(config=True)
108 | 
109 |     unit_extra_properties = Dict(
110 |         {},
111 |         help="""
112 |         Dict of extra properties for systemd-run --property=[...].
113 | 
114 |         Keys are property names, and values are either strings or
115 |         list of strings (for multiple entries). When values are
116 |         lists, ordering is guaranteed. Ordering across keys of the
117 |         dictionary are *not* guaranteed.
118 | 
119 |         Used to add arbitrary properties for spawned Jupyter units.
120 |         Read `man systemd-run` for details on per-unit properties
121 |         available in transient units.
122 |         """,
123 |     ).tag(config=True)
124 | 
125 |     dynamic_users = Bool(
126 |         False,
127 |         help="""
128 |         Allocate system users dynamically for each user.
129 | 
130 |         Uses the DynamicUser= feature of Systemd to make a new system user
131 |         for each hub user dynamically. Their home directories are set up
132 |         under /var/lib/{USERNAME}, and persist over time. The system user
133 |         is deallocated whenever the user's server is not running.
134 | 
135 |         See http://0pointer.net/blog/dynamic-users-with-systemd.html for more
136 |         information.
137 |         """,
138 |     ).tag(config=True)
139 | 
140 |     slice = Unicode(
141 |         None,
142 |         allow_none=True,
143 |         help="""
144 |         Ensure that all users that are created are run within a given slice.
145 |         This allow global configuration of the maximum resources that all users
146 |         collectively can use by creating a a slice beforehand.
147 |         """,
148 |     ).tag(config=True)
149 | 
150 |     def __init__(self, *args, **kwargs):
151 |         super().__init__(*args, **kwargs)
152 |         # All traitlets configurables are configured by now
153 |         self.unit_name = self._expand_user_vars(self.unit_name_template)
154 | 
155 |         self.log.debug(
156 |             "user:%s Initialized spawner with unit %s", self.user.name, self.unit_name
157 |         )
158 | 
159 |         systemd_version = systemd.get_systemd_version()
160 |         if systemd_version is None:
161 |             # not found, nothing to check
162 |             # already warned about this in get_systemd_version
163 |             pass
164 |         elif systemd_version < SYSTEMD_REQUIRED_VERSION:
165 |             self.log.critical(
166 |                 f"systemd version {SYSTEMD_REQUIRED_VERSION} or higher is required, version {systemd_version} is used"
167 |             )
168 |             sys.exit(1)
169 |         elif systemd_version < SYSTEMD_LOWEST_RECOMMENDED_VERSION:
170 |             warnings.warn(
171 |                 f"systemd version {SYSTEMD_LOWEST_RECOMMENDED_VERSION} or higher is recommended, version {systemd_version} is used"
172 |             )
173 | 
174 |     def _expand_user_vars(self, string):
175 |         """
176 |         Expand user related variables in a given string
177 | 
178 |         Currently expands:
179 |           {USERNAME} -> Name of the user
180 |           {USERID} -> UserID
181 |         """
182 |         return string.format(USERNAME=self.user.name, USERID=self.user.id)
183 | 
184 |     def get_state(self):
185 |         """
186 |         Save state required to reconstruct spawner from scratch
187 | 
188 |         We save the unit name, just in case the unit template was changed
189 |         between a restart. We do not want to lost the previously launched
190 |         events.
191 | 
192 |         JupyterHub before 0.7 also assumed your notebook was dead if it
193 |         saved no state, so this helps with that too!
194 |         """
195 |         state = super().get_state()
196 |         state["unit_name"] = self.unit_name
197 |         return state
198 | 
199 |     def load_state(self, state):
200 |         """
201 |         Load state from storage required to reinstate this user's server
202 | 
203 |         This runs after __init__, so we can override it with saved unit name
204 |         if needed. This is useful primarily when you change the unit name template
205 |         between restarts.
206 | 
207 |         JupyterHub before 0.7 also assumed your notebook was dead if it
208 |         saved no state, so this helps with that too!
209 |         """
210 |         if "unit_name" in state:
211 |             self.unit_name = state["unit_name"]
212 | 
213 |     async def start(self):
214 |         self.port = random_port()
215 |         self.log.debug(
216 |             "user:%s Using port %s to start spawning user server",
217 |             self.user.name,
218 |             self.port,
219 |         )
220 | 
221 |         # If there's a unit with this name running already. This means a bug in
222 |         # JupyterHub, a remnant from a previous install or a failed service start
223 |         # from earlier. Regardless, we kill it and start ours in its place.
224 |         # FIXME: Carefully look at this when doing a security sweep.
225 |         if await systemd.service_running(self.unit_name):
226 |             self.log.info(
227 |                 "user:%s Unit %s already exists but not known to JupyterHub. Killing",
228 |                 self.user.name,
229 |                 self.unit_name,
230 |             )
231 |             await systemd.stop_service(self.unit_name)
232 |             if await systemd.service_running(self.unit_name):
233 |                 self.log.error(
234 |                     "user:%s Could not stop already existing unit %s",
235 |                     self.user.name,
236 |                     self.unit_name,
237 |                 )
238 |                 raise Exception(
239 |                     f"Could not stop already existing unit {self.unit_name}"
240 |                 )
241 | 
242 |         # If there's a unit with this name already but sitting in a failed state.
243 |         # Does a reset of the state before trying to start it up again.
244 |         if await systemd.service_failed(self.unit_name):
245 |             self.log.info(
246 |                 "user:%s Unit %s in a failed state. Resetting state.",
247 |                 self.user.name,
248 |                 self.unit_name,
249 |             )
250 |             await systemd.reset_service(self.unit_name)
251 | 
252 |         env = self.get_env()
253 | 
254 |         properties = {}
255 | 
256 |         if self.dynamic_users:
257 |             properties["DynamicUser"] = "yes"
258 |             properties["StateDirectory"] = self._expand_user_vars("{USERNAME}")
259 | 
260 |             # HOME is not set by default otherwise
261 |             env["HOME"] = self._expand_user_vars("/var/lib/{USERNAME}")
262 |             # Set working directory to $HOME too
263 |             working_dir = env["HOME"]
264 |             # Set uid, gid = None so we don't set them
265 |             uid = gid = None
266 |         else:
267 |             try:
268 |                 unix_username = self._expand_user_vars(self.username_template)
269 |                 pwnam = pwd.getpwnam(unix_username)
270 |             except KeyError:
271 |                 self.log.exception(f"No user named {unix_username} found in the system")
272 |                 raise
273 |             uid = pwnam.pw_uid
274 |             gid = pwnam.pw_gid
275 |             if self.user_workingdir is None:
276 |                 working_dir = pwnam.pw_dir
277 |             else:
278 |                 working_dir = self._expand_user_vars(self.user_workingdir)
279 | 
280 |         if self.isolate_tmp:
281 |             properties["PrivateTmp"] = "yes"
282 | 
283 |         if self.isolate_devices:
284 |             properties["PrivateDevices"] = "yes"
285 | 
286 |         if self.extra_paths:
287 |             new_path_list = [self._expand_user_vars(p) for p in self.extra_paths]
288 |             current_or_default_path = env.get("PATH", os.defpath)
289 |             if current_or_default_path:
290 |                 new_path_list.append(current_or_default_path)
291 |             env["PATH"] = ":".join(new_path_list)
292 | 
293 |         env["SHELL"] = self.default_shell
294 | 
295 |         if self.mem_limit is not None:
296 |             properties["MemoryAccounting"] = "yes"
297 |             properties["MemoryMax"] = self.mem_limit
298 | 
299 |         if self.cpu_limit is not None:
300 |             # NOTE: The linux kernel must be compiled with the configuration option
301 |             #       CONFIG_CFS_BANDWIDTH, otherwise CPUQuota doesn't have any
302 |             #       effect.
303 |             #
304 |             #       This can be checked with the check-kernel.bash script in
305 |             #       this git repository.
306 |             #
307 |             #       ref: https://github.com/systemd/systemd/blob/v245/README#L35
308 |             #
309 |             properties["CPUAccounting"] = "yes"
310 |             properties["CPUQuota"] = f"{int(self.cpu_limit * 100)}%"
311 | 
312 |         if self.disable_user_sudo:
313 |             properties["NoNewPrivileges"] = "yes"
314 | 
315 |         if self.readonly_paths is not None:
316 |             properties["ReadOnlyDirectories"] = [
317 |                 self._expand_user_vars(path) for path in self.readonly_paths
318 |             ]
319 | 
320 |         if self.readwrite_paths is not None:
321 |             properties["ReadWriteDirectories"] = [
322 |                 self._expand_user_vars(path) for path in self.readwrite_paths
323 |             ]
324 | 
325 |         for property, value in self.unit_extra_properties.items():
326 |             self.unit_extra_properties[property] = self._expand_user_vars(value)
327 | 
328 |         properties.update(self.unit_extra_properties)
329 | 
330 |         await systemd.start_transient_service(
331 |             self.unit_name,
332 |             cmd=[self._expand_user_vars(c) for c in self.cmd],
333 |             args=[self._expand_user_vars(a) for a in self.get_args()],
334 |             working_dir=working_dir,
335 |             environment_variables=env,
336 |             properties=properties,
337 |             uid=uid,
338 |             gid=gid,
339 |             slice=self.slice,
340 |         )
341 | 
342 |         for i in range(self.start_timeout):
343 |             is_up = await self.poll()
344 |             if is_up is None:
345 |                 return (self.ip or "127.0.0.1", self.port)
346 |             await asyncio.sleep(1)
347 | 
348 |         return None
349 | 
350 |     async def stop(self, now=False):
351 |         await systemd.stop_service(self.unit_name)
352 | 
353 |     async def poll(self):
354 |         if await systemd.service_running(self.unit_name):
355 |             return None
356 |         return 1
357 | 


--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | from traitlets.config import Config
 3 | 
 4 | # pytest-jupyterhub provides a pytest-plugin, and from it we get various
 5 | # fixtures, where we make use of hub_app that builds on MockHub, which defaults
 6 | # to providing a MockSpawner.
 7 | #
 8 | # ref: https://github.com/jupyterhub/pytest-jupyterhub
 9 | # ref: https://github.com/jupyterhub/jupyterhub/blob/4.0.0/jupyterhub/tests/mocking.py#L224
10 | #
11 | pytest_plugins = [
12 |     "jupyterhub-spawners-plugin",
13 | ]
14 | 
15 | 
16 | def pytest_addoption(parser, pluginmanager):
17 |     """
18 |     A pytest hook to register argparse-style options and ini-style config
19 |     values.
20 | 
21 |     We use it to declare command-line arguments.
22 | 
23 |     ref: https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_addoption
24 |     ref: https://docs.pytest.org/en/stable/reference/reference.html#pytest.Parser.addoption
25 |     """
26 |     parser.addoption(
27 |         "--system-test-user",
28 |         help="Test server spawning for this existing system user",
29 |     )
30 | 
31 | 
32 | def pytest_configure(config):
33 |     """
34 |     A pytest hook to adjust configuration before running tests.
35 | 
36 |     We use it to declare pytest marks.
37 | 
38 |     ref: https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_configure
39 |     ref: https://docs.pytest.org/en/stable/reference/reference.html#pytest.Config
40 |     """
41 |     # These markers are registered to avoid warnings triggered by importing from
42 |     # jupyterhub.tests.test_api in test_systemspawner.py.
43 |     config.addinivalue_line("markers", "role: dummy")
44 |     config.addinivalue_line("markers", "user: dummy")
45 |     config.addinivalue_line("markers", "slow: dummy")
46 |     config.addinivalue_line("markers", "group: dummy")
47 |     config.addinivalue_line("markers", "services: dummy")
48 | 
49 | 
50 | @pytest.fixture
51 | async def systemdspawner_config():
52 |     """
53 |     Represents the base configuration of relevance to test SystemdSpawner.
54 |     """
55 |     config = Config()
56 |     config.JupyterHub.spawner_class = "systemd"
57 | 
58 |     # set cookie_secret to avoid having jupyterhub create a file
59 |     config.JupyterHub.cookie_secret = "abc123"
60 | 
61 |     return config
62 | 


--------------------------------------------------------------------------------
/tests/test_systemd.py:
--------------------------------------------------------------------------------
  1 | """
  2 | Test systemd wrapper utilities.
  3 | 
  4 | Must run as root.
  5 | """
  6 | import asyncio
  7 | import os
  8 | import tempfile
  9 | import time
 10 | 
 11 | from systemdspawner import systemd
 12 | 
 13 | 
 14 | def test_get_systemd_version():
 15 |     """
 16 |     Test getting systemd version as an integer, where the assumption for the
 17 |     tests are that systemd is actually running at all.
 18 |     """
 19 |     systemd_version = systemd.get_systemd_version()
 20 |     assert isinstance(
 21 |         systemd_version, int
 22 |     ), "Either systemd wasn't running, or we failed to parse the version into an integer!"
 23 | 
 24 | 
 25 | async def test_simple_start():
 26 |     unit_name = "systemdspawner-unittest-" + str(time.time())
 27 |     await systemd.start_transient_service(
 28 |         unit_name, ["sleep"], ["2000"], working_dir="/"
 29 |     )
 30 | 
 31 |     assert await systemd.service_running(unit_name)
 32 | 
 33 |     await systemd.stop_service(unit_name)
 34 | 
 35 |     assert not await systemd.service_running(unit_name)
 36 | 
 37 | 
 38 | async def test_service_failed_reset():
 39 |     """
 40 |     Test service_failed and reset_service
 41 |     """
 42 |     unit_name = "systemdspawner-unittest-" + str(time.time())
 43 |     # Running a service with an invalid UID makes it enter a failed state
 44 |     await systemd.start_transient_service(
 45 |         unit_name,
 46 |         ["sleep"],
 47 |         ["2000"],
 48 |         working_dir="/systemdspawner-unittest-does-not-exist",
 49 |     )
 50 | 
 51 |     await asyncio.sleep(0.1)
 52 | 
 53 |     assert await systemd.service_failed(unit_name)
 54 | 
 55 |     await systemd.reset_service(unit_name)
 56 | 
 57 |     assert not await systemd.service_failed(unit_name)
 58 | 
 59 | 
 60 | async def test_service_running_fail():
 61 |     """
 62 |     Test service_running failing when there's no service.
 63 |     """
 64 |     unit_name = "systemdspawner-unittest-" + str(time.time())
 65 | 
 66 |     assert not await systemd.service_running(unit_name)
 67 | 
 68 | 
 69 | async def test_env_setting():
 70 |     unit_name = "systemdspawner-unittest-" + str(time.time())
 71 |     with tempfile.TemporaryDirectory() as d:
 72 |         os.chmod(d, 0o777)
 73 |         await systemd.start_transient_service(
 74 |             unit_name,
 75 |             ["/bin/bash"],
 76 |             ["-c", f"pwd; ls -la {d}; env > ./env; sleep 3"],
 77 |             working_dir=d,
 78 |             environment_variables={
 79 |                 "TESTING_SYSTEMD_ENV_1": "TEST 1",
 80 |                 "TESTING_SYSTEMD_ENV_2": "TEST 2",
 81 |             },
 82 |             # set user to ensure we are testing permission issues
 83 |             properties={
 84 |                 "User": "65534",
 85 |             },
 86 |         )
 87 |         env_dir = os.path.join(systemd.RUN_ROOT, unit_name)
 88 |         assert os.path.isdir(env_dir)
 89 |         assert (os.stat(env_dir).st_mode & 0o777) == 0o700
 90 | 
 91 |         # Wait a tiny bit for the systemd unit to complete running
 92 |         await asyncio.sleep(0.1)
 93 |         assert await systemd.service_running(unit_name)
 94 | 
 95 |         env_file = os.path.join(env_dir, f"{unit_name}.env")
 96 |         assert os.path.exists(env_file)
 97 |         assert (os.stat(env_file).st_mode & 0o777) == 0o400
 98 |         # verify that the env had the desired effect
 99 |         with open(os.path.join(d, "env")) as f:
100 |             text = f.read()
101 |             assert "TESTING_SYSTEMD_ENV_1=TEST 1" in text
102 |             assert "TESTING_SYSTEMD_ENV_2=TEST 2" in text
103 | 
104 |         await systemd.stop_service(unit_name)
105 |         assert not await systemd.service_running(unit_name)
106 |         # systemd cleans up env file
107 |         assert not os.path.exists(env_file)
108 | 
109 | 
110 | async def test_workdir():
111 |     unit_name = "systemdspawner-unittest-" + str(time.time())
112 |     _, env_filename = tempfile.mkstemp()
113 |     with tempfile.TemporaryDirectory() as d:
114 |         await systemd.start_transient_service(
115 |             unit_name,
116 |             ["/bin/bash"],
117 |             ["-c", f"pwd > {d}/pwd"],
118 |             working_dir=d,
119 |         )
120 | 
121 |         # Wait a tiny bit for the systemd unit to complete running
122 |         await asyncio.sleep(0.1)
123 | 
124 |         with open(os.path.join(d, "pwd")) as f:
125 |             text = f.read().strip()
126 |             assert text == d
127 | 
128 | 
129 | async def test_executable_path():
130 |     unit_name = "systemdspawner-unittest-" + str(time.time())
131 |     _, env_filename = tempfile.mkstemp()
132 |     with tempfile.TemporaryDirectory() as d:
133 |         await systemd.start_transient_service(
134 |             unit_name,
135 |             ["bash"],
136 |             ["-c", f"pwd > {d}/pwd"],
137 |             working_dir=d,
138 |             environment_variables={"PATH": os.environ["PATH"]},
139 |         )
140 | 
141 |         # Wait a tiny bit for the systemd unit to complete running
142 |         await asyncio.sleep(0.1)
143 | 
144 |         with open(os.path.join(d, "pwd")) as f:
145 |             text = f.read().strip()
146 |             assert text == d
147 | 
148 | 
149 | async def test_slice():
150 |     unit_name = "systemdspawner-unittest-" + str(time.time())
151 |     _, env_filename = tempfile.mkstemp()
152 |     with tempfile.TemporaryDirectory() as d:
153 |         await systemd.start_transient_service(
154 |             unit_name,
155 |             ["/bin/bash"],
156 |             ["-c", f"pwd > {d}/pwd; sleep 10;"],
157 |             working_dir=d,
158 |             slice="user.slice",
159 |         )
160 | 
161 |         # Wait a tiny bit for the systemd unit to complete running
162 |         await asyncio.sleep(0.1)
163 | 
164 |         proc = await asyncio.create_subprocess_exec(
165 |             *["systemctl", "status", unit_name],
166 |             stdout=asyncio.subprocess.PIPE,
167 |             stderr=asyncio.subprocess.PIPE,
168 |         )
169 | 
170 |         stdout, stderr = await proc.communicate()
171 |         assert b"user.slice" in stdout
172 | 
173 | 
174 | async def test_properties_string():
175 |     """
176 |     Test that setting string properties works
177 | 
178 |     - Make a temporary directory
179 |     - Bind mount temporary directory to /bind-test
180 |     - Start process in /bind-test, write to current-directory/pwd the working directory
181 |     - Read it from the *temporary* directory, verify it is /bind-test
182 | 
183 |     This validates the Bind Mount is working, and hence properties are working.
184 |     """
185 |     unit_name = "systemdspawner-unittest-" + str(time.time())
186 |     _, env_filename = tempfile.mkstemp()
187 |     with tempfile.TemporaryDirectory() as d:
188 |         await systemd.start_transient_service(
189 |             unit_name,
190 |             ["/bin/bash"],
191 |             ["-c", "pwd > pwd"],
192 |             working_dir="/bind-test",
193 |             properties={"BindPaths": f"{d}:/bind-test"},
194 |         )
195 | 
196 |         # Wait a tiny bit for the systemd unit to complete running
197 |         await asyncio.sleep(0.1)
198 |         with open(os.path.join(d, "pwd")) as f:
199 |             text = f.read().strip()
200 |             assert text == "/bind-test"
201 | 
202 | 
203 | async def test_properties_list():
204 |     """
205 |     Test setting multiple values for a property
206 | 
207 |     - Make a temporary directory
208 |     - Before starting process, run two mkdir commands to create a nested
209 |       directory. These commands must be run in order by systemd, otherewise
210 |       they will fail. This validates that ordering behavior is preserved.
211 |     - Start a process in temporary directory
212 |     - Write current directory to nested directory created in ExecPreStart
213 | 
214 |     This validates multiple ordered ExcePreStart calls are working, and hence
215 |     properties with lists as values are working.
216 |     """
217 |     unit_name = "systemdspawner-unittest-" + str(time.time())
218 |     _, env_filename = tempfile.mkstemp()
219 |     with tempfile.TemporaryDirectory() as d:
220 |         await systemd.start_transient_service(
221 |             unit_name,
222 |             ["/bin/bash"],
223 |             ["-c", "pwd > test-1/test-2/pwd"],
224 |             working_dir=d,
225 |             properties={
226 |                 "ExecStartPre": [
227 |                     f"/bin/mkdir -p {d}/test-1/test-2",
228 |                 ],
229 |             },
230 |         )
231 | 
232 |         # Wait a tiny bit for the systemd unit to complete running
233 |         await asyncio.sleep(0.1)
234 |         with open(os.path.join(d, "test-1", "test-2", "pwd")) as f:
235 |             text = f.read().strip()
236 |             assert text == d
237 | 
238 | 
239 | async def test_uid_gid():
240 |     """
241 |     Test setting uid and gid
242 | 
243 |     - Make a temporary directory
244 |     - Run service as uid 65534 (nobody) and gid 0 (root)
245 |     - Verify the output of the 'id' command
246 | 
247 |     This validates that setting uid sets uid, gid sets the gid
248 |     """
249 |     unit_name = "systemdspawner-unittest-" + str(time.time())
250 |     _, env_filename = tempfile.mkstemp()
251 |     with tempfile.TemporaryDirectory() as d:
252 |         os.chmod(d, 0o777)
253 |         await systemd.start_transient_service(
254 |             unit_name, ["/bin/bash"], ["-c", "id > id"], working_dir=d, uid=65534, gid=0
255 |         )
256 | 
257 |         # Wait a tiny bit for the systemd unit to complete running
258 |         await asyncio.sleep(0.2)
259 |         with open(os.path.join(d, "id")) as f:
260 |             text = f.read().strip()
261 |             assert text == "uid=65534(nobody) gid=0(root) groups=0(root)"
262 | 


--------------------------------------------------------------------------------
/tests/test_systemdspawner.py:
--------------------------------------------------------------------------------
 1 | from jupyterhub.tests.mocking import public_url
 2 | from jupyterhub.tests.test_api import add_user, api_request
 3 | from jupyterhub.utils import url_path_join
 4 | from tornado.httpclient import AsyncHTTPClient
 5 | 
 6 | from systemdspawner import systemd
 7 | 
 8 | 
 9 | async def test_start_stop(hub_app, systemdspawner_config, pytestconfig):
10 |     """
11 |     Starts a user server, verifies access to its /api/status endpoint, and stops
12 |     the server.
13 | 
14 |     This test is skipped unless pytest is passed --system-test-user=USERNAME.
15 |     The started user server process will run as the user in the user's home
16 |     folder, which perhaps is fine, but maybe not.
17 | 
18 |     About using the root and nobody user:
19 | 
20 |       - A jupyter server started as root will error without the user server being
21 |         passed the --allow-root flag.
22 | 
23 |       - SystemdSpawner runs the user server with a working directory set to the
24 |         user's home home directory, which for the nobody user is /nonexistent on
25 |         ubunutu.
26 |     """
27 |     username = pytestconfig.getoption("--system-test-user", skip=True)
28 |     unit_name = f"jupyter-{username}-singleuser.service"
29 | 
30 |     test_config = {}
31 |     systemdspawner_config.merge(test_config)
32 |     app = await hub_app(systemdspawner_config)
33 | 
34 |     add_user(app.db, app, name=username)
35 |     user = app.users[username]
36 | 
37 |     # start the server with a HTTP POST request to jupyterhub's REST API
38 |     r = await api_request(app, "users", username, "server", method="post")
39 |     pending = r.status_code == 202
40 |     while pending:
41 |         # check server status
42 |         r = await api_request(app, "users", username)
43 |         user_info = r.json()
44 |         pending = user_info["servers"][""]["pending"]
45 |     assert r.status_code in {201, 200}, r.text
46 | 
47 |     # verify the server is started via systemctl
48 |     assert await systemd.service_running(unit_name)
49 | 
50 |     # verify the server is started by accessing the server's api/status
51 |     token = user.new_api_token()
52 |     url = url_path_join(public_url(app, user), "api/status")
53 |     headers = {"Authorization": f"token {token}"}
54 |     resp = await AsyncHTTPClient().fetch(url, headers=headers)
55 |     assert resp.effective_url == url
56 |     resp.rethrow()
57 |     assert "kernels" in resp.body.decode("utf-8")
58 | 
59 |     # stop the server via a HTTP DELETE request to jupyterhub's REST API
60 |     r = await api_request(app, "users", username, "server", method="delete")
61 |     pending = r.status_code == 202
62 |     while pending:
63 |         # check server status
64 |         r = await api_request(app, "users", username)
65 |         user_info = r.json()
66 |         pending = user_info["servers"][""]["pending"]
67 |     assert r.status_code in {204, 200}, r.text
68 | 
69 |     # verify the server is stopped via systemctl
70 |     assert not await systemd.service_running(unit_name)
71 | 


--------------------------------------------------------------------------------