Skip to content

Commit 536bdc0

Browse files
committed
Add Ec2 detector and unit tests
Update Ec2Detector.php Fix style Fix psalm
1 parent 968b477 commit 536bdc0

File tree

2 files changed

+397
-0
lines changed

2 files changed

+397
-0
lines changed

detectors/Ec2Detector.php

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Copyright The OpenTelemetry Authors
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* https://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
21+
namespace Detectors\Aws;
22+
23+
use GuzzleHttp\Client;
24+
use GuzzleHttp\Exception\RequestException;
25+
use GuzzleHttp\Psr7\Request;
26+
use OpenTelemetry\Sdk\Resource\ResourceConstants;
27+
use OpenTelemetry\Sdk\Resource\ResourceInfo;
28+
use OpenTelemetry\Sdk\Trace\Attributes;
29+
30+
/**
31+
* The AwsEc2Detector can be used to detect if a process is running in AWS EC2
32+
* and return a {@link Resource} populated with metadata about the EC2
33+
* instance. Returns an empty Resource if detection fails.
34+
*/
35+
class Ec2Detector
36+
{
37+
private const SCHEME = 'http://';
38+
private const AWS_IDMS_ENDPOINT = '169.254.169.254';
39+
private const AWS_INSTANCE_TOKEN_DOCUMENT_PATH = '/latest/api/token';
40+
private const AWS_INSTANCE_IDENTITY_DOCUMENT_PATH = '/latest/dynamic/instance-identity/document';
41+
private const AWS_INSTANCE_HOST_DOCUMENT_PATH = '/latest/meta-data/hostname';
42+
private const AWS_METADATA_TTL_HEADER = 'X-aws-ec2-metadata-token-ttl-seconds';
43+
private const AWS_METADATA_TOKEN_HEADER = 'X-aws-ec2-metadata-token';
44+
private const MILLISECOND_TIME_OUT = 1000;
45+
private const CLOUD_PROVIDER = 'aws';
46+
47+
private $guzzle;
48+
49+
public function __construct(Client $guzzle)
50+
{
51+
$this->guzzle = $guzzle;
52+
}
53+
54+
/**
55+
* Attempts to connect and obtain an AWS instance Identity document. If the
56+
* connection is succesful it returns a {@link Resource}
57+
* populated with instance metadata. Returns an empty {@link Resource}
58+
* if the connection or parsing of the identity document fails.
59+
*
60+
* @param config (unused) The resource detection config
61+
*/
62+
public function detect()
63+
{
64+
try {
65+
$token = $this->fetchToken();
66+
67+
$hostName = $this->fetchHostname($token);
68+
69+
$identitiesJson = $this->fetchIdentity($token);
70+
71+
if (!$token || !$identitiesJson) {
72+
return ResourceInfo::emptyResource();
73+
}
74+
75+
$attributes = new Attributes();
76+
77+
foreach ($identitiesJson as $key => $value) {
78+
switch ($key) {
79+
case 'instanceId':
80+
$attributes->setAttribute(ResourceConstants::HOST_ID, $value);
81+
82+
break;
83+
case 'availabilityZone':
84+
$attributes->setAttribute(ResourceConstants::CLOUD_ZONE, $value);
85+
86+
break;
87+
case 'instanceType':
88+
$attributes->setAttribute(ResourceConstants::HOST_TYPE, $value);
89+
90+
break;
91+
case 'imageId':
92+
$attributes->setAttribute(ResourceConstants::HOST_IMAGE_ID, $value);
93+
94+
break;
95+
case 'accountId':
96+
$attributes->setAttribute(ResourceConstants::CLOUD_ACCOUNT_ID, $value);
97+
98+
break;
99+
case 'region':
100+
$attributes->setAttribute(ResourceConstants::CLOUD_REGION, $value);
101+
102+
break;
103+
}
104+
}
105+
106+
$attributes->setAttribute(ResourceConstants::HOST_HOSTNAME, $hostName);
107+
$attributes->setAttribute(ResourceConstants::CLOUD_PROVIDER, self::CLOUD_PROVIDER);
108+
109+
return ResourceInfo::create($attributes);
110+
} catch (\Throwable $e) {
111+
//TODO: add 'Process is not running on K8S when logging is added
112+
return ResourceInfo::emptyResource();
113+
}
114+
}
115+
116+
private function fetchToken()
117+
{
118+
return $this->request(
119+
'PUT',
120+
self::AWS_INSTANCE_TOKEN_DOCUMENT_PATH,
121+
[self::AWS_METADATA_TTL_HEADER => '60']
122+
);
123+
}
124+
125+
private function fetchIdentity(String $token)
126+
{
127+
$body = $this->request(
128+
'GET',
129+
self::AWS_INSTANCE_IDENTITY_DOCUMENT_PATH,
130+
[self::AWS_METADATA_TOKEN_HEADER => $token]
131+
);
132+
133+
$json = json_decode($body, true);
134+
135+
if (isset($json)) {
136+
return $json;
137+
}
138+
139+
return null;
140+
}
141+
142+
private function fetchHostname(String $token)
143+
{
144+
return $this->request(
145+
'GET',
146+
self::AWS_INSTANCE_HOST_DOCUMENT_PATH,
147+
[self::AWS_METADATA_TOKEN_HEADER => $token]
148+
);
149+
}
150+
151+
/**
152+
* Function to create a request for any of the given
153+
* fetch functions.
154+
*/
155+
private function request($method, $path, $header)
156+
{
157+
$client = $this->guzzle;
158+
159+
try {
160+
$response = $client->request(
161+
$method,
162+
self::SCHEME . self::AWS_IDMS_ENDPOINT . $path,
163+
[
164+
'headers' => $header,
165+
'timeout' => self::MILLISECOND_TIME_OUT,
166+
]
167+
);
168+
169+
$body = $response->getBody()->getContents();
170+
$responseCode = $response->getStatusCode();
171+
172+
if (!empty($body) && $responseCode < 300 && $responseCode >= 200) {
173+
return $body;
174+
}
175+
176+
return null;
177+
} catch (RequestException $e) {
178+
// TODO: add log for exception. The code below
179+
// provides the exception thrown:
180+
// echo Psr7\Message::toString($e->getRequest());
181+
// if ($e->hasResponse()) {
182+
// echo Psr7\Message::toString($e->getResponse());
183+
// }
184+
return null;
185+
}
186+
}
187+
}

tests/unit/Ec2DetectorTest.php

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Detectors\Aws\Ec2Detector;
6+
use GuzzleHttp\Client;
7+
use GuzzleHttp\Handler\MockHandler;
8+
use GuzzleHttp\HandlerStack;
9+
use GuzzleHttp\Psr7\Response;
10+
use OpenTelemetry\Sdk\Resource\ResourceConstants;
11+
use OpenTelemetry\Sdk\Resource\ResourceInfo;
12+
use OpenTelemetry\Sdk\Trace\Attributes;
13+
use PHPUnit\Framework\TestCase;
14+
15+
class Ec2DetectorTest extends TestCase
16+
{
17+
private const MOCK_TOKEN_RESPONSE = 'my-token';
18+
private const MOCK_HOSTNAME = 'my-hostname';
19+
private const MOCK_IDENTITY = '{
20+
"instanceId": "my-instance-id",
21+
"instanceType": "my-instance-type",
22+
"accountId": "my-account-id",
23+
"region": "my-region",
24+
"availabilityZone": "my-zone",
25+
"imageId": "image-id"
26+
}';
27+
28+
private const MOCK_IDENTITY_INCOMPLETE = '{
29+
"instanceId": "my-instance-id",
30+
"instanceType": "my-instance-type",
31+
"availabilityZone": "my-zone",
32+
"imageId": "image-id"
33+
}';
34+
35+
private const HOST_ID = 'my-instance-id';
36+
private const CLOUD_ZONE = 'my-zone';
37+
private const HOST_TYPE = 'my-instance-type';
38+
private const HOST_IMAGE_ID = 'image-id';
39+
private const CLOUD_ACCOUNT_ID = 'my-account-id';
40+
private const CLOUD_REGION = 'my-region';
41+
private const CLOUD_PROVIDER = 'aws';
42+
43+
/**
44+
* @test
45+
*/
46+
public function TestValidEc2()
47+
{
48+
$mockGuzzle = new MockHandler([
49+
//Fetch token response
50+
new Response(200, ['Foo' => 'Bar'], self::MOCK_TOKEN_RESPONSE),
51+
// Fetch hostName response
52+
new Response(200, ['Foo' => 'Bar'], self::MOCK_HOSTNAME),
53+
// Fetch identities reponse
54+
new Response(200, ['Foo' => 'Bar'], self::MOCK_IDENTITY),
55+
]);
56+
57+
$handlerStack = HandlerStack::create($mockGuzzle);
58+
$client = new Client(['handler' => $handlerStack]);
59+
60+
$detector = new Ec2Detector($client);
61+
62+
$this->assertEquals(ResourceInfo::create(
63+
new Attributes(
64+
[
65+
ResourceConstants::HOST_ID => self::HOST_ID,
66+
ResourceConstants::CLOUD_ZONE => self::CLOUD_ZONE,
67+
ResourceConstants::HOST_TYPE => self::HOST_TYPE,
68+
ResourceConstants::HOST_IMAGE_ID => self::HOST_IMAGE_ID,
69+
ResourceConstants::CLOUD_ACCOUNT_ID => self::CLOUD_ACCOUNT_ID,
70+
ResourceConstants::CLOUD_REGION => self::CLOUD_REGION,
71+
ResourceConstants::HOST_HOSTNAME => self::MOCK_HOSTNAME,
72+
ResourceConstants::CLOUD_PROVIDER => self::CLOUD_PROVIDER,
73+
]
74+
)
75+
), $detector->detect());
76+
}
77+
78+
/**
79+
* @test
80+
*/
81+
public function TestInvalidTokenBody()
82+
{
83+
$mockGuzzle = new MockHandler([
84+
//Fetch token response
85+
new Response(200, ['Foo' => 'Bar']),
86+
// Fetch hostName response
87+
new Response(200, ['Foo' => 'Bar'], self::MOCK_HOSTNAME),
88+
// Fetch identities reponse
89+
new Response(200, ['Foo' => 'Bar'], self::MOCK_IDENTITY),
90+
]);
91+
92+
$handlerStack = HandlerStack::create($mockGuzzle);
93+
$client = new Client(['handler' => $handlerStack]);
94+
95+
$detector = new Ec2Detector($client);
96+
97+
$this->assertEquals(ResourceInfo::emptyResource(), $detector->detect());
98+
}
99+
100+
/**
101+
* @test
102+
*/
103+
public function TestInvalidTokenResponseCode()
104+
{
105+
$mockGuzzle = new MockHandler([
106+
//Fetch token response
107+
new Response(404, ['Foo' => 'Bar'], self::MOCK_TOKEN_RESPONSE),
108+
// Fetch hostName response
109+
new Response(200, ['Foo' => 'Bar'], self::MOCK_HOSTNAME),
110+
// Fetch identities reponse
111+
new Response(200, ['Foo' => 'Bar'], self::MOCK_IDENTITY),
112+
]);
113+
114+
$handlerStack = HandlerStack::create($mockGuzzle);
115+
$client = new Client(['handler' => $handlerStack]);
116+
117+
$detector = new Ec2Detector($client);
118+
119+
$this->assertEquals(ResourceInfo::emptyResource(), $detector->detect());
120+
}
121+
122+
/**
123+
* @test
124+
*/
125+
public function TestInvalidHostName()
126+
{
127+
$mockGuzzle = new MockHandler([
128+
//Fetch token response
129+
new Response(200, ['Foo' => 'Bar'], self::MOCK_TOKEN_RESPONSE),
130+
// Fetch hostName response
131+
new Response(200, ['Foo' => 'Bar']),
132+
// Fetch identities reponse
133+
new Response(200, ['Foo' => 'Bar'], self::MOCK_IDENTITY),
134+
]);
135+
136+
$handlerStack = HandlerStack::create($mockGuzzle);
137+
$client = new Client(['handler' => $handlerStack]);
138+
139+
$detector = new Ec2Detector($client);
140+
141+
$this->assertEquals(ResourceInfo::create(
142+
new Attributes(
143+
[
144+
ResourceConstants::HOST_ID => self::HOST_ID,
145+
ResourceConstants::CLOUD_ZONE => self::CLOUD_ZONE,
146+
ResourceConstants::HOST_TYPE => self::HOST_TYPE,
147+
ResourceConstants::HOST_IMAGE_ID => self::HOST_IMAGE_ID,
148+
ResourceConstants::CLOUD_ACCOUNT_ID => self::CLOUD_ACCOUNT_ID,
149+
ResourceConstants::CLOUD_REGION => self::CLOUD_REGION,
150+
ResourceConstants::CLOUD_PROVIDER => self::CLOUD_PROVIDER
151+
]
152+
)
153+
), $detector->detect());
154+
}
155+
156+
/**
157+
* @test
158+
*/
159+
public function TestInvalidIdentities()
160+
{
161+
$mockGuzzle = new MockHandler([
162+
//Fetch token response
163+
new Response(200, ['Foo' => 'Bar'], self::MOCK_TOKEN_RESPONSE),
164+
// Fetch hostName response
165+
new Response(200, ['Foo' => 'Bar'], self::MOCK_HOSTNAME),
166+
// Fetch identities reponse
167+
new Response(200, ['Foo' => 'Bar']),
168+
]);
169+
170+
$handlerStack = HandlerStack::create($mockGuzzle);
171+
$client = new Client(['handler' => $handlerStack]);
172+
173+
$detector = new Ec2Detector($client);
174+
175+
$this->assertEquals(ResourceInfo::emptyResource(), $detector->detect());
176+
}
177+
178+
/**
179+
* @test
180+
*/
181+
public function TestInvalidIncompleteIdentities()
182+
{
183+
$mockGuzzle = new MockHandler([
184+
//Fetch token response
185+
new Response(200, ['Foo' => 'Bar'], self::MOCK_TOKEN_RESPONSE),
186+
// Fetch hostName response
187+
new Response(200, ['Foo' => 'Bar'], self::MOCK_HOSTNAME),
188+
// Fetch identities reponse
189+
new Response(200, ['Foo' => 'Bar'], self::MOCK_IDENTITY_INCOMPLETE),
190+
]);
191+
192+
$handlerStack = HandlerStack::create($mockGuzzle);
193+
$client = new Client(['handler' => $handlerStack]);
194+
195+
$detector = new Ec2Detector($client);
196+
197+
$this->assertEquals(ResourceInfo::create(
198+
new Attributes(
199+
[
200+
ResourceConstants::HOST_ID => self::HOST_ID,
201+
ResourceConstants::CLOUD_ZONE => self::CLOUD_ZONE,
202+
ResourceConstants::HOST_TYPE => self::HOST_TYPE,
203+
ResourceConstants::HOST_IMAGE_ID => self::HOST_IMAGE_ID,
204+
ResourceConstants::HOST_HOSTNAME => self::MOCK_HOSTNAME,
205+
ResourceConstants::CLOUD_PROVIDER => self::CLOUD_PROVIDER
206+
]
207+
)
208+
), $detector->detect());
209+
}
210+
}

0 commit comments

Comments
 (0)