diff --git a/0002-Fix-session-handling-in-novaclient.patch b/0002-Fix-session-handling-in-novaclient.patch new file mode 100644 index 0000000..31f2972 --- /dev/null +++ b/0002-Fix-session-handling-in-novaclient.patch @@ -0,0 +1,568 @@ +From 9d2d572eaa0c4fbc48588b103a176a132e0476d9 Mon Sep 17 00:00:00 2001 +From: Boris Pavlovic +Date: Wed, 26 Mar 2014 15:22:03 +0400 +Subject: [PATCH] Fix session handling in novaclient + +Prior to this patch, novaclient was handling sessions in an inconsistent +manner. + +Every time we created a client instance, it would use a global +connection pool, which made it difficult to use in a process that is +meant to be forked. + +Obviously sessions like the ones provided by the requests library that +will automatically cause connections to be kept alive should not be +implicit. This patch moves the novaclient back to the age of a single +session-less request call by default, but also adds two more +resource-reuse friendly options that a user needs to be explicit about. + +The first one is that both v1_1 and v3 clients can now be used as +context managers,. where the session will be kept open (and thus the +connection kept-alive) for the duration of the with block. This is far +more ideal for a web worker use-case as the session can be made +request-long. + +The second one is the per-instance session. This is very similar to what +we had up until now, except it is not a global object so forking is +possible as long as each child instantiates it's own client. The session +once created will be kept open for the duration of the client object +lifetime. + +Please note: client instances are not thread safe. As can be seen from +above forking example - if you wish to use threading/multiprocessing, +you *must not* share client instances. + +DocImpact + +Related-bug: #1247056 +Closes-Bug: #1297796 +Co-authored-by: Nikola Dipanov +Change-Id: Id59e48f61bb3f3c6223302355c849e1e99673410 + +Conflicts: + novaclient/client.py + novaclient/tests/test_client.py + novaclient/tests/test_http.py + novaclient/v1_1/client.py + novaclient/v3/client.py +--- + novaclient/client.py | 74 +++++++++++++++-------- + novaclient/tests/test_auth_plugins.py | 8 +-- + novaclient/tests/test_client.py | 110 +++++++++++++++++++++++++++++++++- + novaclient/tests/test_http.py | 8 +-- + novaclient/tests/v1_1/test_auth.py | 12 ++-- + novaclient/v1_1/client.py | 27 ++++++++- + novaclient/v3/client.py | 27 ++++++++- + 7 files changed, 221 insertions(+), 45 deletions(-) + +diff --git a/novaclient/client.py b/novaclient/client.py +index 0b9aeee..7f5032b 100644 +--- a/novaclient/client.py ++++ b/novaclient/client.py +@@ -39,17 +39,19 @@ from novaclient import service_catalog + from novaclient import utils + + +-_ADAPTERS = {} ++class _ClientConnectionPool(object): + ++ def __init__(self): ++ self._adapters = {} + +-def _adapter_pool(url): +- """ +- Store and reuse HTTP adapters per Service URL. +- """ +- if url not in _ADAPTERS: +- _ADAPTERS[url] = adapters.HTTPAdapter() ++ def get(self, url): ++ """ ++ Store and reuse HTTP adapters per Service URL. ++ """ ++ if url not in self._adapters: ++ self._adapters[url] = adapters.HTTPAdapter() + +- return _ADAPTERS[url] ++ return self._adapters[url] + + + class HTTPClient(object): +@@ -64,12 +66,16 @@ class HTTPClient(object): + os_cache=False, no_cache=True, + http_log_debug=False, auth_system='keystone', + auth_plugin=None, auth_token=None, +- cacert=None, tenant_id=None): ++ cacert=None, tenant_id=None, ++ connection_pool=False): + self.user = user + self.password = password + self.projectid = projectid + self.tenant_id = tenant_id + ++ self._connection_pool = (_ClientConnectionPool() ++ if connection_pool else None) ++ + # This will be called by #_get_password if self.password is None. + # EG if a password can only be obtained by prompting the user, but a + # token is available, you don't want to prompt until the token has +@@ -118,8 +124,8 @@ class HTTPClient(object): + + self.auth_system = auth_system + self.auth_plugin = auth_plugin ++ self._session = None + self._current_url = None +- self._http = None + self._logger = logging.getLogger(__name__) + + if self.http_log_debug and not self._logger.handlers: +@@ -180,19 +186,33 @@ class HTTPClient(object): + 'headers': resp.headers, + 'text': resp.text}) + +- def http(self, url): +- magic_tuple = parse.urlsplit(url) +- scheme, netloc, path, query, frag = magic_tuple +- service_url = '%s://%s' % (scheme, netloc) +- if self._current_url != service_url: +- # Invalidate Session object in case the url is somehow changed +- if self._http: +- self._http.close() +- self._current_url = service_url +- self._logger.debug("New session created for: (%s)" % service_url) +- self._http = requests.Session() +- self._http.mount(service_url, _adapter_pool(service_url)) +- return self._http ++ def open_session(self): ++ if not self._connection_pool: ++ self._session = requests.Session() ++ ++ def close_session(self): ++ if self._session and not self._connection_pool: ++ self._session.close() ++ self._session = None ++ ++ def _get_session(self, url): ++ if self._connection_pool: ++ magic_tuple = parse.urlsplit(url) ++ scheme, netloc, path, query, frag = magic_tuple ++ service_url = '%s://%s' % (scheme, netloc) ++ if self._current_url != service_url: ++ # Invalidate Session object in case the url is somehow changed ++ if self._session: ++ self._session.close() ++ self._current_url = service_url ++ self._logger.debug( ++ "New session created for: (%s)" % service_url) ++ self._session = requests.Session() ++ self._session.mount(service_url, ++ self._connection_pool.get(service_url)) ++ return self._session ++ elif self._session: ++ return self._session + + def request(self, url, method, **kwargs): + kwargs.setdefault('headers', kwargs.get('headers', {})) +@@ -207,7 +227,13 @@ class HTTPClient(object): + kwargs['verify'] = self.verify_cert + + self.http_log_req(method, url, kwargs) +- resp = self.http(url).request( ++ ++ request_func = requests.request ++ session = self._get_session(url) ++ if session: ++ request_func = session.request ++ ++ resp = request_func( + method, + url, + **kwargs) +diff --git a/novaclient/tests/test_auth_plugins.py b/novaclient/tests/test_auth_plugins.py +index a084594..1761b98 100644 +--- a/novaclient/tests/test_auth_plugins.py ++++ b/novaclient/tests/test_auth_plugins.py +@@ -92,7 +92,7 @@ class DeprecatedAuthPluginTest(utils.TestCase): + + @mock.patch.object(pkg_resources, "iter_entry_points", + mock_iter_entry_points) +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + plugin = auth_plugin.DeprecatedAuthPlugin("fake") + cs = client.Client("username", "password", "project_id", +@@ -121,7 +121,7 @@ class DeprecatedAuthPluginTest(utils.TestCase): + + @mock.patch.object(pkg_resources, "iter_entry_points", + mock_iter_entry_points) +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + auth_plugin.discover_auth_systems() + plugin = auth_plugin.DeprecatedAuthPlugin("notexists") +@@ -164,7 +164,7 @@ class DeprecatedAuthPluginTest(utils.TestCase): + + @mock.patch.object(pkg_resources, "iter_entry_points", + mock_iter_entry_points) +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + plugin = auth_plugin.DeprecatedAuthPlugin("fakewithauthurl") + cs = client.Client("username", "password", "project_id", +@@ -197,7 +197,7 @@ class DeprecatedAuthPluginTest(utils.TestCase): + + + class AuthPluginTest(utils.TestCase): +- @mock.patch.object(requests.Session, "request") ++ @mock.patch.object(requests, "request") + @mock.patch.object(pkg_resources, "iter_entry_points") + def test_auth_system_success(self, mock_iter_entry_points, mock_request): + """Test that we can authenticate using the auth system.""" +diff --git a/novaclient/tests/test_client.py b/novaclient/tests/test_client.py +index d586702..96901b9 100644 +--- a/novaclient/tests/test_client.py ++++ b/novaclient/tests/test_client.py +@@ -27,6 +27,16 @@ import novaclient.v3.client + import json + + ++class ClientConnectionPoolTest(utils.TestCase): ++ ++ @mock.patch("novaclient.client.adapters.HTTPAdapter") ++ def test_get(self, mock_http_adapter): ++ mock_http_adapter.side_effect = lambda: mock.Mock() ++ pool = novaclient.client._ClientConnectionPool() ++ self.assertEqual(pool.get("abc"), pool.get("abc")) ++ self.assertNotEqual(pool.get("abc"), pool.get("def")) ++ ++ + class ClientTest(utils.TestCase): + + def test_client_with_timeout(self): +@@ -43,9 +53,9 @@ class ClientTest(utils.TestCase): + 'x-server-management-url': 'blah.com', + 'x-auth-token': 'blah', + } +- with mock.patch('requests.Session.request', mock_request): ++ with mock.patch('requests.request', mock_request): + instance.authenticate() +- requests.Session.request.assert_called_with(mock.ANY, mock.ANY, ++ requests.request.assert_called_with(mock.ANY, mock.ANY, + timeout=2, + headers=mock.ANY, + verify=mock.ANY) +@@ -61,7 +71,7 @@ class ClientTest(utils.TestCase): + instance.version = 'v2.0' + mock_request = mock.Mock() + mock_request.side_effect = novaclient.exceptions.Unauthorized(401) +- with mock.patch('requests.Session.request', mock_request): ++ with mock.patch('requests.request', mock_request): + try: + instance.get('/servers/detail') + except Exception: +@@ -197,6 +207,26 @@ class ClientTest(utils.TestCase): + cs.authenticate() + self.assertTrue(mock_authenticate.called) + ++ @mock.patch('novaclient.client.HTTPClient') ++ def test_contextmanager_v1_1(self, mock_http_client): ++ fake_client = mock.Mock() ++ mock_http_client.return_value = fake_client ++ with novaclient.v1_1.client.Client("user", "password", "project_id", ++ auth_url="foo/v2") as client: ++ pass ++ self.assertTrue(fake_client.open_session.called) ++ self.assertTrue(fake_client.close_session.called) ++ ++ @mock.patch('novaclient.client.HTTPClient') ++ def test_contextmanager_v3(self, mock_http_client): ++ fake_client = mock.Mock() ++ mock_http_client.return_value = fake_client ++ with novaclient.v3.client.Client("user", "password", "project_id", ++ auth_url="foo/v2") as client: ++ pass ++ self.assertTrue(fake_client.open_session.called) ++ self.assertTrue(fake_client.close_session.called) ++ + def test_get_password_simple(self): + cs = novaclient.client.HTTPClient("user", "password", "", "") + cs.password_func = mock.Mock() +@@ -216,3 +246,77 @@ class ClientTest(utils.TestCase): + cs.password_func = mock.Mock() + self.assertEqual(cs._get_password(), "password") + self.assertFalse(cs.password_func.called) ++ ++ def test_auth_url_rstrip_slash(self): ++ cs = novaclient.client.HTTPClient("user", "password", "project_id", ++ auth_url="foo/v2/") ++ self.assertEqual(cs.auth_url, "foo/v2") ++ ++ def test_token_and_bypass_url(self): ++ cs = novaclient.client.HTTPClient(None, None, None, ++ auth_token="12345", ++ bypass_url="compute/v100/") ++ self.assertIsNone(cs.auth_url) ++ self.assertEqual(cs.auth_token, "12345") ++ self.assertEqual(cs.bypass_url, "compute/v100") ++ self.assertEqual(cs.management_url, "compute/v100") ++ ++ @mock.patch("novaclient.client.requests.Session") ++ def test_session(self, mock_session): ++ fake_session = mock.Mock() ++ mock_session.return_value = fake_session ++ cs = novaclient.client.HTTPClient("user", None, "", "") ++ cs.open_session() ++ self.assertEqual(cs._session, fake_session) ++ cs.close_session() ++ self.assertIsNone(cs._session) ++ ++ def test_session_connection_pool(self): ++ cs = novaclient.client.HTTPClient("user", None, "", ++ "", connection_pool=True) ++ cs.open_session() ++ self.assertIsNone(cs._session) ++ cs.close_session() ++ self.assertIsNone(cs._session) ++ ++ def test_get_session(self): ++ cs = novaclient.client.HTTPClient("user", None, "", "") ++ self.assertIsNone(cs._get_session("http://nooooooooo.com")) ++ ++ @mock.patch("novaclient.client.requests.Session") ++ def test_get_session_open_session(self, mock_session): ++ fake_session = mock.Mock() ++ mock_session.return_value = fake_session ++ cs = novaclient.client.HTTPClient("user", None, "", "") ++ cs.open_session() ++ self.assertEqual(fake_session, cs._get_session("http://example.com")) ++ ++ @mock.patch("novaclient.client.requests.Session") ++ @mock.patch("novaclient.client._ClientConnectionPool") ++ def test_get_session_connection_pool(self, mock_pool, mock_session): ++ service_url = "http://example.com" ++ ++ pool = mock.MagicMock() ++ pool.get.return_value = "http_adapter" ++ mock_pool.return_value = pool ++ cs = novaclient.client.HTTPClient("user", None, "", ++ "", connection_pool=True) ++ cs._current_url = "http://another.com" ++ ++ session = cs._get_session(service_url) ++ self.assertEqual(session, mock_session.return_value) ++ pool.get.assert_called_once_with(service_url) ++ mock_session().mount.assert_called_once_with(service_url, ++ 'http_adapter') ++ ++ def test_init_without_connection_pool(self): ++ cs = novaclient.client.HTTPClient("user", None, "", "") ++ self.assertIsNone(cs._connection_pool) ++ ++ @mock.patch("novaclient.client._ClientConnectionPool") ++ def test_init_with_proper_connection_pool(self, mock_pool): ++ fake_pool = mock.Mock() ++ mock_pool.return_value = fake_pool ++ cs = novaclient.client.HTTPClient("user", None, "", ++ connection_pool=True) ++ self.assertEqual(cs._connection_pool, fake_pool) +diff --git a/novaclient/tests/test_http.py b/novaclient/tests/test_http.py +index e2fc4fa..aaa3a46 100644 +--- a/novaclient/tests/test_http.py ++++ b/novaclient/tests/test_http.py +@@ -56,7 +56,7 @@ class ClientTest(utils.TestCase): + def test_get(self): + cl = get_authed_client() + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + def test_get_call(): + resp, body = cl.get("/hi") +@@ -78,7 +78,7 @@ class ClientTest(utils.TestCase): + def test_post(self): + cl = get_authed_client() + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_post_call(): + cl.post("/hi", body=[1, 2, 3]) + headers = { +@@ -110,7 +110,7 @@ class ClientTest(utils.TestCase): + def test_connection_refused(self): + cl = get_client() + +- @mock.patch.object(requests.Session, "request", refused_mock_request) ++ @mock.patch.object(requests, "request", refused_mock_request) + def test_refused_call(): + self.assertRaises(exceptions.ConnectionRefused, cl.get, "/hi") + +@@ -119,7 +119,7 @@ class ClientTest(utils.TestCase): + def test_bad_request(self): + cl = get_client() + +- @mock.patch.object(requests.Session, "request", bad_req_mock_request) ++ @mock.patch.object(requests, "request", bad_req_mock_request) + def test_refused_call(): + self.assertRaises(exceptions.BadRequest, cl.get, "/hi") + +diff --git a/novaclient/tests/v1_1/test_auth.py b/novaclient/tests/v1_1/test_auth.py +index 7344bc7..7877145 100644 +--- a/novaclient/tests/v1_1/test_auth.py ++++ b/novaclient/tests/v1_1/test_auth.py +@@ -57,7 +57,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): + + mock_request = mock.Mock(return_value=(auth_response)) + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + cs.client.authenticate() + headers = { +@@ -160,7 +160,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): + + mock_request = mock.Mock(side_effect=side_effect) + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + cs.client.authenticate() + headers = { +@@ -248,7 +248,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): + + mock_request = mock.Mock(side_effect=side_effect) + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + cs.client.authenticate() + headers = { +@@ -373,7 +373,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): + + mock_request = mock.Mock(return_value=(auth_response)) + +- with mock.patch.object(requests.Session, "request", mock_request): ++ with mock.patch.object(requests, "request", mock_request): + cs.client.authenticate() + headers = { + 'User-Agent': cs.client.USER_AGENT, +@@ -432,7 +432,7 @@ class AuthenticationTests(utils.TestCase): + }) + mock_request = mock.Mock(return_value=(auth_response)) + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + cs.client.authenticate() + headers = { +@@ -460,7 +460,7 @@ class AuthenticationTests(utils.TestCase): + auth_response = utils.TestResponse({'status_code': 401}) + mock_request = mock.Mock(return_value=(auth_response)) + +- @mock.patch.object(requests.Session, "request", mock_request) ++ @mock.patch.object(requests, "request", mock_request) + def test_auth_call(): + self.assertRaises(exceptions.Unauthorized, cs.client.authenticate) + +diff --git a/novaclient/v1_1/client.py b/novaclient/v1_1/client.py +index efeb5c9..ce4aea1 100644 +--- a/novaclient/v1_1/client.py ++++ b/novaclient/v1_1/client.py +@@ -61,6 +61,20 @@ class Client(object): + >>> client.flavors.list() + ... + ++ It is also possible to use an instance as a context manager in which ++ case there will be a session kept alive for the duration of the with ++ statement:: ++ ++ >>> with Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) as client: ++ ... client.servers.list() ++ ... client.flavors.list() ++ ... ++ ++ It is also possible to have a permanent (process-long) connection pool, ++ by passing a connection_pool=True:: ++ ++ >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, ++ ... AUTH_URL, connection_pool=True) + """ + + # FIXME(jesse): project_id isn't required to authenticate +@@ -73,7 +87,8 @@ class Client(object): + bypass_url=None, os_cache=False, no_cache=True, + http_log_debug=False, auth_system='keystone', + auth_plugin=None, auth_token=None, +- cacert=None, tenant_id=None): ++ cacert=None, tenant_id=None, ++ connection_pool=False): + # FIXME(comstud): Rename the api_key argument above when we + # know it's not being used as keyword argument + password = api_key +@@ -145,7 +160,15 @@ class Client(object): + bypass_url=bypass_url, + os_cache=self.os_cache, + http_log_debug=http_log_debug, +- cacert=cacert) ++ cacert=cacert, ++ connection_pool=connection_pool) ++ ++ def __enter__(self): ++ self.client.open_session() ++ return self ++ ++ def __exit__(self, t, v, tb): ++ self.client.close_session() + + def set_management_url(self, url): + self.client.set_management_url(url) +diff --git a/novaclient/v3/client.py b/novaclient/v3/client.py +index f26fdcf..214ba57 100644 +--- a/novaclient/v3/client.py ++++ b/novaclient/v3/client.py +@@ -47,6 +47,20 @@ class Client(object): + >>> client.flavors.list() + ... + ++ It is also possible to use an instance as a context manager in which ++ case there will be a session kept alive for the duration of the with ++ statement:: ++ ++ >>> with Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) as client: ++ ... client.servers.list() ++ ... client.flavors.list() ++ ... ++ ++ It is also possible to have a permanent (process-long) connection pool, ++ by passing a connection_pool=True:: ++ ++ >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, ++ ... AUTH_URL, connection_pool=True) + """ + + # FIXME(jesse): project_id isn't required to authenticate +@@ -59,7 +73,8 @@ class Client(object): + bypass_url=None, os_cache=False, no_cache=True, + http_log_debug=False, auth_system='keystone', + auth_plugin=None, auth_token=None, +- cacert=None, tenant_id=None): ++ cacert=None, tenant_id=None, ++ connection_pool=False): + self.projectid = project_id + self.tenant_id = tenant_id + self.os_cache = os_cache or not no_cache +@@ -110,7 +125,15 @@ class Client(object): + bypass_url=bypass_url, + os_cache=os_cache, + http_log_debug=http_log_debug, +- cacert=cacert) ++ cacert=cacert, ++ connection_pool=connection_pool) ++ ++ def __enter__(self): ++ self.client.open_session() ++ return self ++ ++ def __exit__(self, t, v, tb): ++ self.client.close_session() + + def set_management_url(self, url): + self.client.set_management_url(url) diff --git a/0003-Fix-authentication-bug-when-booting-an-server-in-V3.patch b/0003-Fix-authentication-bug-when-booting-an-server-in-V3.patch new file mode 100644 index 0000000..728413b --- /dev/null +++ b/0003-Fix-authentication-bug-when-booting-an-server-in-V3.patch @@ -0,0 +1,36 @@ +From bc1b7808bb937a414217396b2aca7d03cacd166f Mon Sep 17 00:00:00 2001 +From: Haiwei Xu +Date: Sat, 8 Feb 2014 03:45:47 +0900 +Subject: [PATCH] Fix authentication bug when booting an server in V3 + +Currently when booting a server with V3, novaclient sends an empty +os_password to image_cs. This will cause 401(Unauthorized: Invalid +user/password) when trying to find image. This is is a result of +changes nova's V3 API: nova is no longer used as a proxy for the +image service. So novaclient uses two Client instances: one for +nova, the other for image service. This patch checks os_password +before creating the image Client and assigns it if it's empty. + +Change-Id: Ic54cef93e9b823fb98b1edd78776c9a1fc06ba46 +Closes-Bug: #1277425 +--- + novaclient/shell.py | 6 ++++++ + 1 file changed, 6 insertions(+) + +diff --git a/novaclient/shell.py b/novaclient/shell.py +index 919aa21..f751f21 100644 +--- a/novaclient/shell.py ++++ b/novaclient/shell.py +@@ -694,6 +694,12 @@ class OpenStackComputeShell(object): + # sometimes need to be able to look up images information + # via glance when connected to the nova api. + image_service_type = 'image' ++ # NOTE(hdd): the password is needed again because creating a new ++ # Client without specifying bypass_url will force authentication. ++ # We can't reuse self.cs's bypass_url, because that's the URL for ++ # the nova service; we need to get glance's URL for this Client ++ if not os_password: ++ os_password = helper.password + self.cs.image_cs = client.Client( + options.os_compute_api_version, os_username, + os_password, os_tenant_name, tenant_id=os_tenant_id, diff --git a/0004-Nova-CLI-for-server-groups.patch b/0004-Nova-CLI-for-server-groups.patch new file mode 100644 index 0000000..7b396b9 --- /dev/null +++ b/0004-Nova-CLI-for-server-groups.patch @@ -0,0 +1,279 @@ +From d7ca548883971b8042660af5523ba74e3eb78bf0 Mon Sep 17 00:00:00 2001 +From: Gary Kotton +Date: Thu, 13 Jun 2013 15:25:39 +0000 +Subject: [PATCH] Nova CLI for server groups + +CLI support for blueprint instance-group-api-extension + +REST API support:- https://review.openstack.org/#/c/62557/ + +DocImpact + - supports create, list, get and delete + - only V2 is supported + +Change-Id: Iaa5a2922b9a0eed9f682b7584c2acf582379b422 +--- + novaclient/tests/v1_1/fakes.py | 45 ++++++++++++++++++ + novaclient/tests/v1_1/test_server_groups.py | 52 +++++++++++++++++++++ + novaclient/v1_1/client.py | 2 + + novaclient/v1_1/server_groups.py | 71 +++++++++++++++++++++++++++++ + novaclient/v1_1/shell.py | 39 ++++++++++++++++ + 5 files changed, 209 insertions(+) + create mode 100644 novaclient/tests/v1_1/test_server_groups.py + create mode 100644 novaclient/v1_1/server_groups.py + +diff --git a/novaclient/tests/v1_1/fakes.py b/novaclient/tests/v1_1/fakes.py +index dd8fdcc..50658c1 100644 +--- a/novaclient/tests/v1_1/fakes.py ++++ b/novaclient/tests/v1_1/fakes.py +@@ -1996,3 +1996,48 @@ class FakeHTTPClient(base_client.HTTPClient): + return (200, {}, {'events': [ + {'name': 'network-changed', + 'server_uuid': '1234'}]}) ++ ++ # ++ # Server Groups ++ # ++ ++ def get_os_server_groups(self, *kw): ++ return (200, {}, ++ {"server_groups": [ ++ {"members": [], "metadata": {}, ++ "id": "2cbd51f4-fafe-4cdb-801b-cf913a6f288b", ++ "policies": [], "name": "ig1"}, ++ {"members": [], "metadata": {}, ++ "id": "4473bb03-4370-4bfb-80d3-dc8cffc47d94", ++ "policies": ["anti-affinity"], "name": "ig2"}, ++ {"members": [], "metadata": {"key": "value"}, ++ "id": "31ab9bdb-55e1-4ac3-b094-97eeb1b65cc4", ++ "policies": [], "name": "ig3"}, ++ {"members": ["2dccb4a1-02b9-482a-aa23-5799490d6f5d"], ++ "metadata": {}, ++ "id": "4890bb03-7070-45fb-8453-d34556c87d94", ++ "policies": ["anti-affinity"], "name": "ig2"}]}) ++ ++ def _return_server_group(self): ++ r = {'server_group': ++ self.get_os_server_groups()[2]['server_groups'][0]} ++ return (200, {}, r) ++ ++ def post_os_server_groups(self, body, **kw): ++ return self._return_server_group() ++ ++ def get_os_server_groups_2cbd51f4_fafe_4cdb_801b_cf913a6f288b(self, ++ **kw): ++ return self._return_server_group() ++ ++ def put_os_server_groups_2cbd51f4_fafe_4cdb_801b_cf913a6f288b(self, ++ **kw): ++ return self._return_server_group() ++ ++ def post_os_server_groups_2cbd51f4_fafe_4cdb_801b_cf913a6f288b_action( ++ self, body, **kw): ++ return self._return_server_group() ++ ++ def delete_os_server_groups_2cbd51f4_fafe_4cdb_801b_cf913a6f288b( ++ self, **kw): ++ return (202, {}, None) +diff --git a/novaclient/tests/v1_1/test_server_groups.py b/novaclient/tests/v1_1/test_server_groups.py +new file mode 100644 +index 0000000..fde5def +--- /dev/null ++++ b/novaclient/tests/v1_1/test_server_groups.py +@@ -0,0 +1,52 @@ ++# Copyright (c) 2014 VMware, Inc. ++# All Rights Reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++from novaclient.tests import utils ++from novaclient.tests.v1_1 import fakes ++from novaclient.v1_1 import server_groups ++ ++ ++cs = fakes.FakeClient() ++ ++ ++class ServerGroupsTest(utils.TestCase): ++ ++ def test_list_server_groups(self): ++ result = cs.server_groups.list() ++ cs.assert_called('GET', '/os-server-groups') ++ for server_group in result: ++ self.assertTrue(isinstance(server_group, ++ server_groups.ServerGroup)) ++ ++ def test_create_server_group(self): ++ kwargs = {'name': 'ig1', ++ 'policies': ['anti-affinity']} ++ server_group = cs.server_groups.create(**kwargs) ++ body = {'server_group': kwargs} ++ cs.assert_called('POST', '/os-server-groups', body) ++ self.assertTrue(isinstance(server_group, ++ server_groups.ServerGroup)) ++ ++ def test_get_server_group(self): ++ id = '2cbd51f4-fafe-4cdb-801b-cf913a6f288b' ++ server_group = cs.server_groups.get(id) ++ cs.assert_called('GET', '/os-server-groups/%s' % id) ++ self.assertTrue(isinstance(server_group, ++ server_groups.ServerGroup)) ++ ++ def test_delete_server_group(self): ++ id = '2cbd51f4-fafe-4cdb-801b-cf913a6f288b' ++ cs.server_groups.delete(id) ++ cs.assert_called('DELETE', '/os-server-groups/%s' % id) +diff --git a/novaclient/v1_1/client.py b/novaclient/v1_1/client.py +index ce4aea1..9291270 100644 +--- a/novaclient/v1_1/client.py ++++ b/novaclient/v1_1/client.py +@@ -37,6 +37,7 @@ from novaclient.v1_1 import quota_classes + from novaclient.v1_1 import quotas + from novaclient.v1_1 import security_group_rules + from novaclient.v1_1 import security_groups ++from novaclient.v1_1 import server_groups + from novaclient.v1_1 import servers + from novaclient.v1_1 import services + from novaclient.v1_1 import usage +@@ -131,6 +132,7 @@ class Client(object): + self.os_cache = os_cache or not no_cache + self.availability_zones = \ + availability_zones.AvailabilityZoneManager(self) ++ self.server_groups = server_groups.ServerGroupsManager(self) + + # Add in any extensions... + if extensions: +diff --git a/novaclient/v1_1/server_groups.py b/novaclient/v1_1/server_groups.py +new file mode 100644 +index 0000000..be6ff8e +--- /dev/null ++++ b/novaclient/v1_1/server_groups.py +@@ -0,0 +1,71 @@ ++# Copyright (c) 2014 VMware, Inc. ++# All Rights Reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++""" ++Server group interface. ++""" ++ ++from novaclient import base ++ ++ ++class ServerGroup(base.Resource): ++ """ ++ A server group. ++ """ ++ NAME_ATTR = 'server_group_name' ++ ++ def __repr__(self): ++ return '' % self.id ++ ++ def delete(self): ++ self.manager.delete(self) ++ ++ ++class ServerGroupsManager(base.ManagerWithFind): ++ """ ++ Manage :class:`ServerGroup` resources. ++ """ ++ resource_class = ServerGroup ++ ++ def list(self): ++ """Get a list of all server groups. ++ ++ :rtype: list of :class:`ServerGroup`. ++ """ ++ return self._list('/os-server-groups', 'server_groups') ++ ++ def get(self, id): ++ """Get a specific server group. ++ ++ :param id: The ID of the :class:`ServerGroup` to get. ++ :rtype: :class:`ServerGroup` ++ """ ++ return self._get('/os-server-groups/%s' % id, ++ 'server_group') ++ ++ def delete(self, id): ++ """Delete a specific server group. ++ ++ :param id: The ID of the :class:`ServerGroup` to delete. ++ """ ++ self._delete('/os-server-groups/%s' % id) ++ ++ def create(self, **kwargs): ++ """Create (allocate) a server group. ++ ++ :rtype: list of :class:`ServerGroup` ++ """ ++ body = {'server_group': kwargs} ++ return self._create('/os-server-groups', body, 'server_group') +diff --git a/novaclient/v1_1/shell.py b/novaclient/v1_1/shell.py +index a043d3c..c1754c9 100644 +--- a/novaclient/v1_1/shell.py ++++ b/novaclient/v1_1/shell.py +@@ -3512,3 +3512,42 @@ def do_availability_zone_list(cs, _args): + _translate_availability_zone_keys(result) + utils.print_list(result, ['Name', 'Status'], + sortby_index=None) ++ ++ ++def _print_server_group_details(server_group): ++ columns = ['Id', 'Name', 'Policies', 'Members', 'Metadata'] ++ utils.print_list(server_group, columns) ++ ++ ++def do_server_group_list(cs, args): ++ """Print a list of all server groups.""" ++ server_groups = cs.server_groups.list() ++ _print_server_group_details(server_groups) ++ ++ ++@utils.arg('name', metavar='', help='Server group name.') ++@utils.arg('--policy', metavar='', action='append', ++ dest='policies', default=[], type=str, ++ help='Policies for the server groups') ++def do_server_group_create(cs, args): ++ """Create a new server group with the specified details.""" ++ kwargs = {'name': args.name, ++ 'policies': args.policies} ++ server_group = cs.server_groups.create(**kwargs) ++ _print_server_group_details([server_group]) ++ ++ ++@utils.arg('id', metavar='', ++ help="Unique ID of the server group to delete") ++def do_server_group_delete(cs, args): ++ """Delete a specific server group.""" ++ cs.server_groups.delete(args.id) ++ print("Instance group %s has been successfully deleted." % args.id) ++ ++ ++@utils.arg('id', metavar='', ++ help="Unique ID of the server group to get") ++def do_server_group_get(cs, args): ++ """Get a specific server group.""" ++ server_group = cs.server_groups.get(args.id) ++ _print_server_group_details([server_group]) diff --git a/0005-Avoid-AttributeError-in-servers.Server.__repr__.patch b/0005-Avoid-AttributeError-in-servers.Server.__repr__.patch new file mode 100644 index 0000000..f1a5464 --- /dev/null +++ b/0005-Avoid-AttributeError-in-servers.Server.__repr__.patch @@ -0,0 +1,68 @@ +From 9ef187014875577fd62ca2b5454355c34cd22708 Mon Sep 17 00:00:00 2001 +From: ZhiQiang Fan +Date: Mon, 24 Mar 2014 15:29:01 +0800 +Subject: [PATCH] Avoid AttributeError in servers.Server.__repr__ + +servers.Server represents various object now, and some of them may +don't have attribute 'name', for example, the interface_list() result +object. It will cause AttributeError when we try to format string with +such object, so I add a check for the 'name' attribute in __repr__ +method, it will use 'unknown-name' instead when 'name' is not found. + +Change-Id: If4757d5d73721774543d58a4cc875710a6013f34 +Closes-Bug: #1280453 +--- + novaclient/tests/v1_1/test_servers.py | 26 ++++++++++++++++++++++++++ + novaclient/v1_1/servers.py | 2 +- + 2 files changed, 27 insertions(+), 1 deletion(-) + +diff --git a/novaclient/tests/v1_1/test_servers.py b/novaclient/tests/v1_1/test_servers.py +index a48204c..b70964a 100644 +--- a/novaclient/tests/v1_1/test_servers.py ++++ b/novaclient/tests/v1_1/test_servers.py +@@ -582,6 +582,32 @@ class ServersTest(utils.TestCase): + s.interface_list() + cs.assert_called('GET', '/servers/1234/os-interface') + ++ def test_interface_list_result_string_representable(self): ++ """Test for bugs.launchpad.net/python-novaclient/+bug/1280453.""" ++ # According to https://github.com/openstack/nova/blob/master/ ++ # nova/api/openstack/compute/contrib/attach_interfaces.py#L33, ++ # the attach_interface extension get method will return a json ++ # object partly like this: ++ interface_list = [{ ++ 'net_id': 'd7745cf5-63f9-4883-b0ae-983f061e4f23', ++ 'port_id': 'f35079da-36d5-4513-8ec1-0298d703f70e', ++ 'mac_addr': 'fa:16:3e:4c:37:c8', ++ 'port_state': 'ACTIVE', ++ 'fixed_ips': [{ ++ 'subnet_id': 'f1ad93ad-2967-46ba-b403-e8cbbe65f7fa', ++ 'ip_address': '10.2.0.96' ++ }] ++ }] ++ # If server is not string representable, it will raise an exception, ++ # because attribute named 'name' cannot be found. ++ # Parameter 'loaded' must be True or it will try to get attribute ++ # 'id' then fails (lazy load detail), this is exactly same as ++ # novaclient.base.Manager._list() ++ s = servers.Server(servers.ServerManager, interface_list[0], ++ loaded=True) ++ # Trigger the __repr__ magic method ++ self.assertEqual('', '%r' % s) ++ + def test_interface_attach(self): + s = cs.servers.get(1234) + s.interface_attach(None, None, None) +diff --git a/novaclient/v1_1/servers.py b/novaclient/v1_1/servers.py +index cf284d9..d4ac6a7 100644 +--- a/novaclient/v1_1/servers.py ++++ b/novaclient/v1_1/servers.py +@@ -36,7 +36,7 @@ class Server(base.Resource): + HUMAN_ID = True + + def __repr__(self): +- return "" % self.name ++ return '' % getattr(self, 'name', 'unknown-name') + + def delete(self): + """ diff --git a/0006-Enable-delete-multiple-server-groups-in-one-request.patch b/0006-Enable-delete-multiple-server-groups-in-one-request.patch new file mode 100644 index 0000000..876853f --- /dev/null +++ b/0006-Enable-delete-multiple-server-groups-in-one-request.patch @@ -0,0 +1,86 @@ +From 55b72401b7cfce4c8b9edec827a96ab8b815ed30 Mon Sep 17 00:00:00 2001 +From: Jay Lau +Date: Sun, 6 Apr 2014 13:46:53 +0800 +Subject: [PATCH] Enable delete multiple server groups in one request + +Currently, "nova server-group-delete" can only delete one server +group in one request, this patch was enabling nova client support +removing multiple server groups in one request. + +Change-Id: I373151bc27cbe8617e2023ba99f6fb3f0108d592 +Closes-Bug: #1302954 + +Conflicts: + novaclient/tests/v1_1/test_shell.py +--- + novaclient/tests/v1_1/fakes.py | 6 ++++++ + novaclient/tests/v1_1/test_shell.py | 5 +++++ + novaclient/v1_1/shell.py | 21 ++++++++++++++++----- + 3 files changed, 27 insertions(+), 5 deletions(-) + +diff --git a/novaclient/tests/v1_1/fakes.py b/novaclient/tests/v1_1/fakes.py +index 50658c1..61926c1 100644 +--- a/novaclient/tests/v1_1/fakes.py ++++ b/novaclient/tests/v1_1/fakes.py +@@ -405,6 +405,12 @@ class FakeHTTPClient(base_client.HTTPClient): + fakes.assert_has_keys(body['server'], optional=['name', 'adminPass']) + return (204, {}, body) + ++ def delete_os_server_groups_12345(self, **kw): ++ return (202, {}, None) ++ ++ def delete_os_server_groups_56789(self, **kw): ++ return (202, {}, None) ++ + def delete_servers_1234(self, **kw): + return (202, {}, None) + +diff --git a/novaclient/tests/v1_1/test_shell.py b/novaclient/tests/v1_1/test_shell.py +index 580d2ed..503c813 100644 +--- a/novaclient/tests/v1_1/test_shell.py ++++ b/novaclient/tests/v1_1/test_shell.py +@@ -1918,6 +1918,11 @@ class ShellTest(utils.TestCase): + mock_system.assert_called_with("ssh -6 -p22 " + "root@2607:f0d0:1002::4 -1") + ++ def test_delete_multi_server_groups(self): ++ self.run_command('server-group-delete 12345 56789') ++ self.assert_called('DELETE', '/os-server-groups/56789') ++ self.assert_called('DELETE', '/os-server-groups/12345', pos=-2) ++ + + class GetSecgroupTest(utils.TestCase): + def test_with_integer(self): +diff --git a/novaclient/v1_1/shell.py b/novaclient/v1_1/shell.py +index c1754c9..3bf12f6 100644 +--- a/novaclient/v1_1/shell.py ++++ b/novaclient/v1_1/shell.py +@@ -3537,12 +3537,23 @@ def do_server_group_create(cs, args): + _print_server_group_details([server_group]) + + +-@utils.arg('id', metavar='', +- help="Unique ID of the server group to delete") ++@utils.arg('id', metavar='', nargs='+', ++ help="Unique ID(s) of the server group to delete") + def do_server_group_delete(cs, args): +- """Delete a specific server group.""" +- cs.server_groups.delete(args.id) +- print("Instance group %s has been successfully deleted." % args.id) ++ """Delete specific server group(s).""" ++ failure_count = 0 ++ ++ for sg in args.id: ++ try: ++ cs.server_groups.delete(sg) ++ print(_("Server group %s has been successfully deleted.") % sg) ++ except Exception as e: ++ failure_count += 1 ++ print(_("Delete for server group %(sg)s failed: %(e)s") % ++ {'sg': sg, 'e': e}) ++ if failure_count == len(args.id): ++ raise exceptions.CommandError(_("Unable to delete any of the " ++ "specified server groups.")) + + + @utils.arg('id', metavar='', diff --git a/python-novaclient.spec b/python-novaclient.spec index f74b22e..49c8bbe 100644 --- a/python-novaclient.spec +++ b/python-novaclient.spec @@ -1,7 +1,7 @@ Name: python-novaclient Epoch: 1 Version: 2.17.0 -Release: 1%{?dist} +Release: 2%{?dist} Summary: Python API and CLI for OpenStack Nova Group: Development/Languages @@ -14,6 +14,11 @@ Source0: http://pypi.python.org/packages/source/p/%{name}/%{name}-%{ver # patches_base=2.17.0 # Patch0001: 0001-Remove-runtime-dependency-on-python-pbr.patch +Patch0002: 0002-Fix-session-handling-in-novaclient.patch +Patch0003: 0003-Fix-authentication-bug-when-booting-an-server-in-V3.patch +Patch0004: 0004-Nova-CLI-for-server-groups.patch +Patch0005: 0005-Avoid-AttributeError-in-servers.Server.__repr__.patch +Patch0006: 0006-Enable-delete-multiple-server-groups-in-one-request.patch BuildArch: noarch BuildRequires: python-setuptools @@ -53,6 +58,11 @@ This package contains auto-generated documentation. %setup -q %patch0001 -p1 +%patch0002 -p1 +%patch0003 -p1 +%patch0004 -p1 +%patch0005 -p1 +%patch0006 -p1 # We provide version like this in order to remove runtime dep on pbr. sed -i s/REDHATNOVACLIENTVERSION/%{version}/ novaclient/__init__.py @@ -97,6 +107,14 @@ rm -fr html/.doctrees html/.buildinfo %doc html %changelog +* Tue May 27 2014 Jakub Ruzicka 1:2.17.0-2 +- Selective backports (server groups and more) +- Nova CLI for server groups (rhbz#1101014) +- Enable delete multiple server groups in one request +- Fix session handling in novaclient +- Fix authentication bug when booting an server in V3 +- Avoid AttributeError in servers.Server.__repr__ + * Tue Mar 25 2014 Jakub Ruzicka 1:2.17.0-1 - Update to upstream 2.17.0