Repository: jupyterhub/jupyterhub

Issue:
Cull_Idle Instructions
I'm having trouble using the [cull_idle example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/cull-idle#configure-cull-idle-to-run-as-a-hub-managed-service) as a managed service. I added the [example config section](https://github.com/jupyterhub/jupyterhub/blob/master/examples/cull-idle/jupyterhub_config.py) to the bottom of my existing `jupyterhub_config.py` file:

``` python
c = get_config()
docker_ip = '172.17.0.1' 
hub_name='127.0.0.1'

c.JupyterHub.ip = hub_name
c.JupyterHub.hub_ip = docker_ip
c.JupyterHub.debug_proxy = True
c.JupyterHub.port = 8000

c.JupyterHub.cleanup_servers = False
c.JupyterHub.cleanup_proxy = True
c.JupyterHub.ssl_key = '/etc/jupyterhub/ssl/jhub.key'
c.JupyterHub.ssl_cert = '/etc/jupyterhub/ssl/jhub.crt'


c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
c.DockerSpawner.hub_ip_connect = docker_ip
c.DockerSpawner.container_ip = docker_ip 


c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/jupyterhub_cookie_secret'
c.JupyterHub.log_level = 'DEBUG'

#CULL_IDLE SECTION
c.JupyterHub.services = [{
    'name': 'cull-idle',
    'admin': True,
    'command': 'python cull_idle_servers.py --timeout=3600'.split()}]
```

I see the resulting error:

```
[C 2016-09-20 02:28:37.026 JupyterHub app:1444] Failed to start service cull-idle
    Traceback (most recent call last):
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/app.py", line 1442, in start
        yield service.start()
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/services/service.py", line 228, in start
        env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
    AttributeError: 'NoneType' object has no attribute 'base_url'
```

Any ideas of what could be causing this? Thank you!



Discussion:
@whitead It looks as if the `base_url` is not set in `jupyterhub_config.py`. Try adding the following to your `jupyterhub_config.py`:

```
c.JupyterHub.base_url= '/'
```

This section and the following in the JupyterHub docs on services provide a bit more information on the url and the information passed between the Hub and the service:
https://jupyterhub.readthedocs.io/en/latest/services.html#properties-of-a-service

This is the environment variables passed from the Hub to the service:

```
JUPYTERHUB_SERVICE_NAME: 'cull-idle'
JUPYTERHUB_API_TOKEN: API token assigned to the service
JUPYTERHUB_API_URL: http://127.0.0.1:8080/hub/api
JUPYTERHUB_BASE_URL: https://mydomain[:port]
JUPYTERHUB_SERVICE_PREFIX: /services/cull-idle/
```

Hi, thank you for the reply. From what I understand, that is default value. I tried setting it to different options (including `'/'`) though and there was no change. I should mention that the `NoneType` problem is the `self.server` which is an `orm.py` `Server` class. 

@whitead Since services are still under development for the upcoming release, it's possible that you have uncovered a bug. Thanks for the additional detail. We'll take a look at the underlying code. If possible, please run with `--debug` and share with us. Thanks.

One additional question: which version of Dockerspawner are you using? A recent change in Dockerspawner (not yet in the released stable version) may be related (https://github.com/jupyterhub/dockerspawner/pull/115/commits/6bdbb64f742d8e819551cea1537050551af0f663)
cc @minrk

Well the services look like they'll be great when ready! I am on latest git repositories for everything, including Dockerspawner (where I just did a PR by the way) and configurable-http-proxy.

Here's more of the surrounding logfile. I've omitted my whitelist of users:

```
[I 2016-09-20 11:34:37.691 JupyterHub app:705] Loading cookie_secret from /srv/jupyterhub/jupyterhub_cookie_secret
[D 2016-09-20 11:34:37.691 JupyterHub app:777] Connecting to db: sqlite:///jupyterhub.sqlite
[I 2016-09-20 11:34:39.035 JupyterHub app:1431] Hub API listening on http://172.17.0.1:8081/hub/
[I 2016-09-20 11:34:39.040 JupyterHub app:1166] Starting proxy @ http://127.0.0.1:8000/
[D 2016-09-20 11:34:39.040 JupyterHub app:1167] Proxy cmd: ['configurable-http-proxy', '--ip', '127.0.0.1', '--port', '8000', '--api-ip', '127.0.0.1', '--api-port', '8001', '--default-target', 'http://172.17.0.1:8081', '--error-target', 'http://172.17.0.1:8081/hub/error', '--log-level', 'debug', '--ssl-key', '/etc/jupyterhub/ssl/jhub.key', '--ssl-cert', '/etc/jupyterhub/ssl/jhub.crt']
11:34:39.273 - info: [ConfigProxy] Proxying https://127.0.0.1:8000 to http://172.17.0.1:8081
11:34:39.277 - info: [ConfigProxy] Proxy API at http://127.0.0.1:8001/api/routes
[D 2016-09-20 11:34:39.346 JupyterHub app:1195] Proxy started and appears to be up
[I 2016-09-20 11:34:39.347 JupyterHub service:220] Starting service 'cull-idle': ['python', 'cull_idle_severs.py', '--timeout=25']
[C 2016-09-20 11:34:39.350 JupyterHub app:1444] Failed to start service cull-idle
    Traceback (most recent call last):
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/app.py", line 1442, in start
        yield service.start()
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/services/service.py", line 228, in start
        env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
    AttributeError: 'NoneType' object has no attribute 'base_url'

[D 2016-09-20 11:34:39.351 JupyterHub application:642] Exiting application: jupyterhub
```

So I won't pretend to understand if this is right or wrong, but here's a patch that fixes my problem. I'm pretty sure the missing `@gen.coroutine` is a bug, but I'm not sure if just removing the api prefix is correct. The issue is that managed services don't get access to an `orm.server` (see `jupyterhub/app.py:1020`) so they can't ever have an api prefix set.

``` diff
diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py
index bfa1736..b0e9d40 100644
--- a/jupyterhub/services/service.py
+++ b/jupyterhub/services/service.py
@@ -82,6 +82,7 @@ class _ServiceSpawner(LocalProcessSpawner):
             return
         return super().make_preexec_fn(name)

+    @gen.coroutine
     def start(self):
         """Start the process"""
         env = self.get_env()
@@ -213,6 +214,7 @@ class Service(LoggingConfigurable):
             managed=' managed' if self.managed else '',
         )

+    @gen.coroutine
     def start(self):
         """Start a managed service"""
         if not self.managed:
@@ -225,7 +227,6 @@ class Service(LoggingConfigurable):
         env['JUPYTERHUB_API_TOKEN'] = self.api_token
         env['JUPYTERHUB_API_URL'] = self.hub_api_url
         env['JUPYTERHUB_BASE_URL'] = self.base_url
-        env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
         env['JUPYTERHUB_SERVICE_URL'] = self.url

         self.spawner = _ServiceSpawner(
```

I'll just leave this open if you don't mind and so we can see if @minrk finds this useful. 

@whitead Thanks! Definitely useful since you've identified a root cause. Yes, please leave it open for @minrk. I'm going to look at the managed services tests as well.

I also tried it and found the same error and same solution but I think instead of removing that env it has to be set to `self.proxy_path`.

``` python
env['JUPYTERHUB_SERVICE_PREFIX'] = self.proxy_path
```

I also think the spawned processes for services are not being terminated but that might just be me testing.


Resolving patch:
diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py
--- a/jupyterhub/services/service.py
+++ b/jupyterhub/services/service.py
@@ -225,8 +225,9 @@ def start(self):
         env['JUPYTERHUB_API_TOKEN'] = self.api_token
         env['JUPYTERHUB_API_URL'] = self.hub_api_url
         env['JUPYTERHUB_BASE_URL'] = self.base_url
-        env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
-        env['JUPYTERHUB_SERVICE_URL'] = self.url
+        if self.url:
+            env['JUPYTERHUB_SERVICE_URL'] = self.url
+            env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
 
         self.spawner = _ServiceSpawner(
             cmd=self.command,
@@ -248,7 +249,7 @@ def _proc_stopped(self):
         """Called when the service process unexpectedly exits"""
         self.log.error("Service %s exited with status %i", self.name, self.proc.returncode)
         self.start()
-        
+
     def stop(self):
         """Stop a managed service"""
         if not self.managed:


Test patch:
diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py
--- a/jupyterhub/tests/conftest.py
+++ b/jupyterhub/tests/conftest.py
@@ -66,9 +66,16 @@ class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
     poll_interval = 1
 
 
-@yield_fixture
-def mockservice(request, app):
+def _mockservice(request, app, url=False):
     name = 'mock-service'
+    spec = {
+        'name': name,
+        'command': mockservice_cmd,
+        'admin': True,
+    }
+    if url:
+        spec['url'] = 'http://127.0.0.1:%i' % random_port(),
+
     with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner):
         app.services = [{
             'name': name,
@@ -88,4 +95,12 @@ def mockservice(request, app):
         # ensure process finishes starting
         with raises(TimeoutExpired):
             service.proc.wait(1)
-        yield service
+    return service
+
+@yield_fixture
+def mockservice(request, app):
+    yield _mockservice(request, app, url=False)
+
+@yield_fixture
+def mockservice_url(request, app):
+    yield _mockservice(request, app, url=True)
diff --git a/jupyterhub/tests/test_services.py b/jupyterhub/tests/test_services.py
--- a/jupyterhub/tests/test_services.py
+++ b/jupyterhub/tests/test_services.py
@@ -59,10 +59,11 @@ def test_managed_service(app, mockservice):
     assert service.proc.poll() is None
 
 
-def test_proxy_service(app, mockservice, io_loop):
-    name = mockservice.name
+def test_proxy_service(app, mockservice_url, io_loop):
+    service = mockservice_url
+    name = service.name
     routes = io_loop.run_sync(app.proxy.get_routes)
-    url = public_url(app, mockservice) + '/foo'
+    url = public_url(app, service) + '/foo'
     r = requests.get(url, allow_redirects=False)
     path = '/services/{}/foo'.format(name)
     r.raise_for_status()
@@ -99,3 +100,5 @@ def add_services():
         assert len(resp) >= 1
         assert isinstance(resp[0], dict)
         assert 'name' in resp[0]
+
+