diff --git a/docs/requirements.txt b/docs/requirements.txt index 996a4c4..f1e1594 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -sphinx_rtd_theme>=0.1.9 -sphinx-pypi-upload-2>=0.2.2 \ No newline at end of file +sphinx_rtd_theme==1.1.1 +sphinx-pypi-upload-2==0.2.2 diff --git a/docs/usage/cli.rst b/docs/usage/cli.rst index ccbeed0..fb5ec8b 100644 --- a/docs/usage/cli.rst +++ b/docs/usage/cli.rst @@ -4,15 +4,17 @@ Command-line Interface Basic Usage ----------- -In order to use the CLI, you must provide your Selectel VPC API token -and API endpoint. Use the corresponding configuration options -(``--url``, ``--token``), but it is easier to set them in environment variables. +In order to use the CLI, you must provide your Selectel VPC API token, +API endpoint and Keystone identity url. Use the corresponding configuration +options (``--url``, ``--token``, ``--identity_url``), +but it is easier to set them in environment variables. .. code-block:: shell export SEL_URL=url export SEL_TOKEN=token - export SEL_API_VERSION=api_version # by default 2 + export SEL_API_VERSION=api_version # by default: 2 + export OS_AUTH_URL=url # by default: https://api.selvpc.ru/identity/v3 Once you've configured your authentication parameters, you can run **selvpc** commands. All commands take the form of: @@ -110,4 +112,4 @@ Commands :glob: :maxdepth: 2 - commands \ No newline at end of file + commands diff --git a/docs/usage/commands.rst b/docs/usage/commands.rst index 7034d07..0fc2d7f 100644 --- a/docs/usage/commands.rst +++ b/docs/usage/commands.rst @@ -26,12 +26,11 @@ Manage customization **--logo** param can be like URL to logo or local path. -Show domain limits +Show project limits ~~~~~~~~~~~~~~~~~~ .. code-block:: console - $ selvpc limit show - $ selvpc limit show free + $ selvpc limit show --region VALUE Manage projects ~~~~~~~~~~~~~~~ @@ -61,10 +60,8 @@ Manage quotas ~~~~~~~~~~~~~ .. code-block:: console - $ selvpc quota list - $ selvpc quota optimize - $ selvpc quota show - $ selvpc quota set --resource VALUE --region VALUE [--zone VALUE] --value VALUE + $ selvpc quota show --region VALUE + $ selvpc quota set --resource VALUE --region VALUE [--zone VALUE] --value VALUE .. note:: Key **zone** by default is empty. diff --git a/docs/usage/library.rst b/docs/usage/library.rst index 3b3aa30..314c5fa 100644 --- a/docs/usage/library.rst +++ b/docs/usage/library.rst @@ -10,11 +10,22 @@ API URL `here `_) .. code-block:: python - >>> from selvpcclient.client import Client, setup_http_client - >>> SEL_TOKEN=YOUR_API_TOKEN_HERE - >>> SEL_URL="https://api.selectel.ru/vpc/resell" - >>> http_client = setup_http_client(api_url=SEL_URL, api_token=SEL_TOKEN) - >>> selvpc = Client(client=http_client) + >>> from selvpcclient.client import Client + >>> from selvpcclient.client import setup_http_client + >>> from selvpcclient.httpclient import RegionalHTTPClient + + >>> SEL_TOKEN = YOUR_API_TOKEN_HERE + >>> SEL_URL = "https://api.selectel.ru/vpc/resell" + >>> OS_AUTH_URL = "https://api.selvpc.ru/identity/v3" + + >>> http_client = setup_http_client( + ... api_url=SEL_URL, api_token=SEL_TOKEN) + + >>> regional_http_client = RegionalHTTPClient( + ... http_client=http_client, + ... identity_url=OS_AUTH_URL) + + >>> selvpc = Client(client=http_client, regional_client=regional_http_client) Now you can call various methods on the client instance. For example @@ -48,14 +59,12 @@ Set project quotas "quotas": { "compute_cores": [ { - "region": "ru-1", "zone": "ru-1a", "value": 10 } ], "compute_ram": [ { - "region": "ru-1", "zone": "ru-1a", "value": 1024 } @@ -64,11 +73,10 @@ Set project quotas } # via object - >>> project.update_quotas(quotas) + >>> project.update_quotas("ru-1", quotas) # via quotas manager - >>> quotas = selvpc.quotas.update(project.id, quotas=quotas) - + >>> quotas = selvpc.quotas.update_project_quotas(project.id, "ru-1", quotas) Add Windows license ~~~~~~~~~~~~~~~~~~~ diff --git a/env.example.bat b/env.example.bat index 08a316a..6fd3314 100644 --- a/env.example.bat +++ b/env.example.bat @@ -1,6 +1,7 @@ :: Required variables set SEL_TOKEN=MNhA6zVcf7x892LqsQSc9vDN_9999 set SEL_URL=https://api.selectel.ru/vpc/resell +set OS_AUTH_URL=https://api.selvpc.ru/identity/v3 :: Optional variables set SEL_API_VERSION=2 diff --git a/env.example.sh b/env.example.sh index 6652ebe..219bfa4 100644 --- a/env.example.sh +++ b/env.example.sh @@ -2,6 +2,7 @@ # Required variables export SEL_TOKEN=MNhA6zVcf7x892LqsQSc9vDN_9999 export SEL_URL=https://api.selectel.ru/vpc/resell +export OS_AUTH_URL=https://api.selvpc.ru/identity/v3 # Optional variables export SEL_API_VERSION=2 diff --git a/examples/first_project/main.py b/examples/first_project/main.py index 9f81453..7deff77 100644 --- a/examples/first_project/main.py +++ b/examples/first_project/main.py @@ -2,6 +2,7 @@ import os from selvpcclient.client import Client, setup_http_client +from selvpcclient.httpclient import RegionalHTTPClient logging.basicConfig(level=logging.INFO) @@ -17,35 +18,49 @@ # VPC_URL = "https://api.selectel.ru/vpc/resell" +# +# Keystone identity URL +# You can get actual api URL here +# https://support.selectel.ru/vpc/docs +# +IDENTITY_URL = os.getenv('OS_AUTH_URL', 'https://api.selvpc.ru/identity/v3') + REGION = "ru-2" -ZONE = "ru-2a" +ZONE = "ru-2c" http_client = setup_http_client(api_url=VPC_URL, api_token=VPC_TOKEN) -selvpc = Client(client=http_client) +regional_http_client = RegionalHTTPClient(http_client=http_client, + identity_url=IDENTITY_URL) + +selvpc = Client(client=http_client, regional_client=regional_http_client) project = selvpc.projects.create(name="Awesome Projectq12") logging.info( "Project '%s' has been created with id '%s'", project.name, project.id) +project_limits = selvpc.quotas.get_project_limits(project_id=project.id, + region=REGION) + +logging.info(f"Project limits received. " + f"Limits for resource `compute_cores` in region `{REGION}`: " + f"{project_limits.compute_cores}") + quotas = { "quotas": { "compute_cores": [ { - "region": REGION, "zone": ZONE, "value": 1 } ], "compute_ram": [ { - "region": REGION, "zone": ZONE, "value": 512 } ], "volume_gigabytes_fast": [ { - "region": REGION, "zone": ZONE, "value": 5 } @@ -53,7 +68,7 @@ } } -project.update_quotas(quotas) +project.update_quotas(region=REGION, quotas=quotas) logging.info("Project quotas has been set") floating_ips = { diff --git a/requirements.txt b/requirements.txt index fc453ca..89e532d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ -cliff>=2.2 -pbr>=1.8 -requests>=2.9.1 -six>=1.9.0 -PyYAML>=3.10 \ No newline at end of file +cliff==3.10.1 +pbr==5.11.0 +requests==2.28.1 +six==1.16.0 +PyYAML==6.0 +python-keystoneclient==4.5.0 +importlib-metadata==4.13.0 diff --git a/selvpcclient/__init__.py b/selvpcclient/__init__.py index e992399..f2dc0e4 100644 --- a/selvpcclient/__init__.py +++ b/selvpcclient/__init__.py @@ -1 +1 @@ -__version__ = "1.4" +__version__ = "2.0" diff --git a/selvpcclient/base.py b/selvpcclient/base.py index b04489e..d302421 100644 --- a/selvpcclient/base.py +++ b/selvpcclient/base.py @@ -6,7 +6,7 @@ from cliff.lister import Lister from cliff.show import ShowOne -from selvpcclient.util import get_item_properties, process_partial_quotas +from selvpcclient.util import get_item_properties log = logging.getLogger(__name__) @@ -17,7 +17,7 @@ class PartialResponse(object): def __init__(self, manager, ok, fail): if manager.resource_class.__name__ == "Quotas": - self.resources = Resource(manager, process_partial_quotas(ok)) + self.resources = Resource(manager, ok) self._info = self.resources._info else: self._info = ok diff --git a/selvpcclient/client.py b/selvpcclient/client.py index 127c66d..be9f9e9 100644 --- a/selvpcclient/client.py +++ b/selvpcclient/client.py @@ -1,5 +1,7 @@ +import os + from selvpcclient import __version__ -from selvpcclient.httpclient import HTTPClient +from selvpcclient.httpclient import HTTPClient, RegionalHTTPClient from selvpcclient.resources.capabilities import CapabilitiesManager from selvpcclient.resources.customization import CustomizationManager from selvpcclient.resources.floatingips import FloatingIPManager @@ -42,9 +44,21 @@ def setup_http_client(api_url, api_token=None, api_version=2, class Client: """Client for the Selectel VPC API.""" - def __init__(self, client): - self.projects = ProjectsManager(client) - self.quotas = QuotasManager(client) + def __init__( + self, + client: HTTPClient, + regional_client: RegionalHTTPClient = None + ): + if not regional_client: + regional_client = RegionalHTTPClient( + http_client=client, + identity_url=os.environ.get( + 'OS_AUTH_URL', 'https://api.selvpc.ru/identity/v3' + ) + ) + + self.projects = ProjectsManager(client, regional_client) + self.quotas = QuotasManager(regional_client) self.users = UsersManager(client) self.licenses = LicenseManager(client) self.roles = RolesManager(client) diff --git a/selvpcclient/commands/__init__.py b/selvpcclient/commands/__init__.py index 1be5f4d..47b416b 100644 --- a/selvpcclient/commands/__init__.py +++ b/selvpcclient/commands/__init__.py @@ -19,13 +19,10 @@ 'project show': project.Show, 'project delete': project.Delete, - 'limit show': limit.List, - 'limit show free': limit.Free, + 'limit show': limit.Show, - 'quota list': quotas.List, 'quota set': quotas.Update, 'quota show': quotas.Show, - 'quota optimize': quotas.Optimize, 'license add': license.Add, 'license list': license.List, diff --git a/selvpcclient/commands/limit.py b/selvpcclient/commands/limit.py index 5dff0b6..9136c99 100644 --- a/selvpcclient/commands/limit.py +++ b/selvpcclient/commands/limit.py @@ -1,37 +1,37 @@ from selvpcclient.base import ListCommand -from selvpcclient.formatters import join_by_key, reformat_quotas +from selvpcclient.formatters import join_by_key, reformat_limits from selvpcclient.util import handle_http_error -class List(ListCommand): - """Show domain quotas""" +class Show(ListCommand): + """Show project limits""" - columns = ["resource", "region", "zone", "value"] + columns = ["resource", "zone", "value"] _formatters = { - "region": join_by_key("region"), "zone": join_by_key("zone"), "value": join_by_key("value") } sorting_support = True - @handle_http_error - def take_action(self, parsed_args): - result = self.app.context["client"].quotas.get_domain_quotas() - return self.setup_columns(reformat_quotas(result._info), parsed_args) - - -class Free(ListCommand): - """Show free domain quotas""" - - columns = ["resource", "region", "zone", "value"] - _formatters = { - "region": join_by_key("region"), - "zone": join_by_key("zone"), - "value": join_by_key("value") - } - sorting_support = True + def get_parser(self, prog_name): + parser = super(ListCommand, self).get_parser(prog_name) + required = parser.add_argument_group('Required arguments') + required.add_argument('-r', + '--region', + required=True, + ) + required.add_argument('project_id', + metavar='', + ) + return parser @handle_http_error def take_action(self, parsed_args): - result = self.app.context["client"].quotas.get_free_domain_quotas() - return self.setup_columns(reformat_quotas(result._info), parsed_args) + result = self.app.context["client"].quotas.get_project_limits( + project_id=parsed_args.project_id, + region=parsed_args.region + ) + + return self.setup_columns( + reformat_limits(result._info), parsed_args + ) diff --git a/selvpcclient/commands/project.py b/selvpcclient/commands/project.py index bb89a9f..776e147 100644 --- a/selvpcclient/commands/project.py +++ b/selvpcclient/commands/project.py @@ -15,17 +15,11 @@ def get_parser(self, prog_name): '--name', required=True, ) - optional = parser.add_argument_group('Optional arguments') - optional.add_argument('--auto-quotas', - default=False, - action='store_true', - ) return parser @handle_http_error def take_action(self, parsed_args): - result = self.app.context["client"].projects.create( - parsed_args.name, auto_quotas=parsed_args.auto_quotas) + result = self.app.context["client"].projects.create(parsed_args.name) return self.setup_columns(result, parsed_args) diff --git a/selvpcclient/commands/quotas.py b/selvpcclient/commands/quotas.py index 5112e8c..a5046cc 100644 --- a/selvpcclient/commands/quotas.py +++ b/selvpcclient/commands/quotas.py @@ -1,36 +1,16 @@ -import sys - -from selvpcclient.base import ListCommand, PartialResponse -from selvpcclient.formatters import join_by_key, reformat_quotas_with_usages -from selvpcclient.util import handle_http_error - - -class List(ListCommand): - """Show quotas for all projects""" - - columns = ['project_id', 'resource', 'region', 'zone', 'value', 'used'] - _formatters = { - "region": join_by_key("region"), - "zone": join_by_key("zone"), - "value": join_by_key("value"), - "used": join_by_key("used") - } - sorting_support = True - - @handle_http_error - def take_action(self, parsed_args): - result = self.app.context["client"].quotas.get_projects_quotas() - return self.setup_columns( - reformat_quotas_with_usages(result._info), parsed_args - ) +from selvpcclient.base import ListCommand +from selvpcclient.exceptions.base import ClientException +from selvpcclient.formatters import join_by_key +from selvpcclient.formatters import reformat_quotas +from selvpcclient.formatters import reformat_quotas_with_usages +from selvpcclient.util import extract_single_quota_error, handle_http_error class Update(ListCommand): """Set quotas for project""" - columns = ['resource', 'region', 'zone', 'value', 'used'] + columns = ['resource', 'zone', 'value'] _formatters = { - "region": join_by_key("region"), "zone": join_by_key("zone"), "value": join_by_key("value"), "used": join_by_key("used") @@ -69,29 +49,32 @@ def take_action(self, parsed_args): quotas = { 'quotas': { parsed_args.resource: [{ - "region": parsed_args.region, "zone": parsed_args.zone, "value": parsed_args.value }] } } - result = self.app.context["client"].quotas.update( - parsed_args.project_id, quotas - ) - if isinstance(result, PartialResponse): - result._info = result._info["quotas"] - val = {parsed_args.project_id: result._info} + try: + result = self.app.context["client"].quotas.update_project_quotas( + project_id=parsed_args.project_id, + region=parsed_args.region, + quotas=quotas, + ) + except ClientException as e: + # Through the CLI, you can set a quota for only 1 resource. + # It is assumed that there will be only one error. + raise Exception(extract_single_quota_error(e)) + return self.setup_columns( - reformat_quotas_with_usages(val), parsed_args + reformat_quotas(result._info), parsed_args ) class Show(ListCommand): """Show quotas for project""" - columns = ['resource', 'region', 'zone', 'value', 'used'] + columns = ['resource', 'zone', 'value', 'used'] _formatters = { - "region": join_by_key("region"), "zone": join_by_key("zone"), "value": join_by_key("value"), "used": join_by_key("used") @@ -101,6 +84,10 @@ class Show(ListCommand): def get_parser(self, prog_name): parser = super(ListCommand, self).get_parser(prog_name) required = parser.add_argument_group('Required arguments') + required.add_argument('-r', + '--region', + required=True, + ) required.add_argument('project_id', metavar='', ) @@ -109,29 +96,10 @@ def get_parser(self, prog_name): @handle_http_error def take_action(self, parsed_args): result = self.app.context["client"].quotas.get_project_quotas( - parsed_args.project_id - ) - - val = {parsed_args.project_id: result._info} - return self.setup_columns( - reformat_quotas_with_usages(val), parsed_args + project_id=parsed_args.project_id, + region=parsed_args.region ) - -class Optimize(Show): - """Optimize quotas for project""" - - @handle_http_error - def take_action(self, parsed_args): - result = self.app.context["client"].quotas.optimize_project_quotas( - parsed_args.project_id - ) - - if not result: - self.logger.warning("Nothing to optimize!") - sys.exit(1) - - val = {parsed_args.project_id: result._info} return self.setup_columns( - reformat_quotas_with_usages(val), parsed_args + reformat_quotas_with_usages(result._info), parsed_args ) diff --git a/selvpcclient/exceptions/base.py b/selvpcclient/exceptions/base.py index 72b6b6f..57a37d5 100644 --- a/selvpcclient/exceptions/base.py +++ b/selvpcclient/exceptions/base.py @@ -2,8 +2,9 @@ class ClientException(Exception): """The base exception for everything to do with clients.""" message = None - def __init__(self, status_code=None, message=None): + def __init__(self, status_code=None, message=None, errors=None): self.status_code = status_code + self.errors = errors if not message: if self.message: message = self.message diff --git a/selvpcclient/formatters.py b/selvpcclient/formatters.py index 47063c8..11a799f 100644 --- a/selvpcclient/formatters.py +++ b/selvpcclient/formatters.py @@ -15,30 +15,39 @@ def formatter(val): return formatter +def reformat_limits(quotas): + result = [] + for resource, quota in quotas.items(): + if quota[0].get("zone"): + quota = sort_list_of_dicts(quota, "zone") + result.append({ + "resource": resource, + "value": [str(q["value"]) for q in quota], + "zone": [q["zone"] if q.get("zone") else str() for q in quota] + }) + return result + + def reformat_quotas(quotas): result = [] for resource, quota in quotas.items(): - quota = sort_list_of_dicts(quota, "region") + quota = sort_list_of_dicts(quota, "zone") result.append({ "resource": resource, - "region": [q["region"] for q in quota], "zone": [q["zone"] or str() for q in quota], - "value": [str(q["value"]) for q in quota] + "value": [str(q["value"]) for q in quota], }) return result -def reformat_quotas_with_usages(val): +def reformat_quotas_with_usages(quotas): result = [] - for project, quotas in val.items(): - for resource, quota in quotas.items(): - quota = sort_list_of_dicts(quota, "zone") - result.append({ - "project_id": project, - "resource": resource, - "region": [q["region"] for q in quota], - "zone": [q["zone"] or str() for q in quota], - "value": [str(q["value"]) for q in quota], - "used": [str(q["used"]) for q in quota], - }) + for resource, quota in quotas.items(): + quota = sort_list_of_dicts(quota, "zone") + result.append({ + "resource": resource, + "zone": [q["zone"] or str() for q in quota], + "value": [str(q["value"]) for q in quota], + "used": [str(q["used"]) for q in quota], + }) return result diff --git a/selvpcclient/httpclient.py b/selvpcclient/httpclient.py index 8ab43fe..c1369d8 100644 --- a/selvpcclient/httpclient.py +++ b/selvpcclient/httpclient.py @@ -1,22 +1,33 @@ import copy import logging +from dataclasses import dataclass +from datetime import datetime as dt +from datetime import timedelta +from typing import Dict, Optional import requests +from keystoneauth1.session import Session as KeystoneSession +from keystoneauth1.token_endpoint import Token as KeystoneToken +from keystoneclient.v3.client import Client as KeystoneClient +from requests import Response as Resp from selvpcclient.exceptions.base import ClientException from selvpcclient.exceptions.http import get_http_exception -from selvpcclient.util import make_curl, update_json_error_message +from selvpcclient.util import make_curl +from selvpcclient.util import unserialize_quota_error +from selvpcclient.util import update_json_error_message logger = logging.getLogger(__name__) class HTTPClient: - def __init__(self, base_url, headers, timeout=60): + def __init__(self, base_url: str, headers: Dict[str, str], + timeout: int = 60): self.base_url = base_url self.headers = headers self.timeout = timeout - def request(self, method, url, **kwargs): + def request(self, method: str, url: str, **kwargs) -> Resp: if 'timeout' not in kwargs: kwargs['timeout'] = self.timeout kwargs.update(headers=self.headers) @@ -44,14 +55,129 @@ def request(self, method, url, **kwargs): 'body': response.text}) return response - def get(self, path, **kwargs): + def get(self, path: str, **kwargs) -> Resp: return self.request('GET', self.base_url + path, **kwargs) - def post(self, path, **kwargs): + def post(self, path: str, **kwargs) -> Resp: return self.request('POST', self.base_url + path, **kwargs) - def patch(self, path, **kwargs): + def patch(self, path: str, **kwargs) -> Resp: return self.request('PATCH', self.base_url + path, **kwargs) - def delete(self, path, **kwargs): + def delete(self, path: str, **kwargs) -> Resp: return self.request('DELETE', self.base_url + path, **kwargs) + + +class RegionalHTTPClient: + def __init__(self, http_client: HTTPClient, identity_url: str): + self.identity = IdentityManager(http_client, identity_url) + + self.http_client = copy.deepcopy(http_client) + self.http_client.headers.pop('X-Token', None) + + def request(self, method: str, url: str, **kwargs) -> Resp: + x_auth_token = self.identity.get_x_auth_token() + self.http_client.headers['X-Auth-Token'] = x_auth_token + + try: + response = self.http_client.request(method, url, **kwargs) + except ClientException as e: + e.message, e.errors = unserialize_quota_error(e.args[0].message) + raise e + + return response + + def get(self, path: str, service: str, region: str, **kwargs) -> Resp: + return self.request( + 'GET', self.make_url(service, region, path), **kwargs + ) + + def post(self, path: str, service: str, region: str, **kwargs) -> Resp: + return self.request( + 'POST', self.make_url(service, region, path), **kwargs + ) + + def patch(self, path: str, service: str, region: str, **kwargs) -> Resp: + return self.request( + 'PATCH', self.make_url(service, region, path), **kwargs + ) + + def delete(self, path: str, service: str, region: str, **kwargs) -> Resp: + return self.request( + 'DELETE', self.make_url(service, region, path), **kwargs + ) + + def make_url(self, service: str, region: str, path: str) -> str: + region_service_url = self.identity.get_url_by_service(service, region) + return f'{region_service_url}{path}' + + +@dataclass +class XAuthToken: + token: str + expires: Optional[dt] = None + + +class IdentityManager: + MIN_TOKEN_TTL: timedelta = timedelta(seconds=180) + + def __init__( + self, + cloud_management: HTTPClient, + identity_url: str + ): + self.cloud_management = cloud_management + self.identity_url = identity_url + + self.account_name: Optional[str] = None + self._x_auth_token: Optional[XAuthToken] = None + self.keystone: Optional[KeystoneClient] = None + + def get_x_auth_token(self) -> str: + if not self._x_auth_token: + self._x_auth_token = self._issue_x_auth_token() + return self._x_auth_token.token + + ttl = self._x_auth_token.expires.timestamp() - dt.now().timestamp() + if ttl < self.MIN_TOKEN_TTL.seconds: + self._x_auth_token = self._issue_x_auth_token() + return self._x_auth_token.token + + return self._x_auth_token.token + + def get_url_by_service(self, service: str, region: str) -> str: + x_auth_token = self.get_x_auth_token() + token_info = self.keystone.tokens.validate(token=x_auth_token) + + return token_info.service_catalog.url_for( + service_type=service, + region_name=region, + ) + + def _issue_x_auth_token(self) -> XAuthToken: + resp = self.cloud_management.post( + path='/tokens', + json={'token': {'account_name': self._get_account_name()}}, + ) + x_auth_token = resp.json()['token']['id'] + + # Re-initialize keystone client because the token has been refreshed. + self._init_keystone_client(token=x_auth_token) + + token_info = self.keystone.tokens.validate(token=x_auth_token) + + return XAuthToken(token=x_auth_token, expires=token_info.expires) + + def _get_account_name(self) -> str: + if self.account_name: + return self.account_name + + resp = self.cloud_management.get(path='/accounts') + self.account_name = resp.json()['account']['name'] + + return self.account_name + + def _init_keystone_client(self, token: str): + auth_token = KeystoneToken(self.identity_url, token) + session = KeystoneSession(auth=auth_token) + self.keystone = KeystoneClient(session=session) diff --git a/selvpcclient/resources/projects.py b/selvpcclient/resources/projects.py index b04a44b..d9cca57 100644 --- a/selvpcclient/resources/projects.py +++ b/selvpcclient/resources/projects.py @@ -1,16 +1,18 @@ import logging +from typing import Union from selvpcclient import base +from selvpcclient.base import PartialResponse from selvpcclient.exceptions.base import ClientException from selvpcclient.resources.floatingips import FloatingIPManager from selvpcclient.resources.licenses import LicenseManager +from selvpcclient.resources.quotas import Quotas from selvpcclient.resources.quotas import QuotasManager from selvpcclient.resources.roles import RolesManager from selvpcclient.resources.subnets import SubnetManager from selvpcclient.resources.tokens import TokensManager from selvpcclient.util import process_theme_params - log = logging.getLogger(__name__) @@ -68,16 +70,16 @@ def get_roles(self, return_raw=False): self.id, return_raw=return_raw) - def get_quotas(self, return_raw=False): + def get_quotas(self, region, return_raw=False + ) -> Union[Quotas, PartialResponse]: """Show quotas info for current project. + :param string region: name of region :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class - :rtype: list of :class:`selvpcclient.resources.quotas.Quotas` """ return self.manager.quotas_manager.get_project_quotas( - project_id=self.id, - return_raw=return_raw) + project_id=self.id, region=region, return_raw=return_raw) def add_license(self, licenses, return_raw=False): """Create licenses for current project. @@ -157,22 +159,22 @@ def add_token(self, return_raw=False): return self.manager.token_manager.create(self.id, return_raw=return_raw) - def update_quotas(self, quotas, return_raw=False): + def update_quotas(self, region, quotas, return_raw=False + ) -> Union[Quotas, PartialResponse]: """Update current project's quotas. - :param dict quotas: Dict with key `quotas` and keys as dict + :param string region: name of region + :param dict quotas: dict with key `quotas` and keys as dict of items region, zone and value:: { "quotas": { "compute_cores": [ { - "region": "ru-1", "zone": "ru-1a", "value": 10 }, { - "region": "ru-1", "zone": "ru-1b", "value": 10 } @@ -181,11 +183,10 @@ def update_quotas(self, quotas, return_raw=False): } :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class - :rtype: list of :class:`selvpcclient.resources.quotas.Quotas` """ - return self.manager.quotas_manager.update(project_id=self.id, - quotas=quotas, - return_raw=return_raw) + return self.manager.quotas_manager.update_project_quotas( + project_id=self.id, region=region, quotas=quotas, + return_raw=return_raw) def delete(self): """Delete current project and all it's objects.""" @@ -196,10 +197,10 @@ class ProjectsManager(base.Manager): """Manager class for manipulating project.""" resource_class = Project - def __init__(self, client): + def __init__(self, client, regional_client): super(ProjectsManager, self).__init__(client) self.roles_manager = RolesManager(client) - self.quotas_manager = QuotasManager(client) + self.quotas_manager = QuotasManager(regional_client) self.licenses_manager = LicenseManager(client) self.token_manager = TokensManager(client) self.subnets_manager = SubnetManager(client) @@ -214,37 +215,15 @@ def list(self, return_raw=False): """ return self._list('/projects', 'projects', return_raw=return_raw) - def create(self, name, quotas=None, auto_quotas=False, return_raw=False): - """Create new project and optionally set quotas on it. + def create(self, name, return_raw=False): + """Create new project. :param string name: Name of project. :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class - :param dict quotas: Dict with key `quotas` and keys as dict - of items region, zone and value:: - - { - "quotas": { - "compute_cores": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 10 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 10 - } - ] - } - } - :param bool auto_quotas: Automatically set quotas for a project :rtype: list of :class:`Project`. """ - body = {"project": {"name": name, "auto_quotas": auto_quotas}} - if quotas: - body["project"]["quotas"] = quotas + body = {"project": {"name": name}} return self._post('/projects', body, 'project', return_raw=return_raw) def show(self, project_id, return_raw=False): diff --git a/selvpcclient/resources/quotas.py b/selvpcclient/resources/quotas.py index ec144fc..6a5bb6d 100644 --- a/selvpcclient/resources/quotas.py +++ b/selvpcclient/resources/quotas.py @@ -1,107 +1,125 @@ -from selvpcclient import base +import logging +from typing import Union +from requests import Response -class Quotas(base.Resource): +from selvpcclient.base import Manager, PartialResponse, Resource + +log = logging.getLogger(__name__) + + +class Quotas(Resource): """Represents a quota.""" -class QuotasManager(base.Manager): +class QuotasManager(Manager): """Manager class for manipulating quota.""" resource_class = Quotas - def get_domain_quotas(self, return_raw=False): - """Get total amount of resources available to be allocated to projects. + SERVICE_NAME = 'quota-manager' + RESPONSE_QUOTAS_KEY = 'quotas' + RESPONSE_ERRORS_KEY = 'error' - :param return_raw: flag to force returning raw JSON instead of - Python object of self.resource_class - :rtype: :class:`Quotas` - """ - return self._get('/quotas', 'quotas', return_raw=return_raw) + def _get(self, url: str, return_raw: bool = False, + **kwargs) -> Union[Quotas, PartialResponse]: + return self._handle_response( + resp=self.client.get(url, **kwargs), + return_raw=return_raw, + ) - def get_free_domain_quotas(self, return_raw=False): - """Get amount of resources available to be allocated to projects. + def _patch(self, url: str, body: dict = None, return_raw: bool = False, + **kwargs) -> Union[Quotas, PartialResponse]: + return self._handle_response( + resp=self.client.patch(url, json=body, **kwargs), + return_raw=return_raw, + ) - :param return_raw: flag to force returning raw JSON instead of - Python object of self.resource_class - :rtype: :class:`Quotas` - """ - return self._get('/quotas/free', 'quotas', return_raw=return_raw) + def _handle_response(self, resp: Response, return_raw: bool = False + ) -> Union[Quotas, PartialResponse]: + if return_raw: + # JSON can have two keys: 'quotas' and 'error' + return resp.json() + + quotas = resp.json()[self.RESPONSE_QUOTAS_KEY] + + if resp.status_code == 207: + error = resp.json()[self.RESPONSE_ERRORS_KEY] + + log.warning( + f'207 Multi-Status ({self.SERVICE_NAME}): \n\t' + f'Request: {resp.request.method} {resp.request.url} \n\t' + f'Errors: {error["errors"]}' + ) + + return PartialResponse(manager=self, ok=quotas, fail=error) + + return self.resource_class(self, quotas) - def get_projects_quotas(self, return_raw=False): - """Show quotas info for all domain projects. + def get_project_limits(self, project_id: str, region: str, + return_raw=False) -> Union[Quotas, PartialResponse]: + """Show project limits. + :param string project_id: Project id + :param string region: name of region :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class - :rtype: :class:`Quotas` """ - return self._get('/quotas/projects', 'quotas', return_raw=return_raw) - def get_project_quotas(self, project_id, return_raw=False): - """Show quotas info for one project. + return self._get( + url=f'/projects/{project_id}/limits', + service=self.SERVICE_NAME, + region=region, + return_raw=return_raw, + ) + + def get_project_quotas(self, project_id: str, region: str, + return_raw=False) -> Union[Quotas, PartialResponse]: + """Show quotas info for Project. :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class - :param string project_id: Project id. - :rtype: :class:`Quotas` + :param string project_id: Project id + :param string region: name of region """ - return self._get('/quotas/projects/{}'.format(project_id), 'quotas', - return_raw=return_raw) - - def update(self, project_id, quotas, return_raw=False): + return self._get( + url=f'/projects/{project_id}/quotas', + service=self.SERVICE_NAME, + region=region, + return_raw=return_raw, + ) + + def update_project_quotas(self, project_id: str, region: str, + quotas: dict, return_raw=False + ) -> Union[Quotas, PartialResponse]: """Update Project's quotas. :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class :param string project_id: Project id. + :param string region: name of region :param dict quotas: Dict with key `quotas` and keys as dict - of items region, zone and value:: + of items zone and value:: { "quotas": { "compute_cores": [ { - "region": "ru-1", "zone": "ru-1a", "value": 10 }, { - "region": "ru-1", "zone": "ru-1b", "value": 10 } ] } } - :rtype: :class:`Quotas` - """ - - url = '/quotas/projects/{}'.format(project_id) - return self._patch(url=url, body=quotas, response_key='quotas', - return_raw=return_raw) - - def optimize_project_quotas(self, project_id, return_raw=False): - """Optimize project quotas. - - :param return_raw: flag to force returning raw JSON instead of - Python object of self.resource_class - :param string project_id: Project id. """ - body = {"quotas": {}} - quotas = self.get_project_quotas(project_id)._info - for resource, quotas_ in quotas.items(): - for quota in quotas_: - if quota["value"] == 0 or quota["value"] == quota["used"]: - continue - - if resource not in body["quotas"]: - body["quotas"][resource] = [] - - quota["value"] = quota["used"] - del quota["used"] - body["quotas"][resource].append(quota) - - if not body["quotas"]: - return None - - return self.update(project_id, quotas=body, return_raw=return_raw) + return self._patch( + url=f'/projects/{project_id}/quotas', + body=quotas, + service=self.SERVICE_NAME, + region=region, + return_raw=return_raw, + ) diff --git a/selvpcclient/shell.py b/selvpcclient/shell.py index 4daf59b..62f7ef5 100644 --- a/selvpcclient/shell.py +++ b/selvpcclient/shell.py @@ -1,16 +1,18 @@ #!/usr/bin/env python +import logging import os import os.path import sys -import logging from cliff.app import App from cliff.commandmanager import CommandManager from selvpcclient import __version__ -from selvpcclient.client import Client, setup_http_client +from selvpcclient.client import Client +from selvpcclient.client import setup_http_client from selvpcclient.commands import commands +from selvpcclient.httpclient import RegionalHTTPClient from selvpcclient.util import parse_headers logger = logging.getLogger(__name__) @@ -34,6 +36,13 @@ def build_option_parser(self, description, version, argparse_kwargs=None): '--url', default=os.environ.get('SEL_URL', None) ) + parser.add_argument( + '--identity_url', + default=os.environ.get( + 'OS_AUTH_URL', + 'https://api.selvpc.ru/identity/v3' + ) + ) parser.add_argument( '--token', default=os.environ.get('SEL_TOKEN', None) @@ -83,7 +92,17 @@ def prepare_to_run_command(self, cmd): custom_headers=headers, timeout=self.options.timeout, ) - self.context = dict(client=Client(client=http_client)) + regional_http_client = RegionalHTTPClient( + http_client=http_client, + identity_url=self.options.identity_url + ) + + self.context = { + 'client': Client( + client=http_client, + regional_client=regional_http_client + ) + } def main(argv=sys.argv[1:]): diff --git a/selvpcclient/util.py b/selvpcclient/util.py index a6bad8b..85bc7fc 100644 --- a/selvpcclient/util.py +++ b/selvpcclient/util.py @@ -2,15 +2,18 @@ import hashlib import json import logging -import requests import os import sys +from typing import Dict, Optional, Tuple +import requests import six +from selvpcclient.exceptions.base import ClientException + log = logging.getLogger(__name__) -SENSITIVE_HEADERS = ['X-Token'] +SENSITIVE_HEADERS = ['X-Token', 'X-Auth-Token', 'X-Subject-Token'] FILES_EXTENSIONS = ("png", "jpg", "svg", "txt") @@ -220,20 +223,6 @@ def make_curl(url, method, data): return "".join(string_parts) -def process_partial_quotas(resp_ok): - result = {"quotas": {}} - for item in resp_ok: - if item["resource"] not in result["quotas"]: - result["quotas"][item["resource"]] = [{ - k: item[k] for k in item if k != "resource" - }] - else: - result["quotas"][item["resource"]].append({ - k: item[k] for k in item if k != "resource" - }) - return result - - def is_url(data): """Checks if getting value is valid url and path exists.""" try: @@ -298,3 +287,47 @@ def convert_to_short(logo_b64): if len(logo_b64) >= 50: logo_b64 = logo_b64[:15] + ' ... ' + logo_b64[len(logo_b64) - 15:] return logo_b64 + + +def unserialize_quota_error(content: str) -> Tuple[str, Optional[Dict]]: + """Extract general error message and list of errors. + + :param str content: serialized dictionary + + Example input data: str({ + "error": { + "message": "Bad Request", + "code": 400, + "errors": [ + { + "resource": "compute_ram", + "zone": "ru-1a", + "message": "Value doesn't divisible 256.", + "code": 400 + } + ] + } + }) + """ + if 'error' in content: + try: + error = json.loads(content)['error'] + return error['message'], error['errors'] + except Exception: + return content, None + + return content, None + + +def extract_single_quota_error(e: ClientException) -> str: + """Extract single error from quotas error list. + + :param ClientException e: Client Exception (400-599 HTTP error) + + This is used for pretty output in CLI (quota set). It is assumed that when + setting a quota through the CLI, there can be only one error. + """ + if isinstance(e.errors, list): + return e.errors[0]['message'] + + return e.args[0].message diff --git a/test-requirements.txt b/test-requirements.txt index 93dd1cd..6800ed7 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,4 +1,4 @@ pytest==2.9.1 responses==0.5.1 -mock>=2.0 -flake8 +mock==4.0.3 +flake8==3.9.2 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py index 261eaf4..1d941a3 100644 --- a/tests/cli/__init__.py +++ b/tests/cli/__init__.py @@ -56,4 +56,4 @@ def make_client(return_value): "delete.return_value": response, } http_client.configure_mock(**methods) - return Client(client=http_client) + return Client(client=http_client, regional_client=http_client) diff --git a/tests/cli/test_limit.py b/tests/cli/test_limit.py index 404cee8..d551d23 100644 --- a/tests/cli/test_limit.py +++ b/tests/cli/test_limit.py @@ -6,16 +6,7 @@ def test_limit_show(): client = make_client(return_value=answers.LIMITS_SHOW) - args = ['limit show'] - - output = run_cmd(args, client, json_output=True) - - assert len(output) == COUNT_OF_LIMITS - - -def test_limit_show_free(): - client = make_client(return_value=answers.LIMITS_SHOW_FREE) - args = ['limit show free'] + args = ['limit show', '--region=ru-1', 'c2383dc1894748b193031ae1bccf508a'] output = run_cmd(args, client, json_output=True) diff --git a/tests/cli/test_project.py b/tests/cli/test_project.py index bf611d7..28241fa 100644 --- a/tests/cli/test_project.py +++ b/tests/cli/test_project.py @@ -16,19 +16,6 @@ def test_project_create(): assert "url" in output -def test_project_create_with_auto_quotas(): - client = make_client(return_value=answers.PROJECTS_CREATE_WITH_AUTO_QUOTAS) - args = ['project create', '--name', 'project1', '--auto-quotas'] - - output = run_cmd(args, client, json_output=True) - - assert output["name"] == 'project1' - assert output["id"] == '15c578ea47a5466db2aeb57dc8443676' - assert output["enabled"] is True - assert "url" in output - assert "quotas" not in output - - def test_project_update(): client = make_client(return_value=answers.PROJECTS_SET) args = ['project update', diff --git a/tests/cli/test_quota.py b/tests/cli/test_quota.py index 5ca8bbf..b0ef442 100644 --- a/tests/cli/test_quota.py +++ b/tests/cli/test_quota.py @@ -1,5 +1,3 @@ -import pytest - from tests.cli import make_client, run_cmd from tests.util import answers @@ -22,25 +20,9 @@ def test_quota_set(): def test_quota_show(): count_of_quotas = len(answers.QUOTAS_SHOW['quotas']) client = make_client(return_value=answers.QUOTAS_SHOW) - args = ['quota show', '30bde559615740d28bb63ee626fd0f25'] - - output = run_cmd(args, client, json_output=True) - - assert len(output) == count_of_quotas - - -def test_quota_optimize_nothing(): - client = make_client(return_value=answers.QUOTAS_OPTIMIZE_ALL_USING) - args = ['quota optimize', '30bde559615740d28bb63ee626fd0f25'] - - with pytest.raises(SystemExit): - run_cmd(args, client) - - -def test_quota_list(): - count_of_quotas = 10 - client = make_client(return_value=answers.QUOTAS_LIST) - args = ['quota list'] + args = ['quota show', + '--region=ru-1', + '30bde559615740d28bb63ee626fd0f25'] output = run_cmd(args, client, json_output=True) diff --git a/tests/rest/__init__.py b/tests/rest/__init__.py index 6f82937..8ab1ef1 100644 --- a/tests/rest/__init__.py +++ b/tests/rest/__init__.py @@ -1,6 +1,22 @@ +import mock + +from datetime import datetime, timedelta from _pytest.monkeypatch import monkeypatch -from selvpcclient.httpclient import HTTPClient +from selvpcclient.httpclient import HTTPClient, RegionalHTTPClient monkeypatch().setenv("SEL_URL", "http://api") -client = HTTPClient(base_url="http://api/v2", headers={}) + +x_token = "aaaaaaaaaaaaaaaaaaaaaaaaa_000000" + +client = HTTPClient(base_url="http://api/v2", headers={"X-Token": x_token}) +regional_client = RegionalHTTPClient(client, "http://identity/v3") + + +class KeystoneTokenInfoMock: + def __init__(self): + self.expires = datetime.now() + timedelta(hours=24) + + self.service_catalog = mock.Mock() + self.service_catalog.url_for = mock.Mock( + return_value="http://ru-1.api") diff --git a/tests/rest/test_projects.py b/tests/rest/test_projects.py index 96bfd18..a8938e9 100644 --- a/tests/rest/test_projects.py +++ b/tests/rest/test_projects.py @@ -1,9 +1,12 @@ +from unittest.mock import patch + import pytest import responses +from tests.rest import KeystoneTokenInfoMock from selvpcclient.exceptions.base import ClientException from selvpcclient.resources.projects import ProjectsManager -from tests.rest import client +from tests.rest import client, regional_client from tests.util import answers, params @@ -11,7 +14,7 @@ def test_list(): responses.add(responses.GET, 'http://api/v2/projects', json=answers.PROJECTS_LIST) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) projects = manager.list() @@ -22,29 +25,18 @@ def test_list(): def test_add(): responses.add(responses.POST, 'http://api/v2/projects', json=answers.PROJECTS_CREATE) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project = manager.create(name="Kali") assert project is not None -@responses.activate -def test_add_with_auto_quotas(): - responses.add(responses.POST, 'http://api/v2/projects', - json=answers.PROJECTS_CREATE_WITH_AUTO_QUOTAS) - manager = ProjectsManager(client) - - project = manager.create(name="Kylie", auto_quotas=True) - - assert project is not None - - @responses.activate def test_show(): responses.add(responses.GET, 'http://api/v2/projects/666', json=answers.PROJECTS_SHOW) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) info = manager.show(project_id='666') @@ -55,7 +47,7 @@ def test_show(): def test_set(): responses.add(responses.PATCH, 'http://api/v2/projects/666', json=answers.PROJECTS_SET) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) updated_project = manager.update(project_id='666', name="Bonnie") @@ -66,7 +58,7 @@ def test_set(): def test_set_return_raw(): responses.add(responses.PATCH, 'http://api/v2/projects/666', json=answers.PROJECTS_SET) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) updated_project = manager.update(project_id='666', name="Bonnie", return_raw=True) @@ -77,7 +69,7 @@ def test_set_return_raw(): @responses.activate def test_delete(): responses.add(responses.DELETE, 'http://api/v2/projects/204', status=204) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) updated_project = manager.delete(project_id=204) @@ -91,7 +83,7 @@ def test_get_roles_from_single_obj(): responses.add(responses.GET, 'http://api/v2/roles/projects/' '15c578ea47a5466db2aeb57dc8443676', json=answers.PROJECTS_SHOW_ROLES) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) projects = manager.list() project = projects[0] @@ -104,12 +96,19 @@ def test_get_quotas_from_single_obj(): responses.add(responses.GET, 'http://api/v2/projects', json=answers.PROJECTS_LIST) responses.add(responses.GET, - 'http://api/v2/quotas/projects/' - '15c578ea47a5466db2aeb57dc8443676', + 'http://ru-1.api' + '/projects/15c578ea47a5466db2aeb57dc8443676/quotas', json=answers.QUOTAS_SHOW) - project = ProjectsManager(client).list()[0] + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + + project = ProjectsManager(client, regional_client).list()[0] - result = project.get_quotas() + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + result = project.get_quotas(region='ru-1') assert result is not None @@ -118,14 +117,21 @@ def test_get_quotas_from_single_obj(): def test_update_quotas_from_single_obj(): responses.add(responses.GET, 'http://api/v2/projects', json=answers.PROJECTS_LIST) - responses.add(responses.PATCH, 'http://api/v2/quotas/projects/' - '15c578ea47a5466db2aeb57dc8443676', + responses.add(responses.PATCH, + 'http://ru-1.api' + '/projects/15c578ea47a5466db2aeb57dc8443676/quotas', json=answers.QUOTAS_SET) - manager = ProjectsManager(client) + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + manager = ProjectsManager(client, regional_client) projects = manager.list() project = projects[0] - quotas = project.update_quotas({}) + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + quotas = project.update_quotas(region='ru-1', quotas={}) assert quotas is not None @@ -136,7 +142,7 @@ def test_add_license_from_single_obj(): responses.add(responses.POST, 'http://api/v2/licenses/projects/' '15c578ea47a5466db2aeb57dc8443676', json=answers.LICENSES_CREATE) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) projects = manager.list() project = projects[0] @@ -152,7 +158,7 @@ def test_delete_from_single_obj(): responses.add(responses.DELETE, 'http://api/v2/projects/15c578ea47a5466db2aeb57dc8443676', status=204) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project = manager.list()[0] assert project.delete() is None @@ -165,7 +171,7 @@ def test_set_from_single_obj(): responses.add(responses.PATCH, 'http://api/v2/projects/15c578ea47a5466db2aeb57dc8443676', json=answers.PROJECTS_SHOW) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project = manager.list()[0] assert project.update(name="new name project") is not None @@ -176,13 +182,21 @@ def test_show_from_single_obj(): responses.add(responses.GET, 'http://api/v2/projects', json=answers.PROJECTS_LIST) responses.add(responses.GET, - 'http://api/v2/quotas/projects/' - '15c578ea47a5466db2aeb57dc8443676', + 'http://ru-1.api' + '/projects/15c578ea47a5466db2aeb57dc8443676/quotas', json=answers.QUOTAS_SHOW) - manager = ProjectsManager(client) + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + manager = ProjectsManager(client, regional_client) project = manager.list()[0] - assert project.get_quotas() is not None + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + quotas = project.get_quotas(region='ru-1') + + assert quotas is not None @responses.activate @@ -191,7 +205,7 @@ def test_add_token_from_single_obj(): json=answers.PROJECTS_LIST) responses.add(responses.POST, 'http://api/v2/tokens', json=answers.TOKENS_CREATE) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project = manager.list()[0] assert project.add_token() is not None @@ -203,7 +217,7 @@ def test_add_subnets_from_single_obj(): json=answers.PROJECTS_LIST) responses.add(responses.POST, 'http://api/v2/subnets/projects/200', json=answers.SUBNET_ADD) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project = manager.list()[0] project.id = 200 @@ -218,7 +232,7 @@ def test_add_fips_from_single_obj(): json=answers.PROJECTS_LIST) responses.add(responses.POST, 'http://api/v2/floatingips/projects/200', json=answers.FLOATINGIP_ADD) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project = manager.list()[0] project.id = 200 @@ -231,7 +245,7 @@ def test_add_fips_from_single_obj(): def test_get_raw_project_list(): responses.add(responses.GET, 'http://api/v2/projects', json=answers.PROJECTS_LIST) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) project_list_raw = manager.list(return_raw=True) assert len(project_list_raw) > 0 @@ -245,7 +259,7 @@ def test_delete_multiple_with_raise(): responses.add(responses.DELETE, 'http://api/v2/projects/200', status=404) - manager = ProjectsManager(client) + manager = ProjectsManager(client, regional_client) with pytest.raises(ClientException): manager.delete_many(project_ids=[100, 200]) diff --git a/tests/rest/test_quotas.py b/tests/rest/test_quotas.py index f21139e..946321b 100644 --- a/tests/rest/test_quotas.py +++ b/tests/rest/test_quotas.py @@ -1,90 +1,124 @@ +from unittest.mock import patch + import responses -from selvpcclient.resources.quotas import QuotasManager -from tests.rest import client +from selvpcclient.base import PartialResponse +from selvpcclient.resources.quotas import Quotas, QuotasManager +from tests.rest import KeystoneTokenInfoMock +from tests.rest import regional_client from tests.util import answers, params @responses.activate -def test_quotas_get(): - responses.add(responses.GET, 'http://api/v2/quotas', - json=answers.QUOTAS_LIST) - - manager = QuotasManager(client) - - quotas = manager.get_domain_quotas() - - assert quotas is not None - - -@responses.activate -def test_quotas_get_free(): - responses.add(responses.GET, 'http://api/v2/quotas/free', - json=answers.QUOTAS_LIST) +def test_limits_get(): + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + responses.add(responses.GET, + 'http://ru-1.api' + '/projects/30bde559615740d28bb63ee626fd0f25/limits', + json=answers.LIMITS_SHOW) - manager = QuotasManager(client) + manager = QuotasManager(regional_client) - quotas = manager.get_free_domain_quotas() + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + limits = manager.get_project_limits( + project_id='30bde559615740d28bb63ee626fd0f25', region='ru-1') - assert quotas is not None + assert limits is not None + assert isinstance(limits, Quotas) is True @responses.activate def test_quotas_get_for_single_project(): - responses.add(responses.GET, 'http://api/v2/quotas/projects/123', + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + responses.add(responses.GET, + 'http://ru-1.api' + '/projects/30bde559615740d28bb63ee626fd0f25/quotas', json=answers.QUOTAS_SHOW) - manager = QuotasManager(client) + manager = QuotasManager(regional_client) - quotas = manager.get_project_quotas(project_id=123) + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + quotas = manager.get_project_quotas( + project_id='30bde559615740d28bb63ee626fd0f25', region='ru-1') assert quotas is not None - - -@responses.activate -def test_quotas_projects_get(): - responses.add(responses.GET, 'http://api/v2/quotas/projects', - json=answers.QUOTAS_LIST) - - manager = QuotasManager(client) - - project_quotas = manager.get_projects_quotas() - - assert project_quotas is not None + assert isinstance(quotas, Quotas) is True @responses.activate def test_quotas_projects_patch(): - responses.add(responses.PATCH, 'http://api/v2/quotas/projects/123', + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + responses.add(responses.PATCH, + 'http://ru-1.api' + '/projects/30bde559615740d28bb63ee626fd0f25/quotas', json=answers.QUOTAS_SET) - manager = QuotasManager(client) + manager = QuotasManager(regional_client) - project_quotas = manager.update(project_id="123", quotas=params.quotas) + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + project_quotas = manager.update_project_quotas( + project_id="30bde559615740d28bb63ee626fd0f25", region='ru-1', + quotas=params.quotas) assert project_quotas is not None + assert isinstance(project_quotas, Quotas) is True @responses.activate def test_quotas_partial_response(): - responses.add(responses.PATCH, 'http://api/v2/quotas/projects/123', + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + responses.add(responses.PATCH, + 'http://ru-1.api' + '/projects/30bde559615740d28bb63ee626fd0f25/quotas', json=answers.QUOTAS_PARTIAL, status=207) - manager = QuotasManager(client) + manager = QuotasManager(regional_client) - project_quotas = manager.update(project_id="123", quotas=params.quotas) + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + project_quotas = manager.update_project_quotas( + project_id="30bde559615740d28bb63ee626fd0f25", region='ru-1', + quotas=params.quotas) - assert project_quotas._info == answers.QUOTAS_PARTIAL_RESULT + assert isinstance(project_quotas, PartialResponse) is True + assert project_quotas._info == answers.QUOTAS_PARTIAL["quotas"] + assert project_quotas._fail == answers.QUOTAS_PARTIAL["error"] @responses.activate -def test_quotas_raw_get(): - responses.add(responses.GET, 'http://api/v2/quotas', - json=answers.QUOTAS_LIST) - - manager = QuotasManager(client) - - quotas = manager.get_domain_quotas(return_raw=True) - - assert quotas == answers.QUOTAS_LIST["quotas"] +def test_limits_raw_get(): + responses.add(responses.GET, 'http://api/v2/accounts', + json=answers.ACCOUNT_INFO) + responses.add(responses.POST, 'http://api/v2/tokens', + json=answers.TOKENS_CREATE) + responses.add(responses.GET, + 'http://ru-1.api' + '/projects/30bde559615740d28bb63ee626fd0f25/limits', + json=answers.LIMITS_SHOW) + + manager = QuotasManager(regional_client) + + with patch('keystoneclient.v3.tokens.TokenManager.validate', + return_value=KeystoneTokenInfoMock()): + limits = manager.get_project_limits( + project_id='30bde559615740d28bb63ee626fd0f25', region='ru-1', + return_raw=True) + + assert limits == answers.LIMITS_SHOW + assert isinstance(limits, dict) is True diff --git a/tests/util/answers.py b/tests/util/answers.py index bf87f14..0a6e50c 100644 --- a/tests/util/answers.py +++ b/tests/util/answers.py @@ -1,5 +1,15 @@ from tests.util.params import LOGO_BASE64 +ACCOUNT_INFO = { + "account": { + "enabled": True, + "locked": False, + "locks": [], + "name": "fake_domain", + "onboarding": False + } +} + PROJECTS_LIST = { 'projects': [{ "id": "15c578ea47a5466db2aeb57dc8443676", @@ -39,25 +49,6 @@ } } -PROJECTS_CREATE_WITH_AUTO_QUOTAS = { - 'project': { - "id": "15c578ea47a5466db2aeb57dc8443676", - "name": "project1", - "url": "http://11111.selvpc.ru", - "enabled": True, - "quotas": { - "compute_cores": [ - { - "region": "ru-1", - "used": 0, - "zone": "ru-1a", - "value": 10, - }, - ], - }, - } -} - PROJECTS_SET = { 'project': { "id": "15c578ea47a5466db2aeb57dc8443676", @@ -157,201 +148,70 @@ 'quotas': { "compute_cores": [ { - "region": "ru-2", - "zone": "ru-2a", - "value": 10 - }, - { - "region": "ru-1", "zone": "ru-1a", "value": 10 }, - { - "region": "ru-3", - "zone": "ru-3c", - "value": 10 - }, - { - "region": "ru-3", - "zone": "ru-3a", - "value": 10 - }, - { - "region": "ru-3", - "zone": "ru-3z", - "value": 10 - } ], "compute_ram": [ { - "region": "ru-1", "zone": "ru-1a", "value": 1024 }, { - "region": "ru-1", "zone": "ru-1b", "value": 2048 } ], "volume_gigabytes_fast": [ { - "region": "ru-1", "zone": "ru-1a", "value": 100 }, { - "region": "ru-1", "zone": "ru-1b", "value": 100 } ], "volume_gigabytes_universal": [ { - "region": "ru-1", "zone": "ru-1a", "value": 100 }, { - "region": "ru-1", "zone": "ru-1b", "value": 100 } ], "volume_gigabytes_basic": [ { - "region": "ru-1", "zone": "ru-1a", "value": 100 }, { - "region": "ru-1", "zone": "ru-1b", "value": 100 } ], "image_gigabytes": [ { - "region": "ru-2", - "zone": None, - "value": 10 - }, - { - "region": "ru-1", - "zone": None, - "value": 10 - }, - ], - "network_floatingips": [ - { - "region": "ru-1", - "zone": None, - "value": 5 - } - ], - "network_subnets_29": [ - { - "region": "ru-1", - "zone": None, - "value": 1 - } - ], - "license_windows_2012_standard": [ - { - "region": "ru-1", "zone": None, - "value": 1 - } - ] - } -} - -LIMITS_SHOW_FREE = { - 'quotas': { - "compute_cores": [ - { - "region": "ru-1", - "zone": "ru-1a", "value": 10 }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 10 - } - ], - "compute_ram": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 1024 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 2048 - } - ], - "volume_gigabytes_fast": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 100 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 100 - } - ], - "volume_gigabytes_universal": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 100 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 100 - } - ], - "volume_gigabytes_basic": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 100 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 100 - } - ], - "image_gigabytes": [ - { - "region": "ru-1", - "zone": None, - "value": 10 - } ], "network_floatingips": [ { - "region": "ru-1", "zone": None, "value": 5 } ], "network_subnets_29": [ { - "region": "ru-1", "zone": None, "value": 1 } ], "license_windows_2012_standard": [ { - "region": "ru-1", "zone": None, "value": 1 } @@ -359,434 +219,72 @@ } } -QUOTAS_OPTIMIZE_ALL_USING = { - 'quotas': { - "compute_cores": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 10, - "used": 10 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 10, - "used": 10 - } - ], - "compute_ram": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 1024, - "used": 1024 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 2048, - "used": 2048 - } - ], - "volume_gigabytes_fast": [ - { - "region": "ru-1", - "zone": "ru-1a", - "value": 100, - "used": 100 - }, - { - "region": "ru-1", - "zone": "ru-1b", - "value": 100, - "used": 100 - } - ] - } -} - -QUOTAS_LIST = { - "quotas": { - "30bde559615740d28bb63ee626fd0f25": { - "compute_cores": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 36 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 14 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 66 - } - ], - "volume_gigabytes_basic": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 44 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 25 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2b", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 81 - } - ], - "compute_ram": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 9728 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 4608 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 12800 - } - ], - "volume_gigabytes_fast": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 47 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 26 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2b", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 26 - } - ], - "image_gigabytes": [ - { - "region": "ru1", - "used": 0, - "zone": None, - "value": 46 - }, - { - "region": "ru2", - "used": 0, - "zone": None, - "value": 44 - } - ] - }, - "efae8856aa67477a97847ad595306628": { - "compute_cores": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 11 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 0 - } - ], - "volume_gigabytes_basic": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 13 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 0 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2b", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 0 - } - ], - "compute_ram": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 3840 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 0 - } - ], - "volume_gigabytes_fast": [ - { - "region": "ru1", - "used": 0, - "zone": "ru1b", - "value": 12 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1a", - "value": 0 - }, - { - "region": "ru1", - "used": 0, - "zone": "ru1c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2b", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2c", - "value": 0 - }, - { - "region": "ru2", - "used": 0, - "zone": "ru2a", - "value": 0 - } - ], - "image_gigabytes": [ - { - "region": "ru1", - "used": 0, - "zone": None, - "value": 13 - }, - { - "region": "ru2", - "used": 0, - "zone": None, - "value": 0 - } - ] - } - } -} - QUOTAS_SET = { "quotas": { "volume_gigabytes_basic": [ { - "region": "ru-1", - "used": 0, "zone": "ru-1b", "value": 0 }, { - "region": "ru-1", - "used": 0, "zone": "ru-1a", "value": 0 }, { - "region": "ru-2", - "used": 0, "zone": "ru-2a", "value": 0 } ], "compute_cores": [ { - "region": "ru-1", - "used": 0, "zone": "ru-1b", "value": 1 }, { - "region": "ru-1", - "used": 2, "zone": "ru-1a", "value": 2 }, { - "region": "ru-2", - "used": 0, "zone": "ru-2a", "value": 1 } ], "volume_gigabytes_universal": [ { - "region": "ru-1", - "used": 0, "zone": "ru-1b", "value": 0 }, { - "region": "ru-1", - "used": 0, "zone": "ru-1a", "value": 0 }, - { - "region": "ru-2", - "used": 0, - "zone": "ru-2a", - "value": 0 - } ], "compute_ram": [ { - "region": "ru-1", - "used": 0, "zone": "ru-1b", "value": 512 }, { - "region": "ru-1", - "used": 1536, "zone": "ru-1a", "value": 1536 }, - { - "region": "ru-2", - "used": 0, - "zone": "ru-2a", - "value": 0 - } ], "volume_gigabytes_fast": [ { - "region": "ru-1", - "used": 5, "zone": "ru-1b", "value": 5 }, { - "region": "ru-1", - "used": 20, "zone": "ru-1a", "value": 20 }, - { - "region": "ru-2", - "used": 0, - "zone": "ru-2a", - "value": 0 - } ], "image_gigabytes": [ { - "region": "ru-1", - "used": 0, "zone": None, "value": 0 }, { - "region": "ru-2", - "used": 0, "zone": None, "value": 0 } @@ -798,13 +296,11 @@ 'quotas': { "compute_cores": [ { - "region": "ru-1", "zone": "ru-1a", "value": 10, "used": 0 }, { - "region": "ru-1", "zone": "ru-1b", "value": 10, "used": 0 @@ -812,13 +308,11 @@ ], "compute_ram": [ { - "region": "ru-1", "zone": "ru-1a", "value": 1024, "used": 0 }, { - "region": "ru-1", "zone": "ru-1b", "value": 2048, "used": 0 @@ -826,13 +320,11 @@ ], "volume_gigabytes_fast": [ { - "region": "ru-1", "zone": "ru-1a", "value": 100, "used": 0 }, { - "region": "ru-1", "zone": "ru-1b", "value": 100, "used": 0 @@ -840,7 +332,6 @@ ], "network_subnets_29_vrrp": [ { - "region": None, "used": 0, "value": 0, "zone": None @@ -1387,35 +878,23 @@ QUOTAS_PARTIAL = { "quotas": { - "fail": [ + "compute_ram": [ { - "region": "ru-1", - "resource": "compute_ram", "zone": "ru-1b", "value": 2048 } ], - "ok": [ + }, + "error": { + "message": "Multi-Status", + "code": 207, + "errors": [ { - "region": "ru-1", "resource": "image_gigabytes", - "zone": None, - "used": 0, - "value": 400 + "zone": "ru-1a", + "message": "Internal Server Error", + "code": 500 } - ], - "error": "multi_status" - } -} - -QUOTAS_PARTIAL_RESULT = { - "quotas": { - 'image_gigabytes': [ - { - "zone": None, - "region": "ru-1", - "value": 400, - "used": 0} ] } }