diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e0e46da0..9b6161135 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -94,6 +94,7 @@ updates: - "/src/Propagation/TraceResponse" - "/src/ResourceDetectors/Azure" - "/src/ResourceDetectors/Container" + - "/src/ResourceDetectors/DigitalOcean" - "/src/Sampler/RuleBased" - "/src/Shims/OpenTracing" - "/src/Symfony" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index ff913420e..c2aee5e39 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -54,6 +54,7 @@ jobs: 'Propagation/TraceResponse', 'ResourceDetectors/Azure', 'ResourceDetectors/Container', + 'ResourceDetectors/DigitalOcean', 'Sampler/RuleBased', 'Shims/OpenTracing', 'Symfony', diff --git a/.gitsplit.yml b/.gitsplit.yml index a8384be50..6fc01045d 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -80,6 +80,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-detector-azure.git" - prefix: "src/ResourceDetectors/Container" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-detector-container.git" + - prefix: "src/ResourceDetectors/DigitalOcean" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-detector-digitalocean.git" - prefix: "src/Sampler/RuleBased" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sampler-rulebased.git" - prefix: "src/Shims/OpenTracing" diff --git a/composer.json b/composer.json index a661242f0..54413b8b0 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "OpenTelemetry\\Contrib\\Propagation\\ServerTiming\\": "src/Propagation/ServerTiming/src", "OpenTelemetry\\Contrib\\Propagation\\TraceResponse\\": "src/Propagation/TraceResponse/src", "OpenTelemetry\\Contrib\\Resource\\Detector\\Container\\": "src/ResourceDetectors/Container/src", + "OpenTelemetry\\Contrib\\Resource\\Detector\\DigitalOcean\\": "src/ResourceDetectors/DigitalOcean/src", "OpenTelemetry\\Contrib\\Shim\\OpenTracing\\": "src/Shims/OpenTracing/src", "OpenTelemetry\\Contrib\\Symfony\\": "src/Symfony/src" }, @@ -74,7 +75,8 @@ "src/Instrumentation/Symfony/_register.php", "src/Instrumentation/Wordpress/_register.php", "src/Instrumentation/Yii/_register.php", - "src/ResourceDetectors/Container/_register.php" + "src/ResourceDetectors/Container/_register.php", + "src/ResourceDetectors/DigitalOcean/_register.php" ] }, "autoload-dev": { @@ -116,6 +118,7 @@ "open-telemetry/opentelemetry-propagation-traceresponse": "self.version", "open-telemetry/opentelemetry-logger-monolog": "self.version", "open-telemetry/detector-container": "self.version", + "open-telemetry/detector-digitalocean": "self.version", "open-telemetry/symfony-sdk-bundle": "self.version", "open-telemetry/opentracing-shim": "self.version", "open-telemetry/test-utils": "self.version" diff --git a/src/ResourceDetectors/DigitalOcean/.gitattributes b/src/ResourceDetectors/DigitalOcean/.gitattributes new file mode 100644 index 000000000..69b2a0d6b --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/.gitattributes @@ -0,0 +1,14 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.phan export-ignore +/.php-cs-fixer.php export-ignore +/coverage.clover export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore diff --git a/src/ResourceDetectors/DigitalOcean/.gitignore b/src/ResourceDetectors/DigitalOcean/.gitignore new file mode 100644 index 000000000..57872d0f1 --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/ResourceDetectors/DigitalOcean/.phan/config.php b/src/ResourceDetectors/DigitalOcean/.phan/config.php new file mode 100644 index 000000000..6473a9aa8 --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/.phan/config.php @@ -0,0 +1,371 @@ + '8.1', + + // If enabled, missing properties will be created when + // they are first seen. If false, we'll report an + // error message if there is an attempt to write + // to a class property that wasn't explicitly + // defined. + 'allow_missing_properties' => false, + + // If enabled, null can be cast to any type and any + // type can be cast to null. Setting this to true + // will cut down on false positives. + 'null_casts_as_any_type' => false, + + // If enabled, allow null to be cast as any array-like type. + // + // This is an incremental step in migrating away from `null_casts_as_any_type`. + // If `null_casts_as_any_type` is true, this has no effect. + 'null_casts_as_array' => true, + + // If enabled, allow any array-like type to be cast to null. + // This is an incremental step in migrating away from `null_casts_as_any_type`. + // If `null_casts_as_any_type` is true, this has no effect. + 'array_casts_as_null' => true, + + // If enabled, scalars (int, float, bool, string, null) + // are treated as if they can cast to each other. + // This does not affect checks of array keys. See `scalar_array_key_cast`. + 'scalar_implicit_cast' => false, + + // If enabled, any scalar array keys (int, string) + // are treated as if they can cast to each other. + // E.g. `array` can cast to `array` and vice versa. + // Normally, a scalar type such as int could only cast to/from int and mixed. + 'scalar_array_key_cast' => true, + + // If this has entries, scalars (int, float, bool, string, null) + // are allowed to perform the casts listed. + // + // E.g. `['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]` + // allows casting null to a string, but not vice versa. + // (subset of `scalar_implicit_cast`) + 'scalar_implicit_partial' => [], + + // If enabled, Phan will warn if **any** type in a method invocation's object + // is definitely not an object, + // or if **any** type in an invoked expression is not a callable. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_method_checking' => false, + + // If enabled, Phan will warn if **any** type of the object expression for a property access + // does not contain that property. + 'strict_object_checking' => false, + + // If enabled, Phan will warn if **any** type in the argument's union type + // cannot be cast to a type in the parameter's expected union type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_param_checking' => false, + + // If enabled, Phan will warn if **any** type in a property assignment's union type + // cannot be cast to a type in the property's declared union type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_property_checking' => false, + + // If enabled, Phan will warn if **any** type in a returned value's union type + // cannot be cast to the declared return type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_return_checking' => false, + + // If true, seemingly undeclared variables in the global + // scope will be ignored. + // + // This is useful for projects with complicated cross-file + // globals that you have no hope of fixing. + 'ignore_undeclared_variables_in_global_scope' => true, + + // Set this to false to emit `PhanUndeclaredFunction` issues for internal functions that Phan has signatures for, + // but aren't available in the codebase, or from Reflection. + // (may lead to false positives if an extension isn't loaded) + // + // If this is true(default), then Phan will not warn. + // + // Even when this is false, Phan will still infer return values and check parameters of internal functions + // if Phan has the signatures. + 'ignore_undeclared_functions_with_known_signatures' => true, + + // Backwards Compatibility Checking. This is slow + // and expensive, but you should consider running + // it before upgrading your version of PHP to a + // new version that has backward compatibility + // breaks. + // + // If you are migrating from PHP 5 to PHP 7, + // you should also look into using + // [php7cc (no longer maintained)](https://github.com/sstalle/php7cc) + // and [php7mar](https://github.com/Alexia/php7mar), + // which have different backwards compatibility checks. + 'backward_compatibility_checks' => false, + + // If true, check to make sure the return type declared + // in the doc-block (if any) matches the return type + // declared in the method signature. + 'check_docblock_signature_return_type_match' => false, + + // If true, make narrowed types from phpdoc params override + // the real types from the signature, when real types exist. + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // + // Affects analysis of the body of the method and the param types passed in by callers. + // + // (*Requires `check_docblock_signature_param_type_match` to be true*) + 'prefer_narrowed_phpdoc_param_type' => true, + + // (*Requires `check_docblock_signature_return_type_match` to be true*) + // + // If true, make narrowed types from phpdoc returns override + // the real types from the signature, when real types exist. + // + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // + // This setting affects the analysis of return statements in the body of the method and the return types passed in by callers. + 'prefer_narrowed_phpdoc_return_type' => true, + + // If enabled, check all methods that override a + // parent method to make sure its signature is + // compatible with the parent's. + // + // This check can add quite a bit of time to the analysis. + // + // This will also check if final methods are overridden, etc. + 'analyze_signature_compatibility' => true, + + // This setting maps case-insensitive strings to union types. + // + // This is useful if a project uses phpdoc that differs from the phpdoc2 standard. + // + // If the corresponding value is the empty string, + // then Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`) + // + // If the corresponding value is not empty, + // then Phan will act as though it saw the corresponding UnionTypes(s) + // when the keys show up in a UnionType of `@param`, `@return`, `@var`, `@property`, etc. + // + // This matches the **entire string**, not parts of the string. + // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting) + // + // (These are not aliases, this setting is ignored outside of doc comments). + // (Phan does not check if classes with these names exist) + // + // Example setting: `['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']` + 'phpdoc_type_mapping' => [], + + // Set to true in order to attempt to detect dead + // (unreferenced) code. Keep in mind that the + // results will only be a guess given that classes, + // properties, constants and methods can be referenced + // as variables (like `$class->$property` or + // `$class->$method()`) in ways that we're unable + // to make sense of. + 'dead_code_detection' => false, + + // Set to true in order to attempt to detect unused variables. + // `dead_code_detection` will also enable unused variable detection. + // + // This has a few known false positives, e.g. for loops or branches. + 'unused_variable_detection' => false, + + // Set to true in order to attempt to detect redundant and impossible conditions. + // + // This has some false positives involving loops, + // variables set in branches of loops, and global variables. + 'redundant_condition_detection' => false, + + // If enabled, Phan will act as though it's certain of real return types of a subset of internal functions, + // even if those return types aren't available in reflection (real types were taken from php 7.3 or 8.0-dev, depending on target_php_version). + // + // Note that with php 7 and earlier, php would return null or false for many internal functions if the argument types or counts were incorrect. + // As a result, enabling this setting with target_php_version 8.0 may result in false positives for `--redundant-condition-detection` when codebases also support php 7.x. + 'assume_real_types_for_internal_functions' => false, + + // If true, this runs a quick version of checks that takes less + // time at the cost of not running as thorough + // of an analysis. You should consider setting this + // to true only when you wish you had more **undiagnosed** issues + // to fix in your code base. + // + // In quick-mode the scanner doesn't rescan a function + // or a method's code block every time a call is seen. + // This means that the problem here won't be detected: + // + // ```php + // false, + + // Enable or disable support for generic templated + // class types. + 'generic_types_enabled' => true, + + // Override to hardcode existence and types of (non-builtin) globals in the global scope. + // Class names should be prefixed with `\`. + // + // (E.g. `['_FOO' => '\FooClass', 'page' => '\PageClass', 'userId' => 'int']`) + 'globals_type_map' => [], + + // The minimum severity level to report on. This can be + // set to `Issue::SEVERITY_LOW`, `Issue::SEVERITY_NORMAL` or + // `Issue::SEVERITY_CRITICAL`. Setting it to only + // critical issues is a good place to start on a big + // sloppy mature code base. + 'minimum_severity' => Issue::SEVERITY_LOW, + + // Add any issue types (such as `'PhanUndeclaredMethod'`) + // to this deny-list to inhibit them from being reported. + 'suppress_issue_types' => [], + + // A regular expression to match files to be excluded + // from parsing and analysis and will not be read at all. + // + // This is useful for excluding groups of test or example + // directories/files, unanalyzable files, or files that + // can't be removed for whatever reason. + // (e.g. `'@Test\.php$@'`, or `'@vendor/.*/(tests|Tests)/@'`) + 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', + + // A list of files that will be excluded from parsing and analysis + // and will not be read at all. + // + // This is useful for excluding hopelessly unanalyzable + // files that can't be removed for whatever reason. + 'exclude_file_list' => [ + 'vendor/composer/composer/src/Composer/InstalledVersions.php' + ], + + // A directory list that defines files that will be excluded + // from static analysis, but whose class and method + // information should be included. + // + // Generally, you'll want to include the directories for + // third-party code (such as "vendor/") in this list. + // + // n.b.: If you'd like to parse but not analyze 3rd + // party code, directories containing that code + // should be added to the `directory_list` as well as + // to `exclude_analysis_directory_list`. + 'exclude_analysis_directory_list' => [ + 'vendor/', + 'proto/', + 'thrift/' + ], + + // Enable this to enable checks of require/include statements referring to valid paths. + 'enable_include_path_checks' => true, + + // The number of processes to fork off during the analysis + // phase. + 'processes' => 1, + + // List of case-insensitive file extensions supported by Phan. + // (e.g. `['php', 'html', 'htm']`) + 'analyzed_file_extensions' => [ + 'php', + ], + + // You can put paths to stubs of internal extensions in this config option. + // If the corresponding extension is **not** loaded, then Phan will use the stubs instead. + // Phan will continue using its detailed type annotations, + // but load the constants, classes, functions, and classes (and their Reflection types) + // from these stub files (doubling as valid php files). + // Use a different extension from php to avoid accidentally loading these. + // The `tools/make_stubs` script can be used to generate your own stubs (compatible with php 7.0+ right now) + // + // (e.g. `['xdebug' => '.phan/internal_stubs/xdebug.phan_php']`) + 'autoload_internal_extension_signatures' => [], + + // A list of plugin files to execute. + // + // Plugins which are bundled with Phan can be added here by providing their name (e.g. `'AlwaysReturnPlugin'`) + // + // Documentation about available bundled plugins can be found [here](https://github.com/phan/phan/tree/master/.phan/plugins). + // + // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. `'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php'`) + 'plugins' => [ + 'AlwaysReturnPlugin', + 'PregRegexCheckerPlugin', + 'UnreachableCodePlugin', + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in `exclude_analysis_directory_list`, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'src', + 'vendor' + ], + + // A list of individual files to include in analysis + // with a path relative to the root directory of the + // project. + 'file_list' => [], +]; diff --git a/src/ResourceDetectors/DigitalOcean/.php-cs-fixer.php b/src/ResourceDetectors/DigitalOcean/.php-cs-fixer.php new file mode 100644 index 000000000..e35fa078c --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/.php-cs-fixer.php @@ -0,0 +1,43 @@ +exclude('vendor') + ->exclude('var/cache') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'is_null' => true, + 'modernize_types_casting' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'single_line_comment_style' => true, + 'yoda_style' => false, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => true, + 'cast_spaces' => true, + 'declare_strict_types' => true, + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_parentheses' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'echo_tag_syntax' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'short_scalar_cast' => true, + 'blank_lines_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); + diff --git a/src/ResourceDetectors/DigitalOcean/README.md b/src/ResourceDetectors/DigitalOcean/README.md new file mode 100644 index 000000000..6628a3ea2 --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/README.md @@ -0,0 +1,53 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-detector-digitalocean/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://github.com/open-telemetry/opentelemetry-php/issues) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/ResourceDetectors/DigitalOcean) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-detector-digitalocean) +[![Latest Version](http://poser.pugx.org/open-telemetry/detector-digitalocean/v/unstable)](https://packagist.org/packages/open-telemetry/detector-digitalocean/) +[![Stable](http://poser.pugx.org/open-telemetry/detector-digitalocean/v/stable)](https://packagist.org/packages/open-telemetry/detector-digitalocean/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry DigitalOcean resource detector + +Please see https://opentelemetry.io/docs/languages/php/resources/#custom-resource-detectors for installation and configuration. + +## Overview + +This package provides an OpenTelemetry `ResourceDetector` which will detect +resource attributes for DigitalOcean Droplets. + +The following OpenTelemetry resource attributes will be detected: + +| Resource attribute | Droplet | +|---------------------------|-----------------------------------| +| `cloud.account.id` | auth[^1] (scope `account:read`) | +| `cloud.availability_zone` | _not applicable to DigitalOcean_ | +| `cloud.platform` | auto | +| `cloud.provider` | auto | +| `cloud.region` | auto | +| `cloud.resource.id` | auto | +| `host.arch` | static (`amd64`) | +| `host.id` | auto | +| `host.image.id` | auth[^1] (scope `droplet:read`) | +| `host.image.name` | auth[^1] (scope `droplet:read`) | +| `host.image.version` | _not applicable to DigitalOcean_ | +| `host.ip` | omitted[^2] | +| `host.mac` | omitted[^2] | +| `host.name` | auto | +| `host.type` | auth[^1] (scope `droplet:read`) | +| `os.name`[^3] | auth[^1] (scope `droplet:read`) | +| `os.type`[^3] | static (`linux`) | + +[^1]: If a DigitalOcean API personal access token, with the listed scope, is available to PHP via the `DIGITALOCEAN_ACCESS_TOKEN` environment variable, this resource detector will attempt to read the corresponding values from the API. This has no impact on the other attributes. +[^2]: These attributes are marked as `Opt-In` within the OpenTelemetry semantic conventions, meaning they should _not_ be included unless the user configures the instrumentation to do so. It is a future todo for this library to support configuration. +[^3]: These attributes should be combined with a resource detector that includes all of the `os` resource attributes, but if these attributes are known, they will be provided. + +## Configuration + +By default, all installed resource detectors are used, and the attributes they detect will be added to the default resources associated with traces, metrics, and logs. + +You can also provide a list of specific detectors via the `OTEL_PHP_DETECTORS` config (environment variable or `php.ini` setting): + +```shell +OTEL_PHP_DETECTORS="host,process,digitalocean" +``` diff --git a/src/ResourceDetectors/DigitalOcean/_register.php b/src/ResourceDetectors/DigitalOcean/_register.php new file mode 100644 index 000000000..a2a4a160d --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/_register.php @@ -0,0 +1,9 @@ + + + + + + + + + + + + + + + + + + + + src + + + + + + tests/Unit + + + tests/Integration + + + + diff --git a/src/ResourceDetectors/DigitalOcean/psalm.xml.dist b/src/ResourceDetectors/DigitalOcean/psalm.xml.dist new file mode 100644 index 000000000..155711712 --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/ResourceDetectors/DigitalOcean/src/DigitalOceanDetector.php b/src/ResourceDetectors/DigitalOcean/src/DigitalOceanDetector.php new file mode 100644 index 000000000..d2cc12d8a --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/src/DigitalOceanDetector.php @@ -0,0 +1,148 @@ +resolveRequestFactory(); + $token = $_SERVER[self::ENV_DO_API_TOKEN] ?? ''; + + /** Bail early if wrong environment */ + if (!$this->isDigitalOcean()) { + self::logNotice('DigitalOcean resource detector enabled in non-DigitalOcean environment'); + + return ResourceInfoFactory::emptyResource(); + } + + try { + /** Attributes available locally - all non-privileged lookups */ + $attributes = [ + ResourceAttributes::CLOUD_PLATFORM => $this->readSMBIOS('product_family'), + ResourceAttributes::CLOUD_PROVIDER => $this->readSMBIOS('sys_vendor'), + ResourceAttributes::HOST_ARCH => ResourceAttributeValues::HOST_ARCH_AMD64, + ResourceAttributes::HOST_ID => $this->readSMBIOS('board_asset_tag'), + ResourceAttributes::HOST_NAME => gethostname(), + ResourceAttributes::OS_TYPE => ResourceAttributeValues::OS_TYPE_LINUX, + ]; + + /** Attributes available without authentication via the link-local IP API */ + $metadataRequest = $requestFactory->createRequest('GET', self::DO_METADATA_ENDPOINT_URL); + $metadataResponse = $client->sendRequest($metadataRequest); + if ($metadataResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException('Failed to read the DigitalOcean metadata API.'); + } + $metadata = json_decode($metadataResponse->getBody()->getContents(), flags: JSON_THROW_ON_ERROR); + + $attributes[ResourceAttributes::CLOUD_REGION] = $metadata->region; + $attributes[ResourceAttributes::CLOUD_RESOURCE_ID] = (string) $metadata->droplet_id; + } catch (Throwable $t) { + self::logWarning('Failed to detect DigitalOcean resource attributes', ['exception' => $t]); + + return ResourceInfoFactory::emptyResource(); + } + + /** Attributes available by authenticating to the public V2 API (This will be rare.) */ + if ($token !== '') { + // Two different API scopes; a token may have access to neither, one, or both + try { + $accountRequest = $requestFactory + ->createRequest( + HTTP::METHOD_GET, + sprintf('%saccount', self::DO_PUBLIC_ENDPOINT_URL) + ) + ->withHeader('Authorization', sprintf('Bearer %s', $token)); + $accountResponse = $client->sendRequest($accountRequest); + if ($accountResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException('Failed to read the account endpoint on the DigitalOcean API.'); + } + $account = json_decode($accountResponse->getBody()->getContents(), flags: JSON_THROW_ON_ERROR)->account; + + $attributes[ResourceAttributes::CLOUD_ACCOUNT_ID] = $account->team->uuid; + self::logInfo('DigitalOcean Access Token found; setting DigitalOcean account ID in resource attributes'); + } catch (Throwable) { + // The token being available and scoped is the abnormal state, so logging the reverse of this catch + } + + try { + $dropletRequest = $requestFactory + ->createRequest( + HTTP::METHOD_GET, + sprintf('%sdroplets/%s', self::DO_PUBLIC_ENDPOINT_URL, (string) $metadata->droplet_id) + ) + ->withHeader('Authorization', sprintf('Bearer %s', $token)); + $dropletResponse = $client->sendRequest($dropletRequest); + if ($dropletResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException('Failed to read the droplet endpoint on the DigitalOcean API.'); + } + $droplet = json_decode($dropletResponse->getBody()->getContents(), flags: JSON_THROW_ON_ERROR)->droplet; + + $attributes[ResourceAttributes::HOST_IMAGE_ID] = (string) $droplet->image->id; + $attributes[ResourceAttributes::HOST_IMAGE_NAME] = $droplet->image->name; + $attributes[ResourceAttributes::HOST_TYPE] = $droplet->size_slug; + $attributes[ResourceAttributes::OS_NAME] = $droplet->image->distribution; + self::logInfo('DigitalOcean Access Token found; setting additional Droplet info in resource attributes'); + } catch (Throwable) { + // The token being available and scoped is the abnormal state, so logging the reverse of this catch + } + } + + return ResourceInfo::create(Attributes::create($attributes), ResourceAttributes::SCHEMA_URL); + } + + private function isDigitalOcean(): bool + { + try { + $sysVendor = $this->readSMBIOS('sys_vendor'); + } catch (UnexpectedValueException) { + return false; + } + if (PHP_OS_FAMILY !== 'Linux' || $sysVendor !== 'digitalocean') { + return false; + } + + return true; + } + + private function readSMBIOS(string $dmiKeyword): string + { + $dmiValue = file_get_contents(sprintf('%ssys/devices/virtual/dmi/id/%s', $this->rootPath, $dmiKeyword)); + if ($dmiValue === false) { + throw new UnexpectedValueException('Failed to read SMBIOS value from sysfs.'); + } + + return strtolower(trim($dmiValue)); + } +} diff --git a/src/ResourceDetectors/DigitalOcean/tests/Integration/.gitkeep b/src/ResourceDetectors/DigitalOcean/tests/Integration/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/ResourceDetectors/DigitalOcean/tests/Unit/DigitalOceanDetectorTest.php b/src/ResourceDetectors/DigitalOcean/tests/Unit/DigitalOceanDetectorTest.php new file mode 100644 index 000000000..7eb1784fe --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/tests/Unit/DigitalOceanDetectorTest.php @@ -0,0 +1,219 @@ +vfs = vfsStream::setup('/', structure: [ + 'sys' => [ + 'devices' => [ + 'virtual' => [ + 'dmi' => [ + 'id' => [ + 'board_asset_tag' => '10000000', + 'product_family' => 'DigitalOcean_Droplet', + 'sys_vendor' => 'DigitalOcean', + ], + ], + ], + ], + ], + ]); + + /** mock stdout for `error_log` */ + $this->errorLog = tmpfile(); + /** @psalm-suppress PossiblyFalseArgument */ + ini_set('error_log', stream_get_meta_data($this->errorLog)['uri']); + + /** mock HTTP client and DigitalOcean API responses */ + $responseFactory = MessageFactoryResolver::create()->resolveResponseFactory(); + $client = $this->createStub(Psr18Client::class); + $client->method('sendRequest')->willReturnCallback(function (RequestInterface $request) use ($responseFactory) { + if ((string) $request->getUri() === 'http://169.254.169.254/metadata/v1.json') { + /** @psalm-suppress PossiblyFalseArgument */ + return $responseFactory->createResponse()->withBody( + Stream::create(file_get_contents(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'mock-metadata.json')) + ); + } elseif ( + $request->getHeader('Authorization') === ['Bearer scoped-for-account'] && + (string) $request->getUri() === 'https://api.digitalocean.com/v2/account' + ) { + /** @psalm-suppress PossiblyFalseArgument */ + return $responseFactory->createResponse()->withBody( + Stream::create(file_get_contents(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'mock-account.json')) + ); + } elseif ( + $request->getHeader('Authorization') === ['Bearer scoped-for-droplet'] && + (string) $request->getUri() === 'https://api.digitalocean.com/v2/droplets/10000000' + ) { + /** @psalm-suppress PossiblyFalseArgument */ + return $responseFactory->createResponse()->withBody( + Stream::create(file_get_contents(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'mock-droplet.json')) + ); + } + + return $responseFactory->createResponse(404); + }); + $clientDiscoverer = $this->createStub(DiscoveryInterface::class); + $clientDiscoverer->method('available')->willReturn(true); + $clientDiscoverer->method('create')->willReturn($client); + Discovery::setDiscoverers([$clientDiscoverer]); + } + + public function test_droplet_attributes_with_no_authz() + { + $this->assertEquals( + Attributes::create([ + ResourceAttributes::CLOUD_PLATFORM => 'digitalocean_droplet', + ResourceAttributes::CLOUD_PROVIDER => 'digitalocean', + ResourceAttributes::CLOUD_REGION => 'ams1', + ResourceAttributes::CLOUD_RESOURCE_ID => '10000000', + ResourceAttributes::HOST_ARCH => ResourceAttributeValues::HOST_ARCH_AMD64, + ResourceAttributes::HOST_ID => '10000000', + ResourceAttributes::HOST_NAME => 'test-server', + ResourceAttributes::OS_TYPE => ResourceAttributeValues::OS_TYPE_LINUX, + ]), + (new DigitalOceanDetector($this->vfs->url()))->getResource()->getAttributes() + ); + } + + #[Server('DIGITALOCEAN_ACCESS_TOKEN', 'scoped-for-account')] + public function test_droplet_attributes_with_account_only_authz() + { + $this->assertEquals( + Attributes::create([ + ResourceAttributes::CLOUD_ACCOUNT_ID => 'BCF58B93-BF65-4203-9B63-A5F6FD1AF06D', + ResourceAttributes::CLOUD_PLATFORM => 'digitalocean_droplet', + ResourceAttributes::CLOUD_PROVIDER => 'digitalocean', + ResourceAttributes::CLOUD_REGION => 'ams1', + ResourceAttributes::CLOUD_RESOURCE_ID => '10000000', + ResourceAttributes::HOST_ARCH => ResourceAttributeValues::HOST_ARCH_AMD64, + ResourceAttributes::HOST_ID => '10000000', + ResourceAttributes::HOST_NAME => 'test-server', + ResourceAttributes::OS_TYPE => ResourceAttributeValues::OS_TYPE_LINUX, + ]), + (new DigitalOceanDetector($this->vfs->url()))->getResource()->getAttributes() + ); + /** @psalm-suppress PossiblyFalseArgument */ + $this->assertStringContainsString( + 'DigitalOcean Access Token found; setting DigitalOcean account ID in resource attributes', + stream_get_contents($this->errorLog) + ); + } + + #[Server('DIGITALOCEAN_ACCESS_TOKEN', 'scoped-for-droplet')] + public function test_droplet_attributes_with_droplet_only_authz() + { + $this->assertEquals( + Attributes::create([ + ResourceAttributes::CLOUD_PLATFORM => 'digitalocean_droplet', + ResourceAttributes::CLOUD_PROVIDER => 'digitalocean', + ResourceAttributes::CLOUD_REGION => 'ams1', + ResourceAttributes::CLOUD_RESOURCE_ID => '10000000', + ResourceAttributes::HOST_ARCH => ResourceAttributeValues::HOST_ARCH_AMD64, + ResourceAttributes::HOST_ID => '10000000', + ResourceAttributes::HOST_NAME => 'test-server', + ResourceAttributes::OS_TYPE => ResourceAttributeValues::OS_TYPE_LINUX, + ResourceAttributes::HOST_IMAGE_ID => '9999999', + ResourceAttributes::HOST_IMAGE_NAME => 'Debian 11 (bullseye)', + ResourceAttributes::HOST_TYPE => 's-1vcpu-1gb', + ResourceAttributes::OS_NAME => 'Debian', + ]), + (new DigitalOceanDetector($this->vfs->url()))->getResource()->getAttributes() + ); + /** @psalm-suppress PossiblyFalseArgument */ + $this->assertStringContainsString( + 'DigitalOcean Access Token found; setting additional Droplet info in resource attributes', + stream_get_contents($this->errorLog) + ); + } + + public function test_no_droplet_attributes() + { + /** mock HTTP client and with failing response */ + $client = $this->createStub(Psr18Client::class); + $client->method('sendRequest')->willReturnCallback(function () { + $responseFactory = MessageFactoryResolver::create()->resolveResponseFactory(); + + return $responseFactory->createResponse(500); + }); + $clientDiscoverer = $this->createStub(DiscoveryInterface::class); + $clientDiscoverer->method('available')->willReturn(true); + $clientDiscoverer->method('create')->willReturn($client); + Discovery::setDiscoverers([$clientDiscoverer]); + + $this->assertEquals( + ResourceInfoFactory::emptyResource(), + (new DigitalOceanDetector($this->vfs->url()))->getResource() + ); + /** @psalm-suppress PossiblyFalseArgument */ + $this->assertStringContainsString( + 'Failed to detect DigitalOcean resource attributes', + stream_get_contents($this->errorLog) + ); + } + + public function test_not_digitalocean_environment() + { + /** mock sysfs with AWS SMBIOS pointers */ + $vfs = vfsStream::setup('/', structure: ['sys' => ['devices' => ['virtual' => ['dmi' => ['id' => [ + 'sys_vendor' => 'AWS', + ]]]]]]); + + $this->assertEquals( + ResourceInfoFactory::emptyResource(), + (new DigitalOceanDetector($vfs->url()))->getResource() + ); + /** @psalm-suppress PossiblyFalseArgument */ + $this->assertStringContainsString( + 'DigitalOcean resource detector enabled in non-DigitalOcean environment', + stream_get_contents($this->errorLog) + ); + } + + public function test_not_linux() + { + /** mock sysfs with no SMBIOS pointers */ + $vfs = vfsStream::setup('/'); + + $this->assertEquals( + ResourceInfoFactory::emptyResource(), + (new DigitalOceanDetector($vfs->url()))->getResource() + ); + /** @psalm-suppress PossiblyFalseArgument */ + $this->assertStringContainsString( + 'DigitalOcean resource detector enabled in non-DigitalOcean environment', + stream_get_contents($this->errorLog) + ); + } +} diff --git a/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-account.json b/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-account.json new file mode 100644 index 000000000..f78cc5a6c --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-account.json @@ -0,0 +1,7 @@ +{ + "account": { + "team": { + "uuid": "BCF58B93-BF65-4203-9B63-A5F6FD1AF06D" + } + } +} \ No newline at end of file diff --git a/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-droplet.json b/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-droplet.json new file mode 100644 index 000000000..13bc33fd8 --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-droplet.json @@ -0,0 +1,16 @@ +{ + "droplet": { + "id": 10000000, + "name": "test-server", + "image": { + "id": 9999999, + "name": "Debian 11 (bullseye)", + "distribution": "Debian" + }, + "size_slug": "s-1vcpu-1gb", + "region": { + "name": "Amsterdam 1", + "slug": "ams1" + } + } +} \ No newline at end of file diff --git a/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-metadata.json b/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-metadata.json new file mode 100644 index 000000000..5bb7dfe8d --- /dev/null +++ b/src/ResourceDetectors/DigitalOcean/tests/Unit/mock-metadata.json @@ -0,0 +1,5 @@ +{ + "droplet_id": 10000000, + "hostname": "test-server", + "region": "ams1" +} \ No newline at end of file