Repository: jupyterhub/jupyterhub

Issue:
Alembic causes failures on fresh install when postgres db username has '@' in it
I'm using a postgres db where usernames are of the form 'something@something.com'. This causes alembic to fail with:

```
    configparser.InterpolationSyntaxError: '%' must be followed by '%' or '(', found: '%40datahub-fa17-db:<my-db-password>@datahub-fa17-db.postgres.database.azure.com/datahub-fa17-staging'
```

Full traceback below. Note that it was also interleaved with other log lines, not sure why.

```
[D 2017-08-11 23:29:39.187 JupyterHub application:555] Looking for /srv/jupyterhub_config in /srv/jupyterhub
[D 2017-08-11 23:29:39.221 JupyterHub application:577] Loaded config file: /srv/jupyterhub_config.py
[I 2017-08-11 23:29:39.437 JupyterHub app:839] Loading cookie_secret from env[JPY_COOKIE_SECRET]
[D 2017-08-11 23:29:39.437 JupyterHub app:894] Connecting to db: postgresql+psycopg2://datahub-fa17-staging@datahub-fa17-db:<my-password>@datahub-fa17-db.postgres.database.azure.com/datahub-fa17-staging
[D 2017-08-11 23:29:40.808 JupyterHub orm:474] Stamping empty database with alembic revision 3ec6993fe20c
[E 2017-08-11 23:29:40.813 JupyterHub app:1626]
    Traceback (most recent call last):
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/app.py", line 1623, in launch_instance_async
        yield self.initialize(argv)
      File "/usr/lib/python3.5/types.py", line 199, in __next__
        return next(self.__wrapped)
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/app.py", line 1376, in initialize
        self.init_db()
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/app.py", line 900, in init_db
        **self.db_kwargs
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/orm.py", line 530, in new_session_factory
        check_db_revision(engine)
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/orm.py", line 475, in check_db_revision
singleuser.uid 1000
singleuser.fs-gid 1000
singleuser.storage.type hostPath
singleuser.storage.extra-volumes [{'hostPath': {'path': '/data/homes/{username}'}, 'name': 'home'}]
singleuser.storage.extra-volume-mounts [{'name': 'home', 'mountPath': '/home/jovyan'}]
singleuser.memory.limit 1G
singleuser.memory.guarantee 1G
auth.type google
auth.google.client-id <more-secret>
auth.google.client-secret <some-secret>
auth.google.callback-url http://datahub-staging.yuvi.in/hub/oauth_callback
auth.google.hosted-domain berkeley.edu
        alembic.command.stamp(cfg, head)
      File "/usr/local/lib/python3.5/dist-packages/alembic/command.py", line 496, in stamp
        script.run_env()
      File "/usr/local/lib/python3.5/dist-packages/alembic/script/base.py", line 425, in run_env
        util.load_python_file(self.dir, 'env.py')
      File "/usr/local/lib/python3.5/dist-packages/alembic/util/pyfiles.py", line 93, in load_python_file
        module = load_module_py(module_id, path)
      File "/usr/local/lib/python3.5/dist-packages/alembic/util/compat.py", line 64, in load_module_py
        module_id, path).load_module(module_id)
      File "<frozen importlib._bootstrap_external>", line 396, in _check_name_wrapper
      File "<frozen importlib._bootstrap_external>", line 817, in load_module
      File "<frozen importlib._bootstrap_external>", line 676, in load_module
      File "<frozen importlib._bootstrap>", line 268, in _load_module_shim
      File "<frozen importlib._bootstrap>", line 693, in _load
      File "<frozen importlib._bootstrap>", line 673, in _load_unlocked
auth.google.login-service Calnet ID
auth.google.hosted-domain berkeley.edu
admin.access True
cull.enabled True
cull.timeout 3600
cull.every 600
hub.base_url /
hub.db_url postgresql+psycopg2://datahub-fa17-staging@datahub-fa17-db:<my-db-password>@datahub-fa17-db.postgres.database.azure.com/datahub-fa17-staging
singleuser.cmd jupyterhub-singleuser
      File "<frozen importlib._bootstrap_external>", line 673, in exec_module
      File "<frozen importlib._bootstrap>", line 222, in _call_with_frames_removed
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/alembic/env.py", line 82, in <module>
        run_migrations_online()
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/alembic/env.py", line 66, in run_migrations_online
        config.get_section(config.config_ini_section),
      File "/usr/local/lib/python3.5/dist-packages/alembic/config.py", line 201, in get_section
        return dict(self.file_config.items(name))
      File "/usr/lib/python3.5/configparser.py", line 855, in items
        return [(option, value_getter(option)) for option in d.keys()]
      File "/usr/lib/python3.5/configparser.py", line 855, in <listcomp>
        return [(option, value_getter(option)) for option in d.keys()]
      File "/usr/lib/python3.5/configparser.py", line 852, in <lambda>
        section, option, d[option], d)
      File "/usr/lib/python3.5/configparser.py", line 393, in before_get
        self._interpolate_some(parser, option, L, value, section, defaults, 1)
      File "/usr/lib/python3.5/configparser.py", line 443, in _interpolate_some
        "found: %r" % (rest,))
    configparser.InterpolationSyntaxError: '%' must be followed by '%' or '(', found: '%40datahub-fa17-db:<my-db-password>@datahub-fa17-db.postgres.database.azure.com/datahub-fa17-staging'
    
[D 2017-08-11 23:29:40.816 JupyterHub application:647] Exiting application: jupyterhub
```


Discussion:

Resolving patch:
diff --git a/jupyterhub/dbutil.py b/jupyterhub/dbutil.py
--- a/jupyterhub/dbutil.py
+++ b/jupyterhub/dbutil.py
@@ -34,7 +34,11 @@ def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sq
         f.write(
             alembic_ini_tpl.format(
                 alembic_dir=ALEMBIC_DIR,
-                db_url=db_url,
+                # If there are any %s in the URL, they should be replaced with %%, since ConfigParser
+                # by default uses %() for substitution. You'll get %s in your URL when you have usernames
+                # with special chars (such as '@') that need to be URL encoded. URL Encoding is done with %s.
+                # YAY for nested templates?
+                db_url=str(db_url).replace('%', '%%'),
             )
         )
 


Test patch: