jupyterhub-staticsystemdspawner 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. jupyterhub_staticsystemdspawner-0.1.0/.github/workflows/pypi-publish.yml +72 -0
  2. jupyterhub_staticsystemdspawner-0.1.0/.gitignore +89 -0
  3. jupyterhub_staticsystemdspawner-0.1.0/CHANGELOG.md +5 -0
  4. jupyterhub_staticsystemdspawner-0.1.0/LICENSE +30 -0
  5. jupyterhub_staticsystemdspawner-0.1.0/PKG-INFO +363 -0
  6. jupyterhub_staticsystemdspawner-0.1.0/README.md +351 -0
  7. jupyterhub_staticsystemdspawner-0.1.0/jupyterhub_staticsystemdspawner.egg-info/PKG-INFO +363 -0
  8. jupyterhub_staticsystemdspawner-0.1.0/jupyterhub_staticsystemdspawner.egg-info/SOURCES.txt +21 -0
  9. jupyterhub_staticsystemdspawner-0.1.0/jupyterhub_staticsystemdspawner.egg-info/dependency_links.txt +1 -0
  10. jupyterhub_staticsystemdspawner-0.1.0/jupyterhub_staticsystemdspawner.egg-info/entry_points.txt +6 -0
  11. jupyterhub_staticsystemdspawner-0.1.0/jupyterhub_staticsystemdspawner.egg-info/requires.txt +2 -0
  12. jupyterhub_staticsystemdspawner-0.1.0/jupyterhub_staticsystemdspawner.egg-info/top_level.txt +1 -0
  13. jupyterhub_staticsystemdspawner-0.1.0/pyproject.toml +48 -0
  14. jupyterhub_staticsystemdspawner-0.1.0/setup.cfg +4 -0
  15. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/__init__.py +5 -0
  16. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/resources/defaultserver.service +17 -0
  17. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/resources/hardening.conf +8 -0
  18. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/resources/namedserver.service +17 -0
  19. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/resources/slice.conf +5 -0
  20. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/staticsystemdspawner.py +287 -0
  21. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/systemd.py +99 -0
  22. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/unitgenerator.py +136 -0
  23. jupyterhub_staticsystemdspawner-0.1.0/staticsystemdspawner/wrapper.py +43 -0
@@ -0,0 +1,72 @@
1
+ name: Build and publish Python package to PyPI or TestPyPI
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ build-wheels-and-sdist:
7
+ name: Build wheels and sdist
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - name: Fetch sources
11
+ uses: actions/checkout@v7
12
+ with:
13
+ fetch-depth: 0
14
+ persist-credentials: false
15
+
16
+ - name: Set up Python 3.13
17
+ uses: actions/setup-python@v7
18
+ with:
19
+ python-version: '3.13'
20
+
21
+ - name: Build a binary wheel and a source tarball
22
+ run: pipx run build --sdist --wheel --outdir dist/ .
23
+
24
+ - uses: actions/upload-artifact@v7
25
+ with:
26
+ name: cibuilds
27
+ path: |
28
+ dist/*.tar.gz
29
+ dist/*.whl
30
+
31
+ publish-to-testpypi:
32
+ needs: [build-wheels-and-sdist]
33
+ name: Upload build to TestPyPI
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: testpypi
37
+ url: https://test.pypi.org/p/jupyterhub-staticsystemdspawner
38
+ permissions:
39
+ id-token: write
40
+ steps:
41
+ - name: Downloads build wheels and sdist
42
+ uses: actions/download-artifact@v8
43
+ with:
44
+ pattern: cibuilds
45
+ path: dist
46
+ merge-multiple: true
47
+
48
+ - name: Publish package to TestPyPI
49
+ uses: pypa/gh-action-pypi-publish@release/v1
50
+ with:
51
+ repository-url: https://test.pypi.org/legacy/
52
+
53
+ build-and-publish-to-pypi:
54
+ if: startsWith(github.ref, 'refs/tags/')
55
+ needs: [build-wheels-and-sdist]
56
+ name: Upload release to PyPI
57
+ runs-on: ubuntu-latest
58
+ environment:
59
+ name: pypi
60
+ url: https://pypi.org/p/jupyterhub-staticsystemdspawner
61
+ permissions:
62
+ id-token: write
63
+ steps:
64
+ - name: Downloads build wheels and sdist
65
+ uses: actions/download-artifact@v8
66
+ with:
67
+ pattern: cibuilds
68
+ path: dist
69
+ merge-multiple: true
70
+
71
+ - name: Publish package to PyPI
72
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## v0.1
4
+
5
+ - Initial Release
@@ -0,0 +1,30 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2017-2024, Project Jupyter Contributors
4
+ 2024-2026, Jörg Behrmann
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ * Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ * Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ * Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,363 @@
1
+ Metadata-Version: 2.4
2
+ Name: jupyterhub-staticsystemdspawner
3
+ Version: 0.1.0
4
+ Summary: JupyterHub Spawner using systemd unit files
5
+ Author-email: Jörg Behrmann <behrmann@physik.fu-berlin.de>
6
+ License-Expression: BSD-3-Clause
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: jupyterhub>=0.9
10
+ Requires-Dist: tornado>=5.0
11
+ Dynamic: license-file
12
+
13
+ # staticsystemdspawner
14
+
15
+ The **staticsystemdspawner** enables JupyterHub to spawn single-user notebook
16
+ servers using the [systemd](https://www.freedesktop.org/wiki/Software/systemd/)
17
+ service manager, that is available on all current major Linux distributions.
18
+
19
+ This project is a friendly fork of [systemdspawner](https://github.com/jupyterhub/systemdspawner).
20
+
21
+ ## How is this different from systemdspawner?
22
+
23
+ systemdspawner uses `systemd-run` to start transient units that are constructed
24
+ on the fly by systemdspawner itself. This makes it necessary for JupyterHub to
25
+ be able to use `systemd-run`.
26
+
27
+ staticsystemdspawner instead uses pre-generated unit files that only need to be
28
+ started, which makes the scope of what to allow JupyterHub to do smaller and
29
+ also allows to customise the units to a much greater degree without the need to
30
+ pipe all systemd options through the spawner.
31
+
32
+ ## Features
33
+
34
+ The features of staticsystemdspawner are broadly similar to those of
35
+ systemdspawner, but very open-ended. If you can think of it, you can probably
36
+ hack it up without having to touch the code of systemdspawner.
37
+
38
+ You can use everything in the systemd suite, which gets you all sorts of
39
+ sandboxing and resource control options, see the man pages
40
+ - `man systemd.service`,
41
+ - `man systemd.exec`, and
42
+ - `man systemd.resource-control`
43
+ for details and `man systemd.directives` if need an index.
44
+
45
+ ## Requirements
46
+
47
+ ### Systemd
48
+
49
+ staticsystemdspawner requires you to use a Linux Distro that ships with at least
50
+ systemd v252, which shipped by default in Debian bookworm (released in 2023),
51
+ but is also available in Debian bullseye (released in 2021) via Debian
52
+ backports. It might also work with systemd v247 available in Debian bullseye,
53
+ but this has not been tested, we recommend using at least version v252.
54
+
55
+ Consult `systemctl --version` for your version information.
56
+
57
+ ### Polkit
58
+
59
+ To control unit lifecycle staticsystemdspawner uses polkit. A version larger
60
+ than polkit 105 is needed. These are available in Debian starting with Debian
61
+ bookworm or from the experimental repositories before that.
62
+
63
+ ### Local unit files for all users
64
+
65
+ You will have to add local unit files for all users.
66
+
67
+ ### Linux Distro compatibility
68
+
69
+ Anything with a recent systemd (v252 or newer) and polkit (newer than 105),
70
+ should work, i.e. all Debian versions starting with Debian bookworm, all Ubuntu
71
+ versions starting with Ubuntu 24.04 LTS and all versions of RHEL and its
72
+ derivatives starting with RHEL9 should work out of the box
73
+
74
+ ## Installation
75
+
76
+ You cannot install it from PyPI yet, but need to install it from git
77
+
78
+ ```bash
79
+ pip install git+https://<url to the repo>
80
+ ```
81
+
82
+ You can enable it for your jupyterhub with the following lines in your
83
+ `jupyterhub_config.py` file
84
+
85
+ ```python
86
+ c.JupyterHub.spawner_class = 'staticsystemdspawner.StaticSystemdSpawner'
87
+ ```
88
+
89
+ Note that to confirm systemdspawner has been installed in the correct jupyterhub
90
+ environment, a newly generated config file should list `staticsystemdspawner` as one of the
91
+ available spawner classes in the comments above the configuration line.
92
+
93
+ The Hub itself can be run as an unprivileged user
94
+ ```ini
95
+ # jupyterhub.service
96
+ [Unit]
97
+ Description=Jupyterhub
98
+ After=network.target postgresql.service jupyterhub-httpproxy.service
99
+ Wants=network.target postgresql.service
100
+ PartOf=jupyterhub.target
101
+
102
+ [Service]
103
+ User=jupyter
104
+ Environment="PATH=/opt/jupyter/hubenv/bin:/usr/bin:/usr/local/bin:/bin"
105
+ ConfigurationDirectory=jupyterhub
106
+ RuntimeDirectory=jupyterhub
107
+ StateDirectory=jupyterhub
108
+
109
+ LoadCredentialEncrypted=jupyterhub.dbpassword
110
+ LoadCredentialEncrypted=jupyterhub.authcred
111
+ LoadCredentialEncrypted=jupyterhub.cookiesecret
112
+ LoadCredentialEncrypted=jupyterhub.proxy.authtoken
113
+
114
+ ExecStart=/opt/jupyter/hubenv/bin/jupyterhub --config ${CONFIGURATION_DIRECTORY}/jupyterhub_config.py
115
+
116
+ PrivateTmp=yes
117
+ NoNewPrivileges=yes
118
+ ProtectSystem=strict
119
+ ProtectHome=yes
120
+ WorkingDirectory=/var/lib/jupyterhub
121
+ ```
122
+ and it is recommended to start it as a part of a target, e.g.
123
+ ```ini
124
+ [Unit]
125
+ Description=JupyterHub API services
126
+ Wants=jupyterhub.service jupyterhub-httpproxy.service nginx.service
127
+
128
+ [Install]
129
+ WantedBy=multi-user.target
130
+ ```
131
+ assuming the existence of a separate `jupyterhub-httpproxy.service` and running the Hub behind Nginx as a proxy.
132
+
133
+ ### Aside: Running the configurable http proxy as dynamic user
134
+
135
+ ```ini
136
+ # jupyterhub-httpproxy.service
137
+ [Unit]
138
+ Description=JupyterHub HTTP proxy
139
+ After=network-online.target
140
+ PartOf=jupyterhub.target
141
+
142
+ [Service]
143
+ User=jupyterproxy
144
+ DynamicUser=yes
145
+
146
+ LoadCredentialEncrypted=jupyterhub.proxy.authtoken
147
+ ExecStart=/opt/jupyter/configurable-http-proxy-wrapper \
148
+ --ip 127.0.0.1 \
149
+ --port 8000 \
150
+ --api-ip 127.0.0.1 \
151
+ --api-port 8001 \
152
+ --host-routing \
153
+ --default-target=http://127.0.0.1:8081 \
154
+ --error-target=http://127.0.0.1:8081/hub/error
155
+
156
+ SuccessExitStatus=0 2
157
+
158
+ PrivateTmp=yes
159
+ ProtectSystem=strict
160
+ ProtectHome=yes
161
+ ```
162
+
163
+ ### Polkit
164
+
165
+ To authorize JupyterHub to start and stop units a polkit rule is used.
166
+ ```js
167
+ polkit.addRule(function(action, subject) {
168
+ if (action.id == "org.freedesktop.systemd1.manage-units") {
169
+ polkit.log("action=" + action);
170
+ polkit.log("subject=" + subject);
171
+ if (action.lookup("unit").match(/^jupyterhub-singleuser-[a-z][a-z0-9]{0,30}.service$/) ||
172
+ action.lookup("unit").match(/^jupyterhub-singleuser-[a-z][a-z0-9]{0,30}@[a-zA-Z0-9_\\]+.service$/)) {
173
+ var verb = action.lookup("verb");
174
+ if ((verb == "start" || verb == "stop" || verb == "reset-failed") && subject.user == "jupyter") {
175
+ return polkit.Result.YES;
176
+ }
177
+ }
178
+ }
179
+ });
180
+ ```
181
+
182
+ If you have a sufficiently new polkit (124 or newer) you could also add
183
+ additional checks using `subject.system_unit == "jupyterhub.service"` to check
184
+ whether the this is being called from the context of `jupyterhub.service` and
185
+ `subject.no_new_privileges == true` that the `NoNewPrivileges=` setting on the
186
+ Hub is set.
187
+
188
+ ### Unit files
189
+
190
+ This package ships with a script `jupyter-staticsystemdspawner-unitgenerator`
191
+ that can be used to generate unit files for users. These are meant as examples
192
+ and should be changed to fit the needs of your deployment.
193
+
194
+ They use a wrapper script (`jupyter-singleuser-wrapper`) for environment setup,
195
+ since JupyterHub usually reads its configuration from environment files. Since
196
+ the hub is running as an unprivileged users and so are the singleuser servers,
197
+ systemd credentials are used to pass information from the hub to the singleuser
198
+ servers. This wrapper script will therefore be needed to start the singleuser
199
+ servers.
200
+
201
+ An example for such a file:
202
+ ```ini
203
+ [Unit]
204
+ Description=Jupyterhub Single-User Server for user foobar
205
+
206
+ [Service]
207
+ Type=simple
208
+ User=foobar
209
+
210
+ Environment="PATH=/opt/jupyter/hubenv/bin:/usr/bin:/usr/local/bin"
211
+
212
+ LoadCredential=spawnerconf:/run/jupyterhub/spawnerconf/foobar/default
213
+ ExecStart=/opt/jupyter/hubenv/bin/jupyter-singleuser-wrapper
214
+
215
+ WorkingDirectory=/home/foobar
216
+ StateDirectory=jupyterhub-foobar
217
+
218
+ # ensure that one singleuser server going over the memory limit
219
+ # does not kill all the user's servers
220
+ OOMPolicy=continue
221
+ # use the jupyterhub PAM module for session setup (see below)
222
+ PAMName=jupyterhub
223
+ ```
224
+
225
+ If you want to configure settings for all singleuser servers, it is recommend to use drop in configuration and make use of the dash namespacing of units, e.g.
226
+ ```ini
227
+ # jupyterhub-singleuser-.service.d/30-hardening.conf
228
+ [Service]
229
+ PrivateTmp=yes
230
+ PrivateDevices=yes
231
+ ProtectSystem=strict
232
+ ProtectKernelTunables=yes
233
+ ProtectControlGroups=yes
234
+ ReadWritePaths=/home/%j
235
+ NoNewPrivileges=yes
236
+ ```
237
+
238
+ If you want to configure resource constraints, it is recommend to do that via a
239
+ slice unit and configuring `Slice=` for the singleuser unit. For an alternative
240
+ approach using PAM see below.
241
+
242
+ ### PAM config
243
+
244
+ When using the `PAMName` service setting an existing PAM module needs to be
245
+ provided that will do session setup. This setting is independent of the used
246
+ authenticator.
247
+
248
+ Using a PAM module one can register JupyterHub singleuser sessions with `loginctl`.
249
+
250
+ The following example is adapted from the `login` PAM module.
251
+ ```
252
+ #
253
+ # The PAM configuration file for the JupyterHub systemd-spawner service
254
+ #
255
+ # SELinux needs to be the first session rule. This ensures that any
256
+ # lingering context has been cleared. Without this it is possible
257
+ # that a module could execute code in the wrong domain.
258
+ # When the module is present, "required" would be sufficient (When SELinux
259
+ # is disabled, this returns success.)
260
+ session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
261
+
262
+ # Sets the loginuid process attribute
263
+ session required pam_loginuid.so
264
+
265
+ # SELinux needs to intervene at login time to ensure that the process
266
+ # starts in the proper default security context. Only sessions which are
267
+ # intended to run in the user's context should be run after this.
268
+ # pam_selinux.so changes the SELinux context of the used TTY and configures
269
+ # SELinux in order to transition to the user context with the next execve()
270
+ # call.
271
+ session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
272
+ # When the module is present, "required" would be sufficient (When SELinux
273
+ # is disabled, this returns success.)
274
+
275
+ # This module parses environment configuration file(s)
276
+ # and also allows you to use an extended config
277
+ # file /etc/security/pam_env.conf.
278
+ #
279
+ # parsing /etc/environment needs "readenv=1"
280
+ session required pam_env.so readenv=1
281
+ # locale variables are also kept into /etc/default/locale in etch
282
+ # reading this file *in addition to /etc/environment* does not hurt
283
+ session required pam_env.so readenv=1 envfile=/etc/default/locale
284
+
285
+ # Sets up user limits according to /etc/security/limits.conf
286
+ # (Replaces the use of /etc/limits in old login)
287
+ session required pam_limits.so
288
+
289
+ # Prints the last login info upon successful login
290
+ # (Replaces the `LASTLOG_ENAB' option from login.defs)
291
+ session optional pam_lastlog.so
292
+
293
+ # Create a new session keyring.
294
+ session optional pam_keyinit.so force revoke
295
+
296
+ # Standard Un*x account and session
297
+ @include common-session
298
+ ```
299
+
300
+ When using such a setup the resource constraints for a user can be configured on
301
+ their user slice, e.g.
302
+ ```ini
303
+ # user-123456.slice
304
+ [Slice]
305
+ TasksMax=33%
306
+ CPUQuota=400%
307
+ MemoryHigh=16G
308
+ MemoryMax=17G
309
+ ```
310
+
311
+ ## Configuration
312
+
313
+ For the most part configuration besides setting the spawner class should not be
314
+ required.
315
+
316
+ ### `default_server_unit_name_template`
317
+
318
+ The name for the unit that is started by the spawner. It defaults to
319
+ `jupyterhub-singleuser-{USERNAME}.service` and the username is interpolated by
320
+ the spawner. Since the user is identified by the last part of the name after the
321
+ last dash and before the `.service` extension, names not following this format
322
+ will probably not work as exptected.
323
+
324
+ ### `named_server_unit_name_template`
325
+
326
+ The name for the unit that is started by the spawner. It defaults to
327
+ `jupyterhub-singleuser-{USERNAME}@.service` and the username is interpolated by
328
+ the spawner. Since the user is identified by the last part of the name after the
329
+ last dash and before the @, names not following this format will probably not
330
+ work as exptected.
331
+
332
+ The unit instance passed in will be the name of the named server escaped with
333
+ `systemd-escape`, i.e. to get the unescaped name you will have to use the `%I`
334
+ specifier in the unit file.
335
+
336
+ ### `unit_generator`
337
+
338
+ The name of a template unit started when a user unit does not exist. It can be
339
+ used to generate them. Defaults to `None`, which means unit files will have to
340
+ be generated for all users beforehand.
341
+
342
+ ## Getting help
343
+
344
+ We encourage you to ask questions in the repository.
345
+
346
+ ## License
347
+
348
+ We use a shared copyright model that enables all contributors to maintain the
349
+ copyright on their contributions.
350
+
351
+ All code is licensed under the terms of the revised BSD license.
352
+
353
+ ## Resources
354
+
355
+ ### JupyterHub and systemdspawner
356
+
357
+ - [Documentation for JupyterHub](http://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
358
+ - [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
359
+
360
+ ### Jupyter
361
+
362
+ - [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
363
+ - [Project Jupyter website](https://jupyter.org)