Repository: wagtail/wagtail

Issue:
Incorrectly configured search backend breaks page publishing (URLS always has to be a list)
### Issue Summary

I ran into this issue that is probably more a quality of life thing. 


### Steps to Reproduce

1. Have a search backend configured with (as I eventually deduced), URLs set to a single value and not a list. Example: 
```
if "ELASTICSEARCH_HOST" in os.environ:
    WAGTAILSEARCH_BACKENDS["elasticsearch"] = {
        "BACKEND": "wagtail.search.backends.elasticsearch7",
        "URLS": os.environ["ELASTICSEARCH_HOST"],
        "AUTO_UPDATE": True,
        "ATOMIC_REBUILD": True,
    }
```
In this case what I was fulling was a single host url: http://elasticsearch:9200
2. Try to save a page, or even a draft of a page
3. Wagtail explodes: https://dpaste.com/FA8FKYRS3

It took me awhile to trace through and find where the host was missing as the error message really doesn't point you in the right direction.

I think the problem may only be with Elasticsearch, but I haven't tried other backends. 

The doc examples are clear and always show URLS wrapped as a list. But my instinct if I have a single value is not to cast it to a list.

At the very least a note in the docs might help. Even better would be catching the error and deciding how to recover a bit more gracefully and pointing at the problem. 

Best would be to handle this a bit better. I think the code is here:  https://github.com/wagtail/wagtail/blob/8251b4a6c7e1660e16e721a9f45669986a2699a4/wagtail/search/backends/elasticsearch2.py#L1070

I think we could do a simple check. Say, if the item is a list, or cast it to a list if not. Now there could be complications with that, obviously, which is why this is just a bug report so far and not a fix. :-D Short of that, we should be able to check if the value is a list and raise an error if not. 

* I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: no


### Technical details

* Python version: 3.8
* Django version: 3.1.1
* Wagtail version: 2.10
* Browser version: Chrome 85



Discussion:
I just wanted to point out that the documentation shows the defaults for `WAGTAIL_SEARCH_BACKENDS` which show clearly that `URLS` is a list with one item by default:

<img width="490" alt="Screenshot 2020-10-05 at 16 21 06" src="https://user-images.githubusercontent.com/143557/95105521-cb8b7c80-0726-11eb-97e8-ac3dd3af2b62.png">

We might want to re-classify this issue since this is not strictly a bug, but wrong configuration setting by the developer. 

That being said it would be good to either raise an exception if `URLS` is not a list or wrap it with a list if it's a string.

I definitely can see this falling in a grey area; but my thought was that a misconfiguration on a search backend shouldn’t cause a cascading failure on publish without a clear cause.

Resolving patch:
diff --git a/wagtail/search/backends/elasticsearch2.py b/wagtail/search/backends/elasticsearch2.py
--- a/wagtail/search/backends/elasticsearch2.py
+++ b/wagtail/search/backends/elasticsearch2.py
@@ -1070,6 +1070,10 @@ def __init__(self, params):
         if self.hosts is None:
             self.hosts = []
 
+            # if es_urls is not a list, convert it to a list
+            if isinstance(es_urls, str):
+                es_urls = [es_urls]
+
             for url in es_urls:
                 parsed_url = urlparse(url)
 


Test patch:
diff --git a/wagtail/search/tests/test_elasticsearch2_backend.py b/wagtail/search/tests/test_elasticsearch2_backend.py
--- a/wagtail/search/tests/test_elasticsearch2_backend.py
+++ b/wagtail/search/tests/test_elasticsearch2_backend.py
@@ -833,6 +833,25 @@ def test_urls(self, Elasticsearch):
             timeout=10
         )
 
+    def test_urls_as_string_works(self, Elasticsearch):
+        Elasticsearch2SearchBackend(params={
+            'URLS': 'http://localhost:9200'
+        })
+
+        Elasticsearch.assert_called_with(
+            hosts=[
+                {
+                    'host': 'localhost',
+                    'port': 9200,
+                    'url_prefix': '',
+                    'use_ssl': False,
+                    'verify_certs': False,
+                    'http_auth': None,
+                }
+            ],
+            timeout=10
+        )
+
 
 class TestGetModelRoot(TestCase):
     def test_root_model(self):