Repository: deis/deis

Issue:
Can't access a Flavor with underscores _ in its name
``` console
$ deis flavors:create --id=foo_bar --provider=ec2 --params='{"dummy": "Just testing"}'
foo_bar
$ deis flavors:info foo_bar
Error! {"detail": "Not found"}
$ deis flavors:delete foo_bar
Error! 404 {"detail": "Not found"}
$ deis flavors:create --id=foo-bar --provider=ec2 --params='{"dummy": "Just testing"}'
foo-bar
$ deis flavors:delete foo-bar
$
```



Discussion:

Resolving patch:
diff --git a/api/urls.py b/api/urls.py
--- a/api/urls.py
+++ b/api/urls.py
@@ -323,79 +323,79 @@
     url(r'^keys/?',
         views.KeyViewSet.as_view({'get': 'list', 'post': 'create'})),
     # provider
-    url(r'^providers/(?P<id>[a-z0-9-]+)/?',
+    url(r'^providers/(?P<id>[-_\w]+)/?',
         views.ProviderViewSet.as_view({
             'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'})),
     url(r'^providers/?',
         views.ProviderViewSet.as_view({'get': 'list', 'post': 'create'})),
     # flavor
-    url(r'^flavors/(?P<id>[a-z0-9-]+)/?',
+    url(r'^flavors/(?P<id>[-_\w]+)/?',
         views.FlavorViewSet.as_view({
             'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'})),
     url(r'^flavors/?',
         views.FlavorViewSet.as_view({'get': 'list', 'post': 'create'})),
     # formation infrastructure
-    url(r'^formations/(?P<id>[a-z0-9-]+)/layers/(?P<layer>[a-z0-9-]+)/?',
+    url(r'^formations/(?P<id>[-_\w]+)/layers/(?P<layer>[-_\w]+)/?',
         views.FormationLayerViewSet.as_view({
             'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'})),
-    url(r'^formations/(?P<id>[a-z0-9-]+)/layers/?',
+    url(r'^formations/(?P<id>[-_\w]+)/layers/?',
         views.FormationLayerViewSet.as_view({'get': 'list', 'post': 'create'})),
-    url(r'^formations/(?P<id>[a-z0-9-]+)/nodes/(?P<node>[a-z0-9-]+)/?',
+    url(r'^formations/(?P<id>[-_\w]+)/nodes/(?P<node>[-_\w]+)/?',
         views.FormationNodeViewSet.as_view({
             'get': 'retrieve', 'delete': 'destroy'})),
-    url(r'^formations/(?P<id>[a-z0-9-]+)/nodes/?',
+    url(r'^formations/(?P<id>[-_\w]+)/nodes/?',
         views.FormationNodeViewSet.as_view({'get': 'list'})),
     # formation actions
-    url(r'^formations/(?P<id>[a-z0-9-]+)/scale/?',
+    url(r'^formations/(?P<id>[-_\w]+)/scale/?',
         views.FormationViewSet.as_view({'post': 'scale'})),
-    url(r'^formations/(?P<id>[a-z0-9-]+)/balance/?',
+    url(r'^formations/(?P<id>[-_\w]+)/balance/?',
         views.FormationViewSet.as_view({'post': 'balance'})),
-    url(r'^formations/(?P<id>[a-z0-9-]+)/calculate/?',
+    url(r'^formations/(?P<id>[-_\w]+)/calculate/?',
         views.FormationViewSet.as_view({'post': 'calculate'})),
-    url(r'^formations/(?P<id>[a-z0-9-]+)/converge/?',
+    url(r'^formations/(?P<id>[-_\w]+)/converge/?',
         views.FormationViewSet.as_view({'post': 'converge'})),
     # formation base endpoint
-    url(r'^formations/(?P<id>[a-z0-9-]+)/?',
+    url(r'^formations/(?P<id>[-_\w]+)/?',
         views.FormationViewSet.as_view({
             'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'})),
     url(r'^formations/?',
         views.FormationViewSet.as_view({'get': 'list', 'post': 'create'})),
     # application release components
-    url(r'^apps/(?P<id>[a-z0-9-]+)/config/?',
+    url(r'^apps/(?P<id>[-_\w]+)/config/?',
         views.AppConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/builds/(?P<uuid>[a-z0-9-]+)/?',
+    url(r'^apps/(?P<id>[-_\w]+)/builds/(?P<uuid>[-_\w]+)/?',
         views.AppBuildViewSet.as_view({'get': 'retrieve'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/builds/?',
+    url(r'^apps/(?P<id>[-_\w]+)/builds/?',
         views.AppBuildViewSet.as_view({'get': 'list', 'post': 'create'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/releases/(?P<version>[0-9]+)/?',
+    url(r'^apps/(?P<id>[-_\w]+)/releases/(?P<version>[0-9]+)/?',
         views.AppReleaseViewSet.as_view({'get': 'retrieve'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/releases/?',
+    url(r'^apps/(?P<id>[-_\w]+)/releases/?',
         views.AppReleaseViewSet.as_view({'get': 'list'})),
     # application infrastructure
-    url(r'^apps/(?P<id>[a-z0-9-]+)/containers/(?P<type>[a-z0-9-]+)/(?P<num>[a-z0-9-]+)/?',
+    url(r'^apps/(?P<id>[-_\w]+)/containers/(?P<type>[-_\w]+)/(?P<num>[-_\w]+)/?',
         views.AppContainerViewSet.as_view({'get': 'retrieve'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/containers/(?P<type>[a-z0-9-.]+)/?',
+    url(r'^apps/(?P<id>[-_\w]+)/containers/(?P<type>[-_\w.]+)/?',
         views.AppContainerViewSet.as_view({'get': 'list'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/containers/?',
+    url(r'^apps/(?P<id>[-_\w]+)/containers/?',
         views.AppContainerViewSet.as_view({'get': 'list'})),
     # application actions
-    url(r'^apps/(?P<id>[a-z0-9-]+)/scale/?',
+    url(r'^apps/(?P<id>[-_\w]+)/scale/?',
         views.AppViewSet.as_view({'post': 'scale'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/logs/?',
+    url(r'^apps/(?P<id>[-_\w]+)/logs/?',
         views.AppViewSet.as_view({'post': 'logs'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/run/?',
+    url(r'^apps/(?P<id>[-_\w]+)/run/?',
         views.AppViewSet.as_view({'post': 'run'})),
-    url(r'^apps/(?P<id>[a-z0-9-]+)/calculate/?',
+    url(r'^apps/(?P<id>[-_\w]+)/calculate/?',
         views.AppViewSet.as_view({'post': 'calculate'})),
     # apps base endpoint
-    url(r'^apps/(?P<id>[a-z0-9-]+)/?',
+    url(r'^apps/(?P<id>[-_\w]+)/?',
         views.AppViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
     url(r'^apps/?',
         views.AppViewSet.as_view({'get': 'list', 'post': 'create'})),
     # nodes
-    url(r'^nodes/(?P<node>[a-z0-9-]+)/converge/?',
+    url(r'^nodes/(?P<node>[-_\w]+)/converge/?',
         views.NodeViewSet.as_view({'post': 'converge'})),
-    url(r'^nodes/(?P<node>[a-z0-9-]+)/?',
+    url(r'^nodes/(?P<node>[-_\w]+)/?',
         views.NodeViewSet.as_view({
             'get': 'retrieve', 'delete': 'destroy'})),
     url(r'^nodes/?',


Test patch:
diff --git a/api/tests/flavor.py b/api/tests/flavor.py
--- a/api/tests/flavor.py
+++ b/api/tests/flavor.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 """
 Unit tests for the Deis api app.
 
@@ -31,7 +32,9 @@ def test_flavor(self):
         Test that a user can create, read, update and delete a node flavor
         """
         url = '/api/flavors'
-        body = {'id': 'autotest', 'provider': 'autotest',
+        # Ensure that dashes and underscores are allowed in id, per Django's
+        # definition of a SlugField.
+        body = {'id': 'auto_test-1', 'provider': 'autotest',
                 'params': json.dumps({'region': 'us-west-2', 'instance_size': 'm1.medium'})}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 201)
@@ -59,7 +62,7 @@ def test_flavor_update(self):
             'zone': 'any',
             'image': 'i-1234567'
         }
-        body = {'id': 'autotest', 'provider': 'autotest', 'params': json.dumps(params)}
+        body = {'id': 'auto_test', 'provider': 'autotest', 'params': json.dumps(params)}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 201)
         flavor_id = response.data['id']
diff --git a/api/tests/formation.py b/api/tests/formation.py
--- a/api/tests/formation.py
+++ b/api/tests/formation.py
@@ -40,7 +40,7 @@ def test_formation(self):
         Test that a user can create, read, update and delete a node formation
         """
         url = '/api/formations'
-        body = {'id': 'autotest', 'domain': 'localhost.localdomain'}
+        body = {'id': 'auto_test-1', 'domain': 'localhost.localdomain'}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 201)
         formation_id = response.data['id']  # noqa
@@ -78,7 +78,9 @@ def test_formation_cm(self):
         self.assertFalse(os.path.exists(path))
 
     def test_formation_id(self):
-        body = {'id': 'autotest', 'domain': 'localhost.localdomain'}
+        # Ensure that dashes and underscores are allowed in id, per Django's
+        # definition of a SlugField.
+        body = {'id': 'auto_test-1', 'domain': 'localhost.localdomain'}
         response = self.client.post('/api/formations', json.dumps(body),
                                     content_type='application/json')
         self.assertEqual(response.status_code, 201)