From e79ce5cbdb47048a52a82a5160327e6ce4dcad67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Thu, 13 Jan 2022 14:44:05 -0300 Subject: [PATCH 1/3] Ability to override catalog URL, fix #341 --- src/Identity/v3/Models/Catalog.php | 38 ++++++++++++++++++++++++++++++ src/Identity/v3/Service.php | 12 ++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/Identity/v3/Models/Catalog.php b/src/Identity/v3/Models/Catalog.php index d208d7b6..a4957243 100644 --- a/src/Identity/v3/Models/Catalog.php +++ b/src/Identity/v3/Models/Catalog.php @@ -31,6 +31,44 @@ public function populateFromArray(array $data): self return $this; } + /** + * Retrieve a base URL for a service, according to its catalog name, type, region. + * + * @param string $name the name of the service as it appears in the catalog + * @param string $type the type of the service as it appears in the catalog + * @param string $region the region of the service as it appears in the catalog + * @param string $interface the interface of the service as it appears in the catalog + * + * @return null|string NULL if no URL found + */ + public function getServiceUrlOverride( + string $name, + string $type, + string $region, + string $interface, + array $overrides + ): ?string { + foreach ($overrides as $override) { + if ( + (empty($override['name']) || $name == $override['name']) + && (empty($override['type']) || $type == $override['type']) + && (empty($override['region']) || $region == $override['region']) + && (empty($override['interface']) || $interface == $override['interface']) + ) { + if (empty($override['name']) && empty($override['type'])) { + throw new \RuntimeException(sprintf("Endpoint override must at least specify an \"url\" and either \"name\" or a \"type\".")); + } + if (empty($override['url'])) { + throw new \RuntimeException(sprintf("Endpoint override must specify an \"url\".\nName: %s\nType: %s\nRegion: %s\nInterface: %s", $override['name'] ?? '', $override['type'] ?? '', $override['region'] ?? '', $override['interface'] ?? '')); + } + + return $override['url']; + } + } + + return null; + } + /** * Retrieve a base URL for a service, according to its catalog name, type, region. * diff --git a/src/Identity/v3/Service.php b/src/Identity/v3/Service.php index 5760436f..407cd9cb 100644 --- a/src/Identity/v3/Service.php +++ b/src/Identity/v3/Service.php @@ -47,6 +47,18 @@ public function authenticate(array $options): array $region = $options['region']; $interface = $options['interface'] ?? Enum::INTERFACE_PUBLIC; + if (!empty($options['catalog_overrides'])) { + $baseUrl = $token->catalog->getServiceUrlOverride( + $name, + $type, + $region, + $interface, + $options['catalog_overrides'] + ); + if ($baseUrl) { + return [$token, $baseUrl]; + } + } if ($baseUrl = $token->catalog->getServiceUrl($name, $type, $region, $interface)) { return [$token, $baseUrl]; } From b6124e11b06ea723270ecd9f6194445bcafa3381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Thu, 4 Apr 2024 01:39:06 -0300 Subject: [PATCH 2/3] change the comment to avoid patch double-application --- src/Identity/v3/Models/Catalog.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Identity/v3/Models/Catalog.php b/src/Identity/v3/Models/Catalog.php index a4957243..9f18ff29 100644 --- a/src/Identity/v3/Models/Catalog.php +++ b/src/Identity/v3/Models/Catalog.php @@ -32,7 +32,7 @@ public function populateFromArray(array $data): self } /** - * Retrieve a base URL for a service, according to its catalog name, type, region. + * Override a given service's predetermined endpoint URL. * * @param string $name the name of the service as it appears in the catalog * @param string $type the type of the service as it appears in the catalog From 97a68999d402badb91fb7bcb90919c8f682711ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Thu, 5 Nov 2026 23:19:12 -0300 Subject: [PATCH 3/3] tests & docs + fixed misleading config validation error message (Deepseek) --- doc/setup.rst | 16 +++ samples/Setup/catalog_override.php | 21 +++ src/Identity/v3/Models/Catalog.php | 18 +-- src/Identity/v3/Service.php | 1 + src/OpenStack.php | 1 + tests/unit/Identity/v3/Models/CatalogTest.php | 123 ++++++++++++++++++ tests/unit/Identity/v3/ServiceTest.php | 82 ++++++++++++ 7 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 samples/Setup/catalog_override.php diff --git a/doc/setup.rst b/doc/setup.rst index 503a0c74..09d57d4c 100644 --- a/doc/setup.rst +++ b/doc/setup.rst @@ -45,6 +45,22 @@ If you already have a valid token, you can use it to authenticate. .. sample:: Setup/token_id.php +Overriding catalog endpoints +---------------------------- + +Normally the endpoint URL for a service is read from the Identity catalog returned by the authentication request. +In some cases that URL is not the one that should be used, for example when an application credential is used to +access an object container that belongs to another project. The ``catalog_overrides`` option lets you replace the +catalog URL for a service. + +Each override must contain a ``url`` and at least one of ``name`` or ``type``. Every key that is specified must +match the requested service, while omitted keys act as wildcards. The ``region`` and ``interface`` keys are +therefore optional and can be used to restrict the override further. + +.. sample:: Setup/catalog_override.php + +If no override matches the requested service, the URL from the catalog is used as usual. + Other options ------------- diff --git a/samples/Setup/catalog_override.php b/samples/Setup/catalog_override.php new file mode 100644 index 00000000..92dd6877 --- /dev/null +++ b/samples/Setup/catalog_override.php @@ -0,0 +1,21 @@ + '{authUrl}', + 'region' => '{region}', + 'user' => [ + 'id' => '{userId}', + 'password' => '{password}' + ], + 'catalog_overrides' => [ + [ + 'name' => 'swift', + 'type' => 'object-store', + 'region' => '{region}', + 'interface' => 'public', + 'url' => '{serviceUrl}', + ], + ], +]); diff --git a/src/Identity/v3/Models/Catalog.php b/src/Identity/v3/Models/Catalog.php index 9f18ff29..ce5cc37e 100644 --- a/src/Identity/v3/Models/Catalog.php +++ b/src/Identity/v3/Models/Catalog.php @@ -38,8 +38,9 @@ public function populateFromArray(array $data): self * @param string $type the type of the service as it appears in the catalog * @param string $region the region of the service as it appears in the catalog * @param string $interface the interface of the service as it appears in the catalog + * @param array $overrides endpoint overrides to match against * - * @return null|string NULL if no URL found + * @return string|null NULL if no URL found */ public function getServiceUrlOverride( string $name, @@ -49,19 +50,20 @@ public function getServiceUrlOverride( array $overrides ): ?string { foreach ($overrides as $override) { + if (empty($override['name']) && empty($override['type'])) { + throw new \RuntimeException('Endpoint override must specify either a "name" or a "type".'); + } + + if (empty($override['url'])) { + throw new \RuntimeException(sprintf("Endpoint override must specify an \"url\".\nName: %s\nType: %s\nRegion: %s\nInterface: %s", $override['name'] ?? '', $override['type'] ?? '', $override['region'] ?? '', $override['interface'] ?? '')); + } + if ( (empty($override['name']) || $name == $override['name']) && (empty($override['type']) || $type == $override['type']) && (empty($override['region']) || $region == $override['region']) && (empty($override['interface']) || $interface == $override['interface']) ) { - if (empty($override['name']) && empty($override['type'])) { - throw new \RuntimeException(sprintf("Endpoint override must at least specify an \"url\" and either \"name\" or a \"type\".")); - } - if (empty($override['url'])) { - throw new \RuntimeException(sprintf("Endpoint override must specify an \"url\".\nName: %s\nType: %s\nRegion: %s\nInterface: %s", $override['name'] ?? '', $override['type'] ?? '', $override['region'] ?? '', $override['interface'] ?? '')); - } - return $override['url']; } } diff --git a/src/Identity/v3/Service.php b/src/Identity/v3/Service.php index 407cd9cb..43e3f86d 100644 --- a/src/Identity/v3/Service.php +++ b/src/Identity/v3/Service.php @@ -59,6 +59,7 @@ public function authenticate(array $options): array return [$token, $baseUrl]; } } + if ($baseUrl = $token->catalog->getServiceUrl($name, $type, $region, $interface)) { return [$token, $baseUrl]; } diff --git a/src/OpenStack.php b/src/OpenStack.php index 254e66df..aaa7d2a0 100644 --- a/src/OpenStack.php +++ b/src/OpenStack.php @@ -32,6 +32,7 @@ class OpenStack * ['messageFormatter'] = (MessageFormatter) Must set if debugLog is true [OPTIONAL] * ['requestOptions'] = (array) Guzzle Http request options [OPTIONAL] * ['cachedToken'] = (array) Cached token credential [OPTIONAL] + * ['catalog_overrides'] = (array) Override catalog endpoint URLs [OPTIONAL] */ public function __construct(array $options = [], ?Builder $builder = null) { diff --git a/tests/unit/Identity/v3/Models/CatalogTest.php b/tests/unit/Identity/v3/Models/CatalogTest.php index e499c0bf..41527cfe 100644 --- a/tests/unit/Identity/v3/Models/CatalogTest.php +++ b/tests/unit/Identity/v3/Models/CatalogTest.php @@ -39,6 +39,129 @@ public function test_it_returns_service_url() self::assertEquals($url, $this->catalog->getServiceUrl('foo', 'bar', 'baz', '')); } + public function test_it_returns_service_url_override_matching_name_and_type() + { + $overrides = [ + ['name' => 'swift', 'type' => 'object-store', 'url' => 'http://override.example'], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_service_url_override_matching_only_name() + { + $overrides = [ + ['name' => 'swift', 'url' => 'http://override.example'], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_service_url_override_matching_only_type() + { + $overrides = [ + ['type' => 'object-store', 'url' => 'http://override.example'], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_service_url_override_matching_region_and_interface() + { + $overrides = [ + [ + 'name' => 'swift', + 'region' => 'RegionOne', + 'interface' => 'public', + 'url' => 'http://override.example', + ], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_null_when_no_override_matches() + { + $overrides = [ + ['name' => 'nova', 'url' => 'http://override.example'], + ]; + + self::assertNull( + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_null_when_region_or_interface_does_not_match() + { + $overrides = [ + [ + 'name' => 'swift', + 'region' => 'RegionTwo', + 'interface' => 'internal', + 'url' => 'http://override.example', + ], + ]; + + self::assertNull( + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_null_for_empty_overrides() + { + self::assertNull( + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', []) + ); + } + + public function test_it_throws_when_matching_override_has_neither_name_nor_type() + { + $overrides = [ + ['region' => 'RegionOne', 'url' => 'http://override.example'], + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must specify either a "name" or a "type"'); + + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides); + } + + public function test_it_throws_when_matching_override_has_no_url() + { + $overrides = [ + ['name' => 'swift'], + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must specify an "url"'); + + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides); + } + + public function test_it_throws_when_invalid_override_does_not_match() + { + $overrides = [ + ['name' => 'nova'], + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must specify an "url"'); + + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides); + } + public function test_it_throws_if_no_url_found() { $service = $this->prophesize(Service::class); diff --git a/tests/unit/Identity/v3/ServiceTest.php b/tests/unit/Identity/v3/ServiceTest.php index 420bbf6d..8630244d 100644 --- a/tests/unit/Identity/v3/ServiceTest.php +++ b/tests/unit/Identity/v3/ServiceTest.php @@ -139,6 +139,68 @@ public function test_it_authenticates_using_cache_token() self::assertEquals('http://example.org:8080/v1/AUTH_e00abf65afca49609eedd163c515cf10', $url); } + public function test_it_authenticates_using_catalog_override() + { + $userOptions = [ + 'catalogName' => 'swift', + 'catalogType' => 'object-store', + 'region' => 'RegionOne', + 'cachedToken' => $this->cachedToken(), + 'catalog_overrides' => [ + [ + 'name' => 'swift', + 'type' => 'object-store', + 'region' => 'RegionOne', + 'interface' => 'public', + 'url' => 'http://override.example/v1/AUTH_override', + ], + ], + ]; + + [$token, $url] = $this->service->authenticate($userOptions); + + self::assertInstanceOf(Models\Token::class, $token); + self::assertEquals('http://override.example/v1/AUTH_override', $url); + } + + public function test_it_uses_public_interface_by_default_for_catalog_override() + { + $userOptions = [ + 'catalogName' => 'swift', + 'catalogType' => 'object-store', + 'region' => 'RegionOne', + 'cachedToken' => $this->cachedToken(), + 'catalog_overrides' => [ + [ + 'name' => 'swift', + 'interface' => Enum::INTERFACE_PUBLIC, + 'url' => 'http://override.example/v1/AUTH_override', + ], + ], + ]; + + $result = $this->service->authenticate($userOptions); + + self::assertEquals('http://override.example/v1/AUTH_override', $result[1]); + } + + public function test_it_falls_back_to_catalog_url_when_override_does_not_match() + { + $userOptions = [ + 'catalogName' => 'swift', + 'catalogType' => 'object-store', + 'region' => 'RegionOne', + 'cachedToken' => $this->cachedToken(), + 'catalog_overrides' => [ + ['name' => 'nova', 'url' => 'http://override.example/v1/AUTH_override'], + ], + ]; + + $result = $this->service->authenticate($userOptions); + + self::assertEquals('http://example.org:8080/v1/AUTH_catalog', $result[1]); + } + public function test_it_authenticates_and_throws_exception_when_authenticate_with_expired_cached_token() { $cachedToken = [ @@ -684,4 +746,24 @@ public function test_it_gets_endpoint() { $this->getTest($this->createFn($this->service, 'getEndpoint', 'id'), 'endpoint'); } + + private function cachedToken(): array + { + return [ + 'expires_at' => '2199-11-24T04:47:49.000000Z', + 'catalog' => [ + [ + 'name' => 'swift', + 'type' => 'object-store', + 'endpoints' => [ + [ + 'region' => 'RegionOne', + 'interface' => 'public', + 'url' => 'http://example.org:8080/v1/AUTH_catalog', + ], + ], + ], + ], + ]; + } }