-###############################################################################
-# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #
-# and others #
-# #
-# All rights reserved. This program and the accompanying materials #
-# are made available under the terms of the Apache License, Version 2.0 #
-# which accompanies this distribution, and is available at #
-# http://www.apache.org/licenses/LICENSE-2.0 #
-###############################################################################
-import calendar
-import re
-import requests
-import time
-
-from discover.configuration import Configuration
-from discover.fetcher import Fetcher
-from utils.string_utils import jsonify
-
-
-class ApiAccess(Fetcher):
- subject_token = None
- initialized = False
- regions = {}
- config = None
- api_config = None
-
- host = ""
- base_url = ""
- admin_token = ""
- tokens = {}
- admin_endpoint = ""
- admin_project = None
- auth_response = None
-
- alternative_services = {
- "neutron": ["quantum"]
- }
-
- # identitity API v2 version with admin token
- def __init__(self):
- super(ApiAccess, self).__init__()
- if ApiAccess.initialized:
- return
- ApiAccess.config = Configuration()
- ApiAccess.api_config = ApiAccess.config.get("OpenStack")
- host = ApiAccess.api_config["host"]
- ApiAccess.host = host
- port = ApiAccess.api_config["port"]
- if not (host and port):
- raise ValueError('Missing definition of host or port ' +
- 'for OpenStack API access')
- ApiAccess.base_url = "http://" + host + ":" + port
- ApiAccess.admin_token = ApiAccess.api_config["admin_token"]
- ApiAccess.admin_project = ApiAccess.api_config["admin_project"] \
- if "admin_project" in ApiAccess.api_config \
- else 'admin'
- ApiAccess.admin_endpoint = "http://" + host + ":" + "35357"
-
- token = self.v2_auth_pwd(ApiAccess.admin_project)
- if not token:
- raise ValueError("Authentication failed. Failed to obtain token")
- else:
- self.subject_token = token
-
- @staticmethod
- def parse_time(time_str):
- try:
- time_struct = time.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ")
- except ValueError:
- try:
- time_struct = time.strptime(time_str,
- "%Y-%m-%dT%H:%M:%S.%fZ")
- except ValueError:
- return None
- return time_struct
-
- # try to use existing token, if it did not expire
- def get_existing_token(self, project_id):
- try:
- token_details = ApiAccess.tokens[project_id]
- except KeyError:
- return None
- token_expiry = token_details["expires"]
- token_expiry_time_struct = self.parse_time(token_expiry)
- if not token_expiry_time_struct:
- return None
- token_expiry_time = token_details["token_expiry_time"]
- now = time.time()
- if now > token_expiry_time:
- # token has expired
- ApiAccess.tokens.pop(project_id)
- return None
- return token_details
-
- def v2_auth(self, project_id, headers, post_body):
- subject_token = self.get_existing_token(project_id)
- if subject_token:
- return subject_token
- req_url = ApiAccess.base_url + "/v2.0/tokens"
- response = requests.post(req_url, json=post_body, headers=headers)
- ApiAccess.auth_response = response.json()
- if 'error' in self.auth_response:
- e = self.auth_response['error']
- self.log.error(str(e['code']) + ' ' + e['title'] + ': ' +
- e['message'] + ", URL: " + req_url)
- return None
- try:
- token_details = ApiAccess.auth_response["access"]["token"]
- except KeyError:
- # assume authentication failed
- return None
- token_expiry = token_details["expires"]
- token_expiry_time_struct = self.parse_time(token_expiry)
- if not token_expiry_time_struct:
- return None
- token_expiry_time = calendar.timegm(token_expiry_time_struct)
- token_details["token_expiry_time"] = token_expiry_time
- ApiAccess.tokens[project_id] = token_details
- return token_details
-
- def v2_auth_pwd(self, project):
- user = ApiAccess.api_config["user"]
- pwd = ApiAccess.api_config["pwd"]
- post_body = {
- "auth": {
- "passwordCredentials": {
- "username": user,
- "password": pwd
- }
- }
- }
- if project is not None:
- post_body["auth"]["tenantName"] = project
- project_id = project
- else:
- project_id = ""
- headers = {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json; charset=UTF-8'
- }
- return self.v2_auth(project_id, headers, post_body)
-
- def get_rel_url(self, relative_url, headers):
- req_url = ApiAccess.base_url + relative_url
- return self.get_url(req_url, headers)
-
- def get_url(self, req_url, headers):
- response = requests.get(req_url, headers=headers)
- if response.status_code != requests.codes.ok:
- # some error happened
- if "reason" in response:
- msg = ", reason: {}".format(response.reason)
- else:
- msg = ", response: {}".format(response.text)
- self.log.error("req_url: {} {}".format(req_url, msg))
- return response
- ret = response.json()
- return ret
-
- def get_region_url(self, region_name, service):
- if region_name not in self.regions:
- return None
- region = self.regions[region_name]
- s = self.get_service_region_endpoints(region, service)
- if not s:
- return None
- orig_url = s["adminURL"]
- # replace host name with the host found in config
- url = re.sub(r"^([^/]+)//[^:]+", r"\1//" + ApiAccess.host, orig_url)
- return url
-
- # like get_region_url(), but remove everything starting from the "/v2"
- def get_region_url_nover(self, region, service):
- full_url = self.get_region_url(region, service)
- if not full_url:
- self.log.error("could not find region URL for region: " + region)
- exit()
- url = re.sub(r":([0-9]+)/v[2-9].*", r":\1", full_url)
- return url
-
- def get_catalog(self, pretty):
- return jsonify(self.regions, pretty)
-
- # find the endpoints for a given service name,
- # considering also alternative service names
- def get_service_region_endpoints(self, region, service):
- alternatives = [service]
- endpoints = region["endpoints"]
- if service in self.alternative_services:
- alternatives.extend(self.alternative_services[service])
- for sname in alternatives:
- if sname in endpoints:
- return endpoints[sname]
- return None
-
+###############################################################################\r
+# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #\r
+# and others #\r
+# #\r
+# All rights reserved. This program and the accompanying materials #\r
+# are made available under the terms of the Apache License, Version 2.0 #\r
+# which accompanies this distribution, and is available at #\r
+# http://www.apache.org/licenses/LICENSE-2.0 #\r
+###############################################################################\r
+import calendar\r
+import re\r
+import requests\r
+import time\r
+\r
+from discover.configuration import Configuration\r
+from discover.fetcher import Fetcher\r
+from utils.string_utils import jsonify\r
+\r
+\r
+class ApiAccess(Fetcher):\r
+ subject_token = None\r
+ initialized = False\r
+ regions = {}\r
+ config = None\r
+ api_config = None\r
+\r
+ host = ""\r
+ base_url = ""\r
+ admin_token = ""\r
+ tokens = {}\r
+ admin_endpoint = ""\r
+ admin_project = None\r
+ auth_response = {}\r
+\r
+ alternative_services = {\r
+ "neutron": ["quantum"]\r
+ }\r
+\r
+ # identitity API v2 version with admin token\r
+ def __init__(self):\r
+ super(ApiAccess, self).__init__()\r
+ if ApiAccess.initialized:\r
+ return\r
+ ApiAccess.config = Configuration()\r
+ ApiAccess.api_config = ApiAccess.config.get("OpenStack")\r
+ host = ApiAccess.api_config["host"]\r
+ ApiAccess.host = host\r
+ port = ApiAccess.api_config["port"]\r
+ if not (host and port):\r
+ raise ValueError('Missing definition of host or port ' +\r
+ 'for OpenStack API access')\r
+ ApiAccess.base_url = "http://" + host + ":" + port\r
+ ApiAccess.admin_token = ApiAccess.api_config["admin_token"]\r
+ ApiAccess.admin_project = ApiAccess.api_config["admin_project"] \\r
+ if "admin_project" in ApiAccess.api_config \\r
+ else 'admin'\r
+ ApiAccess.admin_endpoint = "http://" + host + ":" + "35357"\r
+\r
+ token = self.v2_auth_pwd(ApiAccess.admin_project)\r
+ if not token:\r
+ raise ValueError("Authentication failed. Failed to obtain token")\r
+ else:\r
+ self.subject_token = token\r
+\r
+ @staticmethod\r
+ def parse_time(time_str):\r
+ try:\r
+ time_struct = time.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ")\r
+ except ValueError:\r
+ try:\r
+ time_struct = time.strptime(time_str,\r
+ "%Y-%m-%dT%H:%M:%S.%fZ")\r
+ except ValueError:\r
+ return None\r
+ return time_struct\r
+\r
+ # try to use existing token, if it did not expire\r
+ def get_existing_token(self, project_id):\r
+ try:\r
+ token_details = ApiAccess.tokens[project_id]\r
+ except KeyError:\r
+ return None\r
+ token_expiry = token_details["expires"]\r
+ token_expiry_time_struct = self.parse_time(token_expiry)\r
+ if not token_expiry_time_struct:\r
+ return None\r
+ token_expiry_time = token_details["token_expiry_time"]\r
+ now = time.time()\r
+ if now > token_expiry_time:\r
+ # token has expired\r
+ ApiAccess.tokens.pop(project_id)\r
+ return None\r
+ return token_details\r
+\r
+ def v2_auth(self, project_id, headers, post_body):\r
+ subject_token = self.get_existing_token(project_id)\r
+ if subject_token:\r
+ return subject_token\r
+ req_url = ApiAccess.base_url + "/v2.0/tokens"\r
+ response = requests.post(req_url, json=post_body, headers=headers)\r
+ response = response.json()\r
+ ApiAccess.auth_response[project_id] = response\r
+ if 'error' in response:\r
+ e = response['error']\r
+ self.log.error(str(e['code']) + ' ' + e['title'] + ': ' +\r
+ e['message'] + ", URL: " + req_url)\r
+ return None\r
+ try:\r
+ token_details = response["access"]["token"]\r
+ except KeyError:\r
+ # assume authentication failed\r
+ return None\r
+ token_expiry = token_details["expires"]\r
+ token_expiry_time_struct = self.parse_time(token_expiry)\r
+ if not token_expiry_time_struct:\r
+ return None\r
+ token_expiry_time = calendar.timegm(token_expiry_time_struct)\r
+ token_details["token_expiry_time"] = token_expiry_time\r
+ ApiAccess.tokens[project_id] = token_details\r
+ return token_details\r
+\r
+ def v2_auth_pwd(self, project):\r
+ user = ApiAccess.api_config["user"]\r
+ pwd = ApiAccess.api_config["pwd"]\r
+ post_body = {\r
+ "auth": {\r
+ "passwordCredentials": {\r
+ "username": user,\r
+ "password": pwd\r
+ }\r
+ }\r
+ }\r
+ if project is not None:\r
+ post_body["auth"]["tenantName"] = project\r
+ project_id = project\r
+ else:\r
+ project_id = ""\r
+ headers = {\r
+ 'Accept': 'application/json',\r
+ 'Content-Type': 'application/json; charset=UTF-8'\r
+ }\r
+ return self.v2_auth(project_id, headers, post_body)\r
+\r
+ @staticmethod\r
+ def get_auth_response(project_id):\r
+ auth_response = ApiAccess.auth_response.get(project_id)\r
+ if not auth_response:\r
+ auth_response = ApiAccess.auth_response.get('admin', {})\r
+ return auth_response\r
+\r
+ def get_rel_url(self, relative_url, headers):\r
+ req_url = ApiAccess.base_url + relative_url\r
+ return self.get_url(req_url, headers)\r
+\r
+ def get_url(self, req_url, headers):\r
+ response = requests.get(req_url, headers=headers)\r
+ if response.status_code != requests.codes.ok:\r
+ # some error happened\r
+ if "reason" in response:\r
+ msg = ", reason: {}".format(response.reason)\r
+ else:\r
+ msg = ", response: {}".format(response.text)\r
+ self.log.error("req_url: {} {}".format(req_url, msg))\r
+ return response\r
+ ret = response.json()\r
+ return ret\r
+\r
+ def get_region_url(self, region_name, service):\r
+ if region_name not in self.regions:\r
+ return None\r
+ region = self.regions[region_name]\r
+ s = self.get_service_region_endpoints(region, service)\r
+ if not s:\r
+ return None\r
+ orig_url = s["adminURL"]\r
+ # replace host name with the host found in config\r
+ url = re.sub(r"^([^/]+)//[^:]+", r"\1//" + ApiAccess.host, orig_url)\r
+ return url\r
+\r
+ # like get_region_url(), but remove everything starting from the "/v2"\r
+ def get_region_url_nover(self, region, service):\r
+ full_url = self.get_region_url(region, service)\r
+ if not full_url:\r
+ self.log.error("could not find region URL for region: " + region)\r
+ exit()\r
+ url = re.sub(r":([0-9]+)/v[2-9].*", r":\1", full_url)\r
+ return url\r
+\r
+ def get_catalog(self, pretty):\r
+ return jsonify(self.regions, pretty)\r
+\r
+ # find the endpoints for a given service name,\r
+ # considering also alternative service names\r
+ def get_service_region_endpoints(self, region, service):\r
+ alternatives = [service]\r
+ endpoints = region["endpoints"]\r
+ if service in self.alternative_services:\r
+ alternatives.extend(self.alternative_services[service])\r
+ for sname in alternatives:\r
+ if sname in endpoints:\r
+ return endpoints[sname]\r
+ return None\r
-###############################################################################
-# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #
-# and others #
-# #
-# All rights reserved. This program and the accompanying materials #
-# are made available under the terms of the Apache License, Version 2.0 #
-# which accompanies this distribution, and is available at #
-# http://www.apache.org/licenses/LICENSE-2.0 #
-###############################################################################
-from discover.fetchers.api.api_access import ApiAccess
-
-
-class ApiFetchRegions(ApiAccess):
- def __init__(self):
- super(ApiFetchRegions, self).__init__()
- self.endpoint = ApiAccess.base_url
-
- def get(self, project_id):
- token = self.v2_auth_pwd(self.admin_project)
- if not token:
- return []
- # the returned authentication response contains the list of end points
- # and regions
- service_catalog = ApiAccess.auth_response.get('access', {}).get('serviceCatalog')
- if not service_catalog:
- return []
- env = self.get_env()
- ret = []
- NULL_REGION = "No-Region"
- for service in service_catalog:
- for e in service["endpoints"]:
- if "region" in e:
- region_name = e.pop("region")
- region_name = region_name if region_name else NULL_REGION
- else:
- region_name = NULL_REGION
- if region_name in self.regions.keys():
- region = self.regions[region_name]
- else:
- region = {
- "id": region_name,
- "name": region_name,
- "endpoints": {}
- }
- ApiAccess.regions[region_name] = region
- region["parent_type"] = "regions_folder"
- region["parent_id"] = env + "-regions"
- e["service_type"] = service["type"]
- region["endpoints"][service["name"]] = e
- ret.extend(list(ApiAccess.regions.values()))
- return ret
+###############################################################################\r
+# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #\r
+# and others #\r
+# #\r
+# All rights reserved. This program and the accompanying materials #\r
+# are made available under the terms of the Apache License, Version 2.0 #\r
+# which accompanies this distribution, and is available at #\r
+# http://www.apache.org/licenses/LICENSE-2.0 #\r
+###############################################################################\r
+from discover.fetchers.api.api_access import ApiAccess\r
+\r
+\r
+class ApiFetchRegions(ApiAccess):\r
+ def __init__(self):\r
+ super(ApiFetchRegions, self).__init__()\r
+ self.endpoint = ApiAccess.base_url\r
+\r
+ def get(self, regions_folder_id):\r
+ token = self.v2_auth_pwd(self.admin_project)\r
+ if not token:\r
+ return []\r
+ # the returned authentication response contains the list of end points\r
+ # and regions\r
+ project_id = regions_folder_id.replace('-regions', '')\r
+ response = ApiAccess.get_auth_response(project_id)\r
+ service_catalog = response.get('access', {}).get('serviceCatalog')\r
+ if not service_catalog:\r
+ return []\r
+ env = self.get_env()\r
+ ret = []\r
+ NULL_REGION = "No-Region"\r
+ for service in service_catalog:\r
+ for e in service["endpoints"]:\r
+ if "region" in e:\r
+ region_name = e.pop("region")\r
+ region_name = region_name if region_name else NULL_REGION\r
+ else:\r
+ region_name = NULL_REGION\r
+ if region_name in self.regions.keys():\r
+ region = self.regions[region_name]\r
+ else:\r
+ region = {\r
+ "id": region_name,\r
+ "name": region_name,\r
+ "endpoints": {}\r
+ }\r
+ ApiAccess.regions[region_name] = region\r
+ region["parent_type"] = "regions_folder"\r
+ region["parent_id"] = env + "-regions"\r
+ e["service_type"] = service["type"]\r
+ region["endpoints"][service["name"]] = e\r
+ ret.extend(list(ApiAccess.regions.values()))\r
+ return ret\r
-###############################################################################
-# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #
-# and others #
-# #
-# All rights reserved. This program and the accompanying materials #
-# are made available under the terms of the Apache License, Version 2.0 #
-# which accompanies this distribution, and is available at #
-# http://www.apache.org/licenses/LICENSE-2.0 #
-###############################################################################
-from discover.fetchers.api.api_access import ApiAccess
-from discover.fetchers.api.api_fetch_regions import ApiFetchRegions
-from test.fetch.test_fetch import TestFetch
-from test.fetch.api_fetch.test_data.api_fetch_regions import *
-from test.fetch.api_fetch.test_data.token import TOKEN
-from unittest.mock import MagicMock
-
-
-class TestApiFetchRegions(TestFetch):
-
- def setUp(self):
- ApiFetchRegions.v2_auth_pwd = MagicMock(return_value=TOKEN)
- self.configure_environment()
-
- def test_get(self):
- fetcher = ApiFetchRegions()
- fetcher.set_env(ENV)
-
- ApiAccess.auth_response = AUTH_RESPONSE
- ret = fetcher.get("test_id")
- self.assertEqual(ret, REGIONS_RESULT,
- "Can't get correct regions information")
-
- def test_get_without_token(self):
- fetcher = ApiFetchRegions()
- fetcher.v2_auth_pwd = MagicMock(return_value=[])
- fetcher.set_env(ENV)
-
- ret = fetcher.get("test_id")
-
- ApiFetchRegions.v2_auth_pwd = MagicMock(return_value=TOKEN)
- self.assertEqual(ret, [], "Can't get [] when the token is invalid")
+###############################################################################\r
+# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #\r
+# and others #\r
+# #\r
+# All rights reserved. This program and the accompanying materials #\r
+# are made available under the terms of the Apache License, Version 2.0 #\r
+# which accompanies this distribution, and is available at #\r
+# http://www.apache.org/licenses/LICENSE-2.0 #\r
+###############################################################################\r
+from discover.fetchers.api.api_access import ApiAccess\r
+from discover.fetchers.api.api_fetch_regions import ApiFetchRegions\r
+from test.fetch.test_fetch import TestFetch\r
+from test.fetch.api_fetch.test_data.api_fetch_regions import *\r
+from test.fetch.api_fetch.test_data.token import TOKEN\r
+from unittest.mock import MagicMock\r
+\r
+\r
+class TestApiFetchRegions(TestFetch):\r
+\r
+ def setUp(self):\r
+ ApiFetchRegions.v2_auth_pwd = MagicMock(return_value=TOKEN)\r
+ self.configure_environment()\r
+\r
+ def test_get(self):\r
+ fetcher = ApiFetchRegions()\r
+ fetcher.set_env(ENV)\r
+\r
+ ApiAccess.auth_response["admin"] = AUTH_RESPONSE\r
+ ret = fetcher.get("test_id")\r
+ self.assertEqual(ret, REGIONS_RESULT,\r
+ "Can't get correct regions information")\r
+\r
+ def test_get_without_token(self):\r
+ fetcher = ApiFetchRegions()\r
+ fetcher.v2_auth_pwd = MagicMock(return_value=[])\r
+ fetcher.set_env(ENV)\r
+\r
+ ret = fetcher.get("test_id")\r
+\r
+ ApiFetchRegions.v2_auth_pwd = MagicMock(return_value=TOKEN)\r
+ self.assertEqual(ret, [], "Can't get [] when the token is invalid")\r
-###############################################################################
-# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #
-# and others #
-# #
-# All rights reserved. This program and the accompanying materials #
-# are made available under the terms of the Apache License, Version 2.0 #
-# which accompanies this distribution, and is available at #
-# http://www.apache.org/licenses/LICENSE-2.0 #
-###############################################################################
-REGION = "RegionOne"
-ENV = "Mirantis-Liberty"
-
-AUTH_RESPONSE = {
- "access": {
- "serviceCatalog": [
- {
- "endpoints": [
- {
- "adminURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",
- "id": "274cbbd9fd6d4311b78e78dd3a1df51f",
- "internalURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",
- "publicURL": "http://172.16.0.3:8774/v2/8c1751e0ce714736a63fee3c776164da",
- "region": "RegionOne"
- }
- ],
- "endpoints_links": [],
- "name": "nova",
- "type": "compute"
- }
- ]
- }
-}
-
-REGIONS_RESULT = [
- {
- "id": "RegionOne",
- "endpoints": {
- "nova": {
- "adminURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",
- "id": "274cbbd9fd6d4311b78e78dd3a1df51f",
- "internalURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",
- "publicURL": "http://172.16.0.3:8774/v2/8c1751e0ce714736a63fee3c776164da",
- "service_type": "compute"
- }
- },
- "name": "RegionOne",
- "parent_type": "regions_folder",
- "parent_id": ENV + "-regions",
- }
-]
+###############################################################################\r
+# Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) #\r
+# and others #\r
+# #\r
+# All rights reserved. This program and the accompanying materials #\r
+# are made available under the terms of the Apache License, Version 2.0 #\r
+# which accompanies this distribution, and is available at #\r
+# http://www.apache.org/licenses/LICENSE-2.0 #\r
+###############################################################################\r
+REGION = "RegionOne"\r
+ENV = "Mirantis-Liberty"\r
+\r
+AUTH_RESPONSE = {\r
+ "admin": {\r
+ "access": {\r
+ "serviceCatalog": [\r
+ {\r
+ "endpoints": [\r
+ {\r
+ "adminURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",\r
+ "id": "274cbbd9fd6d4311b78e78dd3a1df51f",\r
+ "internalURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",\r
+ "publicURL": "http://172.16.0.3:8774/v2/8c1751e0ce714736a63fee3c776164da",\r
+ "region": "RegionOne"\r
+ }\r
+ ],\r
+ "endpoints_links": [],\r
+ "name": "nova",\r
+ "type": "compute"\r
+ }\r
+ ]\r
+ }\r
+ }\r
+}\r
+\r
+REGIONS_RESULT = [\r
+ {\r
+ "id": "RegionOne",\r
+ "endpoints": {\r
+ "nova": {\r
+ "adminURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",\r
+ "id": "274cbbd9fd6d4311b78e78dd3a1df51f",\r
+ "internalURL": "http://192.168.0.2:8774/v2/8c1751e0ce714736a63fee3c776164da",\r
+ "publicURL": "http://172.16.0.3:8774/v2/8c1751e0ce714736a63fee3c776164da",\r
+ "service_type": "compute"\r
+ }\r
+ },\r
+ "name": "RegionOne",\r
+ "parent_type": "regions_folder",\r
+ "parent_id": ENV + "-regions",\r
+ }\r
+]\r