From f2b584523a8f88d9e338653ae4dc3d78643a46fd Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Wed, 14 Oct 2026 14:28:49 +1300 Subject: [PATCH] Escape resource IDs when appending to URLs Ceilometer passes original resource IDs to many Gnocchi resource API functions, even if they are not UUIDs. Gnocchi allows this, but converts them to resource UUIDs based on the authenticated user and project. Ceilometer publishes Swift containers to Gnocchi using IDs in the format `{project_id}_{container_name}`. Container names can be made up of any characters up to 256 characters long, including special characters such as the ones used in URL escapes. To ensure such resource IDs are parsed by Gnocchi correctly, escape them using `urllib.parse.quote` before appending to URLs where required. --- gnocchiclient/tests/functional/test_resource.py | 3 ++- gnocchiclient/v1/metric.py | 17 +++++++++++------ gnocchiclient/v1/resource.py | 15 +++++++++++---- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/gnocchiclient/tests/functional/test_resource.py b/gnocchiclient/tests/functional/test_resource.py index 0e85210..c296637 100644 --- a/gnocchiclient/tests/functional/test_resource.py +++ b/gnocchiclient/tests/functional/test_resource.py @@ -17,7 +17,8 @@ class ResourceClientTest(base.ClientTestBase): RESOURCE_ID = str(uuid.uuid4()) - RESOURCE_ID2 = "foo" + # NOTE(callumdickinson): Also tests URL escaping. + RESOURCE_ID2 = "foo%201" PROJECT_ID = str(uuid.uuid4()) def test_help(self): diff --git a/gnocchiclient/v1/metric.py b/gnocchiclient/v1/metric.py index e855790..5639973 100644 --- a/gnocchiclient/v1/metric.py +++ b/gnocchiclient/v1/metric.py @@ -15,6 +15,7 @@ import uuid from debtcollector import removals +from urllib import parse as urlparse import iso8601 @@ -77,7 +78,8 @@ def get(self, metric, resource_id=None): self._ensure_metric_is_uuid(metric) url = self.metric_url + metric else: - url = (self.resource_url % resource_id) + metric + url = (self.resource_url + % urlparse.quote(resource_id)) + metric return self._get(url).json() # FIXME(jd): This is what create will be after debtcollector warnings have @@ -114,7 +116,7 @@ def _create_new(self, name=None, archive_policy_name=None, "Metric name is required if resource_id is set") return self._post( - self.resource_url % resource_id, + self.resource_url % urlparse.quote(resource_id), headers={'Content-Type': "application/json"}, data=ujson.dumps({name: metric})).json()[0] @@ -168,7 +170,7 @@ def create(self, metric=None, refetch_metric=True, del metric['resource_id'] metric = {metric_name: metric} metric = self._post( - self.resource_url % resource_id, + self.resource_url % urlparse.quote(resource_id), headers={'Content-Type': "application/json"}, data=ujson.dumps(metric)) return self.get(metric_name, resource_id) @@ -186,7 +188,8 @@ def delete(self, metric, resource_id=None): self._ensure_metric_is_uuid(metric) url = self.metric_url + metric else: - url = self.resource_url % resource_id + metric + url = (self.resource_url + % urlparse.quote(resource_id)) + metric self._delete(url) def add_measures(self, metric, measures, resource_id=None): @@ -204,7 +207,8 @@ def add_measures(self, metric, measures, resource_id=None): self._ensure_metric_is_uuid(metric) url = self.metric_url + metric + "/measures" else: - url = self.resource_url % resource_id + metric + "/measures" + url = (self.resource_url + % urlparse.quote(resource_id)) + metric + "/measures" return self._post( url, headers={'Content-Type': "application/json"}, data=ujson.dumps(measures)) @@ -272,7 +276,8 @@ def get_measures(self, metric, start=None, stop=None, aggregation=None, self._ensure_metric_is_uuid(metric) url = self.metric_url + metric + "/measures" else: - url = self.resource_url % resource_id + metric + "/measures" + url = (self.resource_url + % urlparse.quote(resource_id)) + metric + "/measures" measures = self._get(url, params=params).json() return [(iso8601.parse_date(ts), g, value) for ts, g, value in measures] diff --git a/gnocchiclient/v1/resource.py b/gnocchiclient/v1/resource.py index cc2103b..d0bd4e0 100644 --- a/gnocchiclient/v1/resource.py +++ b/gnocchiclient/v1/resource.py @@ -13,6 +13,8 @@ import ujson +from urllib import parse as urlparse + from gnocchiclient import utils from gnocchiclient.v1 import base @@ -64,7 +66,8 @@ def get(self, resource_type, resource_id, history=False): :type history: bool """ history = "/history" if history else "" - url = self.url + "%s/%s%s" % (resource_type, resource_id, history) + url = self.url + "%s/%s%s" % ( + resource_type, urlparse.quote(resource_id), history) return self._get(url).json() def history(self, resource_type, resource_id, details=False, @@ -88,7 +91,8 @@ def history(self, resource_type, resource_id, details=False, """ params = utils.build_pagination_options(details, False, limit, marker, sorts) - url = "%s%s/%s/history?%s" % (self.url, resource_type, resource_id, + url = "%s%s/%s/history?%s" % (self.url, resource_type, + urlparse.quote(resource_id), utils.dict_to_querystring(params)) return self._get(url).json() @@ -116,7 +120,10 @@ def update(self, resource_type, resource_id, resource): :type resource: dict """ return self._patch( - self.url + resource_type + "/" + resource_id, + (self.url + + resource_type + + "/" + + urlparse.quote(resource_id)), headers={'Content-Type': "application/json"}, data=ujson.dumps(resource)).json() @@ -126,7 +133,7 @@ def delete(self, resource_id): :param resource_id: ID of the resource :type resource_id: str """ - self._delete(self.url + "generic/" + resource_id) + self._delete(self.url + "generic/" + urlparse.quote(resource_id)) def batch_delete(self, query, resource_type="generic"): """Delete a batch of resources based on attribute values.