diff --git a/src/Providers/EcsRamRoleCredentialsProvider.php b/src/Providers/EcsRamRoleCredentialsProvider.php index 8dffb50..1624ab1 100644 --- a/src/Providers/EcsRamRoleCredentialsProvider.php +++ b/src/Providers/EcsRamRoleCredentialsProvider.php @@ -5,6 +5,7 @@ use AlibabaCloud\Credentials\Utils\Helper; use AlibabaCloud\Credentials\Utils\Filter; use AlibabaCloud\Credentials\Request\Request; +use Exception; use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; use RuntimeException; @@ -110,6 +111,16 @@ private function filterDisableECSIMDSv1($params) } } + /** + * @param string|null $metadataToken + * + * @return bool + */ + private function shouldFallbackToIMDSv1($metadataToken) + { + return !is_null($metadataToken) && !$this->disableIMDSv1; + } + /** * Get credentials by request. * @@ -128,12 +139,33 @@ public function refreshCredentials() $this->roleName = $this->getRoleNameFromMeta(); } - $url = $this->metadataHost . $this->ecsUri . $this->roleName; + $metadataToken = $this->getMetadataToken(); + try { + return $this->doRefreshCredentials($this->roleName, $metadataToken); + } catch (Exception $e) { + if ($this->shouldFallbackToIMDSv1($metadataToken)) { + return $this->doRefreshCredentials($this->roleName, null); + } + throw $e; + } + } + + /** + * @param string $roleName + * @param string|null $metadataToken + * + * @return RefreshResult + * @throws InvalidArgumentException + * @throws RuntimeException + * @throws GuzzleException + */ + private function doRefreshCredentials($roleName, $metadataToken) + { + $url = $this->metadataHost . $this->ecsUri . $roleName; $options = Request::commonOptions(); $options['read_timeout'] = $this->readTimeout; $options['connect_timeout'] = $this->connectTimeout; - $metadataToken = $this->getMetadataToken(); if (!is_null($metadataToken)) { $options['headers']['X-aliyun-ecs-metadata-token'] = $metadataToken; } @@ -174,12 +206,32 @@ public function refreshCredentials() * @throws GuzzleException */ private function getRoleNameFromMeta() + { + $metadataToken = $this->getMetadataToken(); + try { + return $this->doGetRoleNameFromMeta($metadataToken); + } catch (Exception $e) { + if ($this->shouldFallbackToIMDSv1($metadataToken)) { + return $this->doGetRoleNameFromMeta(null); + } + throw $e; + } + } + + /** + * @param string|null $metadataToken + * + * @return string + * @throws InvalidArgumentException + * @throws RuntimeException + * @throws GuzzleException + */ + private function doGetRoleNameFromMeta($metadataToken) { $options = Request::commonOptions(); $options['read_timeout'] = $this->readTimeout; $options['connect_timeout'] = $this->connectTimeout; - $metadataToken = $this->getMetadataToken(); if (!is_null($metadataToken)) { $options['headers']['X-aliyun-ecs-metadata-token'] = $metadataToken; } @@ -209,7 +261,7 @@ private function getRoleNameFromMeta() /** * Get metadata token by request. * - * @return string + * @return string|null * @throws RuntimeException * @throws GuzzleException */ diff --git a/src/Utils/MockTrait.php b/src/Utils/MockTrait.php index cc07119..e851cc8 100644 --- a/src/Utils/MockTrait.php +++ b/src/Utils/MockTrait.php @@ -92,6 +92,8 @@ public static function cancelMock() { self::$mockQueue = []; self::$mock = null; + // Clear in place so history middleware references stay valid. + array_splice(self::$history, 0); } /** diff --git a/tests/Unit/EcsRamRoleCredentialTest.php b/tests/Unit/EcsRamRoleCredentialTest.php index a92ecee..2cf83ba 100644 --- a/tests/Unit/EcsRamRoleCredentialTest.php +++ b/tests/Unit/EcsRamRoleCredentialTest.php @@ -139,6 +139,8 @@ public function testDefault404() { Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(404, [], 'RoleName'); + // IMDSv1 fallback retry + Credentials::mockResponse(404, [], 'RoleName'); $this->credential = new EcsRamRoleCredential(); @@ -161,6 +163,8 @@ public function testDefault500() { Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(500, [], 'RoleName'); + // IMDSv1 fallback retry + Credentials::mockResponse(500, [], 'RoleName'); $this->credential = new EcsRamRoleCredential(); $this->expectException(RuntimeException::class); @@ -185,6 +189,8 @@ public function testDefaultEmpty() Credentials::mockResponse(200, [], 'RoleNameTest'); Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(200, [], []); + // IMDSv1 fallback retry after incomplete credentials body + Credentials::mockResponse(200, [], []); $this->credential = new EcsRamRoleCredential(); @@ -237,6 +243,8 @@ public function testStsIncomplete() $credential = new EcsRamRoleCredential('EcsRamRoleTest2'); Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(200, [], $result); + // IMDSv1 fallback retry after incomplete credentials body + Credentials::mockResponse(200, [], $result); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Error retrieving credentials from IMDS result:{"Expiration":"2049-10-01 00:00:00","AccessKeyId":"foo"}'); // Test @@ -260,6 +268,8 @@ public function testStsNoCode() $credential = new EcsRamRoleCredential('EcsRamRoleTest2'); Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(200, [], $result); + // IMDSv1 fallback retry after Code missing + Credentials::mockResponse(200, [], $result); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Error retrieving credentials from IMDS result, Code is not Success:{"Expiration":"2049-10-01 00:00:00","AccessKeyId":"foo","AccessKeySecret":"bar","SecurityToken":"token"}'); // Test @@ -281,6 +291,8 @@ public function testSts404() $credential = new EcsRamRoleCredential('EcsRamRoleTest3'); Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(404, [], $result); + // IMDSv1 fallback retry + Credentials::mockResponse(404, [], $result); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The role was not found in the instance'); @@ -304,6 +316,8 @@ public function testSts500() $credential = new EcsRamRoleCredential('EcsRamRoleTest3'); Credentials::mockResponse(200, [], 'Token'); Credentials::mockResponse(500, [], $result); + // IMDSv1 fallback retry + Credentials::mockResponse(500, [], $result); $this->expectException(RuntimeException::class); if (method_exists($this, 'expectExceptionMessageMatches')) { @@ -345,9 +359,9 @@ public function testStsWithoutMock() $this->expectException(RuntimeException::class); if (method_exists($this, 'expectExceptionMessageMatches')) { - $this->expectExceptionMessageMatches('/Timeout was reached/'); + $this->expectExceptionMessageMatches('/Timeout was reached|Connection timed out|cURL error 28/'); } elseif (method_exists($this, 'expectExceptionMessageRegExp')) { - $this->expectExceptionMessageRegExp('/Timeout was reached/'); + $this->expectExceptionMessageRegExp('/Timeout was reached|Connection timed out|cURL error 28/'); } // Test self::assertEquals('foo', $credential->getAccessKeyId()); diff --git a/tests/Unit/Providers/EcsRamRoleCredentialsProviderTest.php b/tests/Unit/Providers/EcsRamRoleCredentialsProviderTest.php index 186dc83..b8c8a02 100644 --- a/tests/Unit/Providers/EcsRamRoleCredentialsProviderTest.php +++ b/tests/Unit/Providers/EcsRamRoleCredentialsProviderTest.php @@ -4,6 +4,7 @@ use AlibabaCloud\Credentials\Credentials; use AlibabaCloud\Credentials\Providers\EcsRamRoleCredentialsProvider; +use AlibabaCloud\Credentials\Providers\SessionCredentialsProvider; use Exception; use GuzzleHttp\Exception\GuzzleException; use PHPUnit\Framework\TestCase; @@ -25,6 +26,18 @@ protected function initialize() { parent::setUp(); Credentials::cancelMock(); + putenv('ALIBABA_CLOUD_ECS_METADATA_DISABLED'); + putenv('ALIBABA_CLOUD_IMDSV1_DISABLED'); + putenv('ALIBABA_CLOUD_ECS_METADATA'); + $this->clearCredentialsCache(); + } + + private function clearCredentialsCache() + { + $reflection = new ReflectionClass(SessionCredentialsProvider::class); + $property = $reflection->getProperty('credentialsCache'); + $property->setAccessible(true); + $property->setValue(null, []); } private function getPrivateField($instance, $field) @@ -91,15 +104,17 @@ public function testEnvDisabled() putenv("ALIBABA_CLOUD_ECS_METADATA_DISABLED=true"); $provider = new EcsRamRoleCredentialsProvider([], []); - $this->expectException(RuntimeException::class); - if (method_exists($this, 'expectExceptionMessageMatches')) { - $this->expectExceptionMessageMatches('/IMDS credentials is disabled/'); - } elseif (method_exists($this, 'expectExceptionMessageRegExp')) { - $this->expectExceptionMessageRegExp('/IMDS credentials is disabled/'); + try { + $this->expectException(RuntimeException::class); + if (method_exists($this, 'expectExceptionMessageMatches')) { + $this->expectExceptionMessageMatches('/IMDS credentials is disabled/'); + } elseif (method_exists($this, 'expectExceptionMessageRegExp')) { + $this->expectExceptionMessageRegExp('/IMDS credentials is disabled/'); + } + $provider->getCredentials(); + } finally { + putenv('ALIBABA_CLOUD_ECS_METADATA_DISABLED'); } - $provider->getCredentials(); - - putenv("ALIBABA_CLOUD_ECS_METADATA_DISABLED="); } public function testGetDisableECSIMDSv1() @@ -227,4 +242,84 @@ public function testEnableV1404() $request = end($histroy)['request']; self::assertEquals(null, $token); } + + public function testFallbackToIMDSv1WhenCredentialGetFailsAfterToken() + { + $result = [ + 'Expiration' => '2049-10-01 00:00:00', + 'AccessKeyId' => 'foo', + 'AccessKeySecret' => 'bar', + 'SecurityToken' => 'token', + 'Code' => 'Success', + ]; + $provider = new EcsRamRoleCredentialsProvider([ + 'roleName' => 'test', + 'disableIMDSv1' => false, + ]); + + Credentials::mockResponse(200, [], 'Token'); + Credentials::mockResponse(500, [], 'v2 failed'); + Credentials::mockResponse(200, [], $result); + + $credential = $provider->getCredentials(); + self::assertEquals('foo', $credential->getAccessKeyId()); + self::assertEquals('bar', $credential->getAccessKeySecret()); + self::assertEquals('token', $credential->getSecurityToken()); + + $history = Credentials::getHistroy(); + self::assertEquals(3, count($history)); + self::assertEquals('PUT', $history[0]['request']->getMethod()); + self::assertTrue($history[1]['request']->hasHeader('X-aliyun-ecs-metadata-token')); + self::assertFalse($history[2]['request']->hasHeader('X-aliyun-ecs-metadata-token')); + } + + public function testNoFallbackWhenDisableIMDSv1True() + { + $provider = new EcsRamRoleCredentialsProvider([ + 'roleName' => 'test', + 'disableIMDSv1' => true, + ]); + + Credentials::mockResponse(200, [], 'Token'); + Credentials::mockResponse(500, [], 'v2 failed'); + + $this->expectException(RuntimeException::class); + if (method_exists($this, 'expectExceptionMessageMatches')) { + $this->expectExceptionMessageMatches('/Error refreshing credentials from IMDS, statusCode: 500/'); + } elseif (method_exists($this, 'expectExceptionMessageRegExp')) { + $this->expectExceptionMessageRegExp('/Error refreshing credentials from IMDS, statusCode: 500/'); + } + + $provider->getCredentials(); + } + + public function testFallbackToIMDSv1WhenRoleNameGetFailsAfterToken() + { + $result = [ + 'Expiration' => '2049-10-01 00:00:00', + 'AccessKeyId' => 'foo', + 'AccessKeySecret' => 'bar', + 'SecurityToken' => 'token', + 'Code' => 'Success', + ]; + $provider = new EcsRamRoleCredentialsProvider([ + 'disableIMDSv1' => false, + ]); + + // getRoleNameFromMeta: token ok, GET with token fails, retry without token ok + Credentials::mockResponse(200, [], 'Token'); + Credentials::mockResponse(404, [], 'not found'); + Credentials::mockResponse(200, [], 'fallback-role'); + // refreshCredentials: token + credentials + Credentials::mockResponse(200, [], 'Token'); + Credentials::mockResponse(200, [], $result); + + $credential = $provider->getCredentials(); + self::assertEquals('foo', $credential->getAccessKeyId()); + self::assertEquals('fallback-role', $provider->getRoleName()); + + $history = Credentials::getHistroy(); + self::assertEquals(5, count($history)); + self::assertFalse($history[2]['request']->hasHeader('X-aliyun-ecs-metadata-token')); + } }