diff --git a/package-lock.json b/package-lock.json index 0dce368804..94c41b3560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37040,9 +37040,10 @@ "@types/mocha": "10.0.10", "@types/node": "18.18.14", "@types/semver": "7.5.8", - "nock": "13.3.3", + "@types/sinon": "17.0.4", + "bignumber.js": "9.3.1", "nyc": "17.1.0", - "rimraf": "5.0.10", + "sinon": "15.2.0", "typescript": "5.0.4" }, "engines": { @@ -47712,10 +47713,11 @@ "@types/mocha": "10.0.10", "@types/node": "18.18.14", "@types/semver": "7.5.8", + "@types/sinon": "17.0.4", + "bignumber.js": "9.3.1", "gcp-metadata": "^6.0.0", - "nock": "13.3.3", "nyc": "17.1.0", - "rimraf": "5.0.10", + "sinon": "15.2.0", "typescript": "5.0.4" }, "dependencies": { diff --git a/packages/resource-detector-gcp/package.json b/packages/resource-detector-gcp/package.json index 6961b2de36..c042701da5 100644 --- a/packages/resource-detector-gcp/package.json +++ b/packages/resource-detector-gcp/package.json @@ -52,9 +52,10 @@ "@types/mocha": "10.0.10", "@types/node": "18.18.14", "@types/semver": "7.5.8", - "nock": "13.3.3", + "@types/sinon": "17.0.4", + "bignumber.js": "9.3.1", "nyc": "17.1.0", - "rimraf": "5.0.10", + "sinon": "15.2.0", "typescript": "5.0.4" }, "peerDependencies": { diff --git a/packages/resource-detector-gcp/src/detectors/GcpDetector.ts b/packages/resource-detector-gcp/src/detectors/GcpDetector.ts index 3a5aa06021..7935c1f843 100644 --- a/packages/resource-detector-gcp/src/detectors/GcpDetector.ts +++ b/packages/resource-detector-gcp/src/detectors/GcpDetector.ts @@ -1,4 +1,5 @@ /* + * Copyright 2022 Google LLC * Copyright The OpenTelemetry Authors * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,147 +15,216 @@ * limitations under the License. */ -import * as gcpMetadata from 'gcp-metadata'; import { context } from '@opentelemetry/api'; import { suppressTracing } from '@opentelemetry/core'; import { - ResourceDetectionConfig, - ResourceDetector, - DetectedResource, - DetectedResourceAttributes, -} from '@opentelemetry/resources'; -import { + CLOUDPLATFORMVALUES_GCP_APP_ENGINE, + CLOUDPLATFORMVALUES_GCP_CLOUD_FUNCTIONS, + CLOUDPLATFORMVALUES_GCP_CLOUD_RUN, + CLOUDPLATFORMVALUES_GCP_COMPUTE_ENGINE, + CLOUDPLATFORMVALUES_GCP_KUBERNETES_ENGINE, CLOUDPROVIDERVALUES_GCP, SEMRESATTRS_CLOUD_ACCOUNT_ID, SEMRESATTRS_CLOUD_AVAILABILITY_ZONE, + SEMRESATTRS_CLOUD_PLATFORM, SEMRESATTRS_CLOUD_PROVIDER, - SEMRESATTRS_CONTAINER_NAME, + SEMRESATTRS_CLOUD_REGION, + SEMRESATTRS_FAAS_INSTANCE, + SEMRESATTRS_FAAS_NAME, + SEMRESATTRS_FAAS_VERSION, SEMRESATTRS_HOST_ID, SEMRESATTRS_HOST_NAME, + SEMRESATTRS_HOST_TYPE, SEMRESATTRS_K8S_CLUSTER_NAME, - SEMRESATTRS_K8S_NAMESPACE_NAME, - SEMRESATTRS_K8S_POD_NAME, } from '@opentelemetry/semantic-conventions'; -/** - * The GcpDetector can be used to detect if a process is running in the Google - * Cloud Platform and return a {@link Resource} populated with metadata about - * the instance. Returns an empty Resource if detection fails. - */ -class GcpDetector implements ResourceDetector { - detect(_config?: ResourceDetectionConfig): DetectedResource { - const attributes = context.with(suppressTracing(context.active()), () => - this._getAttributes() - ); - return { attributes }; - } +import { AttributeValue, Attributes } from '@opentelemetry/api'; +import { + DetectedResource, + DetectedResourceAttributes, + emptyResource, + Resource, + ResourceDetector, + resourceFromAttributes, +} from '@opentelemetry/resources'; +import * as metadata from 'gcp-metadata'; +import * as faas from './faas'; +import * as gae from './gae'; +import * as gce from './gce'; +import * as gke from './gke'; - /** - * Asynchronously gather GCP cloud metadata. - */ - private _getAttributes(): DetectedResourceAttributes { - const isAvail = gcpMetadata.isAvailable(); - - const attributes: DetectedResourceAttributes = { - [SEMRESATTRS_CLOUD_PROVIDER]: (async () => { - return (await isAvail) ? CLOUDPROVIDERVALUES_GCP : undefined; - })(), - [SEMRESATTRS_CLOUD_ACCOUNT_ID]: this._getProjectId(isAvail), - [SEMRESATTRS_HOST_ID]: this._getInstanceId(isAvail), - [SEMRESATTRS_HOST_NAME]: this._getHostname(isAvail), - [SEMRESATTRS_CLOUD_AVAILABILITY_ZONE]: this._getZone(isAvail), - }; - - // Add resource attributes for K8s. - if (process.env.KUBERNETES_SERVICE_HOST) { - attributes[SEMRESATTRS_K8S_CLUSTER_NAME] = this._getClusterName(isAvail); - attributes[SEMRESATTRS_K8S_NAMESPACE_NAME] = (async () => { - return (await isAvail) ? process.env.NAMESPACE : undefined; - })(); - attributes[SEMRESATTRS_K8S_POD_NAME] = (async () => { - return (await isAvail) ? process.env.HOSTNAME : undefined; - })(); - attributes[SEMRESATTRS_CONTAINER_NAME] = (async () => { - return (await isAvail) ? process.env.CONTAINER_NAME : undefined; - })(); - } - - return attributes; - } +const ATTRIBUTE_NAMES = [ + SEMRESATTRS_CLOUD_PLATFORM, + SEMRESATTRS_CLOUD_AVAILABILITY_ZONE, + SEMRESATTRS_CLOUD_REGION, + SEMRESATTRS_K8S_CLUSTER_NAME, + SEMRESATTRS_HOST_TYPE, + SEMRESATTRS_HOST_ID, + SEMRESATTRS_HOST_NAME, + SEMRESATTRS_CLOUD_PROVIDER, + SEMRESATTRS_CLOUD_ACCOUNT_ID, + SEMRESATTRS_FAAS_NAME, + SEMRESATTRS_FAAS_VERSION, + SEMRESATTRS_FAAS_INSTANCE, +] as const; + +// Ensure that all resource keys are accounted for in ATTRIBUTE_NAMES +type GcpResourceAttributeName = (typeof ATTRIBUTE_NAMES)[number]; +type GcpResourceAttributes = Partial< + Record +>; - /** Gets project id from GCP project metadata. */ - private async _getProjectId( - isAvail: Promise - ): Promise { - if (!(await isAvail)) { - return undefined; - } - try { - return await gcpMetadata.project('project-id'); - } catch { - return ''; - } +async function detect(): Promise { + if (!(await metadata.isAvailable())) { + return emptyResource(); } - /** Gets instance id from GCP instance metadata. */ - private async _getInstanceId( - isAvail: Promise - ): Promise { - if (!(await isAvail)) { - return undefined; - } - try { - const id = await gcpMetadata.instance('id'); - return id.toString(); - } catch { - return ''; - } + // Note the order of these if checks is significant with more specific resources coming + // first. E.g. Cloud Functions gen2 are executed in Cloud Run so it must be checked first. + if (await gke.onGke()) { + return await gkeResource(); + } else if (await faas.onCloudFunctions()) { + return await cloudFunctionsResource(); + } else if (await faas.onCloudRun()) { + return await cloudRunResource(); + } else if (await gae.onAppEngine()) { + return await gaeResource(); + } else if (await gce.onGce()) { + return await gceResource(); } - /** Gets zone from GCP instance metadata. */ - private async _getZone( - isAvail: Promise - ): Promise { - if (!(await isAvail)) { - return undefined; - } - try { - const zoneId = await gcpMetadata.instance('zone'); - if (zoneId) { - return zoneId.split('/').pop(); - } - return ''; - } catch { - return ''; - } + return emptyResource(); +} + +async function gkeResource(): Promise { + const [zoneOrRegion, k8sClusterName, hostId] = await Promise.all([ + gke.availabilityZoneOrRegion(), + gke.clusterName(), + gke.hostId(), + ]); + + return await makeResource({ + [SEMRESATTRS_CLOUD_PLATFORM]: CLOUDPLATFORMVALUES_GCP_KUBERNETES_ENGINE, + [zoneOrRegion.type === 'zone' + ? SEMRESATTRS_CLOUD_AVAILABILITY_ZONE + : SEMRESATTRS_CLOUD_REGION]: zoneOrRegion.value, + [SEMRESATTRS_K8S_CLUSTER_NAME]: k8sClusterName, + [SEMRESATTRS_HOST_ID]: hostId, + }); +} + +async function cloudRunResource(): Promise { + const [faasName, faasVersion, faasInstance, faasCloudRegion] = + await Promise.all([ + faas.faasName(), + faas.faasVersion(), + faas.faasInstance(), + faas.faasCloudRegion(), + ]); + + return await makeResource({ + [SEMRESATTRS_CLOUD_PLATFORM]: CLOUDPLATFORMVALUES_GCP_CLOUD_RUN, + [SEMRESATTRS_FAAS_NAME]: faasName, + [SEMRESATTRS_FAAS_VERSION]: faasVersion, + [SEMRESATTRS_FAAS_INSTANCE]: faasInstance, + [SEMRESATTRS_CLOUD_REGION]: faasCloudRegion, + }); +} + +async function cloudFunctionsResource(): Promise { + const [faasName, faasVersion, faasInstance, faasCloudRegion] = + await Promise.all([ + faas.faasName(), + faas.faasVersion(), + faas.faasInstance(), + faas.faasCloudRegion(), + ]); + + return await makeResource({ + [SEMRESATTRS_CLOUD_PLATFORM]: CLOUDPLATFORMVALUES_GCP_CLOUD_FUNCTIONS, + [SEMRESATTRS_FAAS_NAME]: faasName, + [SEMRESATTRS_FAAS_VERSION]: faasVersion, + [SEMRESATTRS_FAAS_INSTANCE]: faasInstance, + [SEMRESATTRS_CLOUD_REGION]: faasCloudRegion, + }); +} + +async function gaeResource(): Promise { + let zone, region; + if (await gae.onAppEngineStandard()) { + [zone, region] = await Promise.all([ + gae.standardAvailabilityZone(), + gae.standardCloudRegion(), + ]); + } else { + ({ zone, region } = await gce.availabilityZoneAndRegion()); } + const [faasName, faasVersion, faasInstance] = await Promise.all([ + gae.serviceName(), + gae.serviceVersion(), + gae.serviceInstance(), + ]); + + return await makeResource({ + [SEMRESATTRS_CLOUD_PLATFORM]: CLOUDPLATFORMVALUES_GCP_APP_ENGINE, + [SEMRESATTRS_FAAS_NAME]: faasName, + [SEMRESATTRS_FAAS_VERSION]: faasVersion, + [SEMRESATTRS_FAAS_INSTANCE]: faasInstance, + [SEMRESATTRS_CLOUD_AVAILABILITY_ZONE]: zone, + [SEMRESATTRS_CLOUD_REGION]: region, + }); +} - /** Gets cluster name from GCP instance metadata. */ - private async _getClusterName( - isAvail: Promise - ): Promise { - if (!(await isAvail)) { - return undefined; - } - try { - return await gcpMetadata.instance('attributes/cluster-name'); - } catch { - return ''; - } +async function gceResource(): Promise { + const [zoneAndRegion, hostType, hostId, hostName] = await Promise.all([ + gce.availabilityZoneAndRegion(), + gce.hostType(), + gce.hostId(), + gce.hostName(), + ]); + + return await makeResource({ + [SEMRESATTRS_CLOUD_PLATFORM]: CLOUDPLATFORMVALUES_GCP_COMPUTE_ENGINE, + [SEMRESATTRS_CLOUD_AVAILABILITY_ZONE]: zoneAndRegion.zone, + [SEMRESATTRS_CLOUD_REGION]: zoneAndRegion.region, + [SEMRESATTRS_HOST_TYPE]: hostType, + [SEMRESATTRS_HOST_ID]: hostId, + [SEMRESATTRS_HOST_NAME]: hostName, + }); +} + +async function makeResource(attrs: GcpResourceAttributes): Promise { + const project = await metadata.project('project-id'); + + return resourceFromAttributes({ + [SEMRESATTRS_CLOUD_PROVIDER]: CLOUDPROVIDERVALUES_GCP, + [SEMRESATTRS_CLOUD_ACCOUNT_ID]: project, + ...attrs, + } satisfies GcpResourceAttributes); +} + +/** + * Google Cloud resource detector which populates attributes based on the environment this + * process is running in. If not on GCP, returns an empty resource. + */ +export class GcpDetector implements ResourceDetector { + private async _asyncAttributes(): Promise { + const resource = await context.with( + suppressTracing(context.active()), + detect + ); + return resource.attributes; } - /** Gets hostname from GCP instance metadata. */ - private async _getHostname( - isAvail: Promise - ): Promise { - if (!(await isAvail)) { - return undefined; - } - try { - return await gcpMetadata.instance('hostname'); - } catch { - return ''; - } + detect(): DetectedResource { + const asyncAttributes = this._asyncAttributes(); + const attributes = {} as DetectedResourceAttributes; + ATTRIBUTE_NAMES.forEach(name => { + // Each resource attribute is determined asynchronously in _gatherData(). + attributes[name] = asyncAttributes.then(data => data[name]); + }); + + return { attributes }; } } diff --git a/packages/resource-detector-gcp/src/detectors/faas.ts b/packages/resource-detector-gcp/src/detectors/faas.ts new file mode 100644 index 0000000000..30e1bb1e55 --- /dev/null +++ b/packages/resource-detector-gcp/src/detectors/faas.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2023 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Implementation in this file copied from + * https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/blob/v1.8.0/detectors/gcp/faas.go + */ + +import * as metadata from 'gcp-metadata'; + +const ID_METADATA_ATTR = 'id'; +const CLOUD_RUN_CONFIG_ENV = 'K_CONFIGURATION'; +const CLOUD_FUNCTION_TARGET_ENV = 'FUNCTION_TARGET'; +const FAAS_SERVICE_ENV = 'K_SERVICE'; +const FAAS_REVISION_ENV = 'K_REVISION'; +const REGION_METADATA_ATTR = 'region'; + +export async function onCloudRun(): Promise { + return process.env[CLOUD_RUN_CONFIG_ENV] !== undefined; +} + +export async function onCloudFunctions(): Promise { + return process.env[CLOUD_FUNCTION_TARGET_ENV] !== undefined; +} + +/** + * The name of the Cloud Run or Cloud Function. Check that {@link onCloudRun()} or {@link + * onCloudFunctions()} is true before calling this, or it may throw exceptions. + */ +export async function faasName(): Promise { + return lookupEnv(FAAS_SERVICE_ENV); +} + +/** + * The version/revision of the Cloud Run or Cloud Function. Check that {@link onCloudRun()} or + * {@link onCloudFunctions()} is true before calling this, or it may throw exceptions. + */ +export async function faasVersion(): Promise { + return lookupEnv(FAAS_REVISION_ENV); +} + +/** + * The ID for the running instance of a Cloud Run or Cloud Function. Check that {@link + * onCloudRun()} or {@link onCloudFunctions()} is true before calling this, or it may throw + * exceptions. + */ +export async function faasInstance(): Promise { + // May be a bignumber.js BigNumber which can just be converted with toString(). See + // https://github.com/googleapis/gcp-metadata#take-care-with-large-number-valued-properties + const id = await metadata.instance(ID_METADATA_ATTR); + return id.toString(); +} + +/** + * The cloud region where the running instance of a Cloud Run or Cloud Function is located. + * Check that {@link onCloudRun()} or {@link onCloudFunctions()} is true before calling this, + * or it may throw exceptions. + */ +export async function faasCloudRegion(): Promise { + const region = await metadata.instance(REGION_METADATA_ATTR); + return region.slice(region.lastIndexOf('/') + 1); +} + +function lookupEnv(key: string): string { + const val = process.env[key]; + if (val === undefined) { + throw new Error(`Environment variable ${key} not found`); + } + return val; +} diff --git a/packages/resource-detector-gcp/src/detectors/gae.ts b/packages/resource-detector-gcp/src/detectors/gae.ts new file mode 100644 index 0000000000..5ad5f617db --- /dev/null +++ b/packages/resource-detector-gcp/src/detectors/gae.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2023 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Implementation in this file copied from + * https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/blob/v1.8.0/detectors/gcp/app_engine.go + */ + +import * as metadata from 'gcp-metadata'; +import * as gce from './gce'; +import * as faas from './faas'; + +const GAE_SERVICE_ENV = 'GAE_SERVICE'; +const GAE_VERSION_ENV = 'GAE_VERSION'; +const GAE_INSTANCE_ENV = 'GAE_INSTANCE'; +const GAE_ENV = 'GAE_ENV'; +const GAE_STANDARD = 'standard'; +const ZONE_METADATA_ATTR = 'zone'; + +export async function onAppEngineStandard(): Promise { + return process.env[GAE_ENV] === GAE_STANDARD; +} + +export async function onAppEngine(): Promise { + return process.env[GAE_SERVICE_ENV] !== undefined; +} + +/** + * The service name of the app engine service. Check that {@link onAppEngine()} is true before + * calling this, or it may throw exceptions. + */ +export async function serviceName(): Promise { + return lookupEnv(GAE_SERVICE_ENV); +} + +/** + * The service version of the app engine service. Check that {@link onAppEngine()} is true + * before calling this, or it may throw exceptions. + */ +export async function serviceVersion(): Promise { + return lookupEnv(GAE_VERSION_ENV); +} + +/** + * The service instance of the app engine service. Check that {@link onAppEngine()} is true + * before calling this, or it may throw exceptions. + */ +export async function serviceInstance(): Promise { + return lookupEnv(GAE_INSTANCE_ENV); +} + +/** + * The zone and region in which this program is running. Check that {@link onAppEngine()} is + * true before calling this, or it may throw exceptions. + */ +export async function flexAvailabilityZoneAndRegion(): Promise<{ + zone: string; + region: string; +}> { + return await gce.availabilityZoneAndRegion(); +} + +/** + * The zone the app engine service is running in. Check that {@link onAppEngineStandard()} is + * true before calling this, or it may throw exceptions. + */ +export async function standardAvailabilityZone(): Promise { + const zone = await metadata.instance(ZONE_METADATA_ATTR); + // zone is of the form "projects/233510669999/zones/us15" + return zone.slice(zone.lastIndexOf('/') + 1); +} + +/** + * The region the app engine service is running in. Check that {@link onAppEngineStandard()} is + * true before calling this, or it may throw exceptions. + */ +export async function standardCloudRegion(): Promise { + return await faas.faasCloudRegion(); +} + +function lookupEnv(key: string): string { + const val = process.env[key]; + if (val === undefined) { + throw new Error(`Environment variable ${key} not found`); + } + return val; +} diff --git a/packages/resource-detector-gcp/src/detectors/gce.ts b/packages/resource-detector-gcp/src/detectors/gce.ts new file mode 100644 index 0000000000..a6473bdafd --- /dev/null +++ b/packages/resource-detector-gcp/src/detectors/gce.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Implementation in this file copied from + * https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/blob/v1.8.0/detectors/gcp/gce.go + */ + +import { diag } from '@opentelemetry/api'; +import * as metadata from 'gcp-metadata'; + +const MACHINE_TYPE_METADATA_ATTR = 'machine-type'; +const ID_METADATA_ATTR = 'id'; +const HOST_NAME_METADATA_ATTR = 'name'; +const ZONE_METADATA_ATTR = 'zone'; + +export async function onGce(): Promise { + try { + await metadata.instance(MACHINE_TYPE_METADATA_ATTR); + return true; + } catch (err) { + diag.debug( + 'Could not fetch metadata attribute %s, assuming not on GCE. Error was %s', + MACHINE_TYPE_METADATA_ATTR, + err + ); + return false; + } +} + +/** + * The machine type of the instance on which this program is running. Check that {@link + * onGce()} is true before calling this, or it may throw exceptions. + */ +export async function hostType(): Promise { + return metadata.instance(MACHINE_TYPE_METADATA_ATTR); +} + +/** + * The instance ID of the instance on which this program is running. Check that {@link onGce()} + * is true before calling this, or it may throw exceptions. + */ +export async function hostId(): Promise { + // May be a bignumber.js BigNumber which can just be converted with toString(). See + // https://github.com/googleapis/gcp-metadata#take-care-with-large-number-valued-properties + const id = await metadata.instance(ID_METADATA_ATTR); + return id.toString(); +} + +/** + * The instance ID of the instance on which this program is running. Check that {@link onGce()} + * is true before calling this, or it may throw exceptions. + */ +export async function hostName(): Promise { + return metadata.instance(HOST_NAME_METADATA_ATTR); +} + +/** + * The zone and region in which this program is running. Check that {@link onGce()} is true + * before calling this, or it may throw exceptions. + */ +export async function availabilityZoneAndRegion(): Promise<{ + zone: string; + region: string; +}> { + const fullZone = await metadata.instance(ZONE_METADATA_ATTR); + + // Format described in + // https://cloud.google.com/compute/docs/metadata/default-metadata-values#vm_instance_metadata + const re = /projects\/\d+\/zones\/(?(?\w+-\w+)-\w+)/; + const { zone, region } = fullZone.match(re)?.groups ?? {}; + if (!zone || !region) { + throw new Error( + `zone was not in the expected format: projects/PROJECT_NUM/zones/COUNTRY-REGION-ZONE. Got ${fullZone}` + ); + } + + return { zone, region }; +} diff --git a/packages/resource-detector-gcp/src/detectors/gke.ts b/packages/resource-detector-gcp/src/detectors/gke.ts new file mode 100644 index 0000000000..0f48f1d138 --- /dev/null +++ b/packages/resource-detector-gcp/src/detectors/gke.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2022 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Implementation in this file copied from + * https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/blob/v1.8.0/detectors/gcp/gke.go + */ + +import * as metadata from 'gcp-metadata'; +import * as gce from './gce'; + +const KUBERNETES_SERVICE_HOST_ENV = 'KUBERNETES_SERVICE_HOST'; +const CLUSTER_NAME_METADATA_ATTR = 'attributes/cluster-name'; +const CLUSTER_LOCATION_METADATA_ATTR = 'attributes/cluster-location'; + +export async function onGke(): Promise { + return process.env[KUBERNETES_SERVICE_HOST_ENV] !== undefined; +} + +/** + * The instance ID of the instance on which this program is running. Check that {@link onGke()} + * is true before calling this, or it may throw exceptions. + */ +export async function hostId(): Promise { + return await gce.hostId(); +} + +/** + * The name of the GKE cluster in which this program is running. Check that {@link onGke()} is + * true before calling this, or it may throw exceptions. + */ +export async function clusterName(): Promise { + return metadata.instance(CLUSTER_NAME_METADATA_ATTR); +} + +/** + * The location of the cluster and whether the cluster is zonal or regional. Check that {@link + * onGke()} is true before calling this, or it may throw exceptions. + */ +export async function availabilityZoneOrRegion(): Promise<{ + type: 'zone' | 'region'; + value: string; +}> { + const clusterLocation = await metadata.instance( + CLUSTER_LOCATION_METADATA_ATTR + ); + switch (countChar(clusterLocation, '-')) { + case 1: + return { type: 'region', value: clusterLocation }; + case 2: + return { type: 'zone', value: clusterLocation }; + default: + throw new Error( + `unrecognized format for cluster location: ${clusterLocation}` + ); + } +} + +function countChar(s: string, char: string): number { + let count = 0; + for (let i = 0; i < s.length; i++) { + if (s[i] === char) { + count += 1; + } + } + return count; +} diff --git a/packages/resource-detector-gcp/test/detectors/GcpDetector.test.ts b/packages/resource-detector-gcp/test/detectors/GcpDetector.test.ts index 49d2ad8c0f..c59f367e6b 100644 --- a/packages/resource-detector-gcp/test/detectors/GcpDetector.test.ts +++ b/packages/resource-detector-gcp/test/detectors/GcpDetector.test.ts @@ -1,4 +1,5 @@ /* + * Copyright 2022 Google LLC * Copyright The OpenTelemetry Authors * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,212 +15,205 @@ * limitations under the License. */ -import { - BASE_PATH, - HEADER_NAME, - HEADER_VALUE, - HOST_ADDRESS, - SECONDARY_HOST_ADDRESS, - resetIsAvailableCache, -} from 'gcp-metadata'; -import * as nock from 'nock'; -import { gcpDetector } from '../../src'; -import { - assertCloudResource, - assertHostResource, - assertK8sResource, - assertContainerResource, - assertEmptyResource, -} from '@opentelemetry/contrib-test-utils'; -import { detectResources } from '@opentelemetry/resources'; - -const HEADERS = { - [HEADER_NAME.toLowerCase()]: HEADER_VALUE, -}; -const INSTANCE_PATH = BASE_PATH + '/instance'; -const INSTANCE_ID_PATH = BASE_PATH + '/instance/id'; -const PROJECT_ID_PATH = BASE_PATH + '/project/project-id'; -const ZONE_PATH = BASE_PATH + '/instance/zone'; -const CLUSTER_NAME_PATH = BASE_PATH + '/instance/attributes/cluster-name'; -const HOSTNAME_PATH = BASE_PATH + '/instance/hostname'; +import * as sinon from 'sinon'; +import * as metadata from 'gcp-metadata'; + +import { gcpDetector } from '../../src/'; +import { detectResources, Resource } from '@opentelemetry/resources'; +import * as assert from 'assert'; + +async function detectAndWait(): Promise { + const resource = detectResources({ detectors: [gcpDetector] }); + await resource.waitForAsyncAttributes?.(); + return resource; +} describe('gcpDetector', () => { - describe('.detect', () => { - before(() => { - nock.disableNetConnect(); - }); + let metadataStub: sinon.SinonStubbedInstance; + let envStub: NodeJS.ProcessEnv; + beforeEach(() => { + metadataStub = sinon.stub(metadata); + metadataStub.isAvailable.resolves(true); + metadataStub.project.withArgs('project-id').resolves('fake-project-id'); + + envStub = sinon.replace(process, 'env', {}); + }); - after(() => { - nock.enableNetConnect(); - delete process.env.KUBERNETES_SERVICE_HOST; - delete process.env.NAMESPACE; - delete process.env.CONTAINER_NAME; - delete process.env.HOSTNAME; - }); + afterEach(() => { + sinon.restore(); + }); + it('returns empty resource when metadata server is not available', async () => { + metadataStub.isAvailable.resolves(false); + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, {}); + }); + + describe('detects a GKE resource', () => { beforeEach(() => { - resetIsAvailableCache(); - nock.cleanAll(); - delete process.env.KUBERNETES_SERVICE_HOST; - delete process.env.NAMESPACE; - delete process.env.CONTAINER_NAME; - delete process.env.HOSTNAME; + envStub.KUBERNETES_SERVICE_HOST = 'fake-service-host'; + metadataStub.instance + .withArgs('id') + .resolves(12345) + + .withArgs('attributes/cluster-name') + .resolves('fake-cluster-name'); }); - it('should return resource with GCP metadata', async () => { - const scope = nock(HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS) - .get(INSTANCE_ID_PATH) - // This number is too large to be safely represented by a JS number - // See https://github.com/googleapis/gcp-metadata/tree/fc2f0778138b36285643b2f716c485bf9614611f#take-care-with-large-number-valued-properties - .reply(200, () => '4520031799277581759', HEADERS) - .get(PROJECT_ID_PATH) - .reply(200, () => 'my-project-id', HEADERS) - .get(ZONE_PATH) - .reply(200, () => 'project/zone/my-zone', HEADERS) - .get(HOSTNAME_PATH) - .reply(200, () => 'dev.my-project.local', HEADERS); - const secondaryScope = nock(SECONDARY_HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS); - - const resource = detectResources({ detectors: [gcpDetector] }); - await resource.waitForAsyncAttributes?.(); - - secondaryScope.done(); - scope.done(); - - assertCloudResource(resource, { - provider: 'gcp', - accountId: 'my-project-id', - zone: 'my-zone', - }); - assertHostResource(resource, { - id: '4520031799277581759', - name: 'dev.my-project.local', + it('zonal', async () => { + metadataStub.instance + .withArgs('attributes/cluster-location') + .resolves('us-east4-b'); + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.availability_zone': 'us-east4-b', + 'cloud.platform': 'gcp_kubernetes_engine', + 'cloud.provider': 'gcp', + 'host.id': '12345', + 'k8s.cluster.name': 'fake-cluster-name', }); }); - it('should populate K8s attributes when KUBERNETES_SERVICE_HOST is set', async () => { - process.env.KUBERNETES_SERVICE_HOST = 'my-host'; - process.env.NAMESPACE = 'my-namespace'; - process.env.HOSTNAME = 'my-hostname'; - process.env.CONTAINER_NAME = 'my-container-name'; - const scope = nock(HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS) - .get(INSTANCE_ID_PATH) - .reply(200, () => '4520031799277581759', HEADERS) - .get(CLUSTER_NAME_PATH) - .reply(200, () => 'my-cluster', HEADERS) - .get(PROJECT_ID_PATH) - .reply(200, () => 'my-project-id', HEADERS) - .get(ZONE_PATH) - .reply(200, () => 'project/zone/my-zone', HEADERS) - .get(HOSTNAME_PATH) - .reply(200, () => 'dev.my-project.local', HEADERS); - const secondaryScope = nock(SECONDARY_HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS); - - const resource = detectResources({ detectors: [gcpDetector] }); - await resource.waitForAsyncAttributes?.(); - - secondaryScope.done(); - scope.done(); - - assertCloudResource(resource, { - provider: 'gcp', - accountId: 'my-project-id', - zone: 'my-zone', + it('regional', async () => { + metadataStub.instance + .withArgs('attributes/cluster-location') + .resolves('us-east4'); + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.platform': 'gcp_kubernetes_engine', + 'cloud.provider': 'gcp', + 'cloud.region': 'us-east4', + 'host.id': '12345', + 'k8s.cluster.name': 'fake-cluster-name', }); - assertK8sResource(resource, { - clusterName: 'my-cluster', - podName: 'my-hostname', - namespaceName: 'my-namespace', - }); - assertContainerResource(resource, { name: 'my-container-name' }); }); + }); - it('should return resource and empty data for non-available metadata attributes', async () => { - // Set KUBERNETES_SERVICE_HOST to have the implementation call - // CLUSTER_NAME_PATH, to be able to test it handling the HTTP 413. - process.env.KUBERNETES_SERVICE_HOST = 'my-host'; - const scope = nock(HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS) - .get(PROJECT_ID_PATH) - .reply(200, () => 'my-project-id', HEADERS) - .get(ZONE_PATH) - .reply(413) - .get(INSTANCE_ID_PATH) - .reply(400, undefined, HEADERS) - .get(CLUSTER_NAME_PATH) - .reply(413) - .get(HOSTNAME_PATH) - .reply(400, undefined, HEADERS); - const secondaryScope = nock(SECONDARY_HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS); - - const resource = detectResources({ detectors: [gcpDetector] }); - await resource.waitForAsyncAttributes?.(); - - secondaryScope.done(); - scope.done(); - - assertCloudResource(resource, { - provider: 'gcp', - accountId: 'my-project-id', - zone: '', - }); + it('detects a GCE resource', async () => { + metadataStub.instance + .withArgs('id') + .resolves(12345) + + .withArgs('machine-type') + .resolves('fake-machine-type') + + .withArgs('name') + .resolves('fake-name') + + .withArgs('zone') + .resolves('projects/233510669999/zones/us-east4-b'); + + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.availability_zone': 'us-east4-b', + 'cloud.platform': 'gcp_compute_engine', + 'cloud.provider': 'gcp', + 'cloud.region': 'us-east4', + 'host.id': '12345', + 'host.name': 'fake-name', + 'host.type': 'fake-machine-type', }); + }); - it('should return resource and undefined for non-available kubernetes attributes', async () => { - process.env.KUBERNETES_SERVICE_HOST = 'my-host'; - process.env.HOSTNAME = 'my-hostname'; - process.env.CONTAINER_NAME = 'my-container-name'; - const scope = nock(HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS) - .get(INSTANCE_ID_PATH) - .reply(200, () => '4520031799277581759', HEADERS) - .get(CLUSTER_NAME_PATH) - .reply(200, () => 'my-cluster', HEADERS) - .get(PROJECT_ID_PATH) - .reply(200, () => 'my-project-id', HEADERS) - .get(ZONE_PATH) - .reply(200, () => 'project/zone/my-zone', HEADERS) - .get(HOSTNAME_PATH) - .reply(200, () => 'dev.my-project.local', HEADERS); - const secondaryScope = nock(SECONDARY_HOST_ADDRESS) - .get(INSTANCE_PATH) - .reply(200, {}, HEADERS); - - const resource = detectResources({ detectors: [gcpDetector] }); - await resource.waitForAsyncAttributes?.(); - - secondaryScope.done(); - scope.done(); - - assertCloudResource(resource, { - provider: 'gcp', - accountId: 'my-project-id', - zone: 'my-zone', - }); - assertK8sResource(resource, { - clusterName: 'my-cluster', - podName: 'my-hostname', - namespaceName: undefined, - }); - assertContainerResource(resource, { name: 'my-container-name' }); + it('detects a Cloud Run resource', async () => { + envStub.K_CONFIGURATION = 'fake-configuration'; + envStub.K_SERVICE = 'fake-service'; + envStub.K_REVISION = 'fake-revision'; + metadataStub.instance + .withArgs('id') + .resolves(12345) + + .withArgs('region') + .resolves('projects/233510669999/regions/us-east4'); + + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.platform': 'gcp_cloud_run', + 'cloud.provider': 'gcp', + 'cloud.region': 'us-east4', + 'faas.instance': '12345', + 'faas.name': 'fake-service', + 'faas.version': 'fake-revision', }); + }); + + it('detects a Cloud Functions resource', async () => { + envStub.FUNCTION_TARGET = 'fake-function-target'; + envStub.K_SERVICE = 'fake-service'; + envStub.K_REVISION = 'fake-revision'; + metadataStub.instance + .withArgs('id') + .resolves(12345) + + .withArgs('region') + .resolves('projects/233510669999/regions/us-east4'); + + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.platform': 'gcp_cloud_functions', + 'cloud.provider': 'gcp', + 'cloud.region': 'us-east4', + 'faas.instance': '12345', + 'faas.name': 'fake-service', + 'faas.version': 'fake-revision', + }); + }); - it('returns empty resource if not detected', async () => { - const resource = detectResources({ detectors: [gcpDetector] }); - await resource.waitForAsyncAttributes?.(); - assertEmptyResource(resource); + it('detects a App Engine Standard resource', async () => { + envStub.GAE_ENV = 'standard'; + envStub.GAE_SERVICE = 'fake-service'; + envStub.GAE_VERSION = 'fake-version'; + envStub.GAE_INSTANCE = 'fake-instance'; + metadataStub.instance.withArgs('zone').resolves('us-east4-b'); + metadataStub.instance + .withArgs('region') + .resolves('projects/233510669999/regions/us-east4'); + + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.availability_zone': 'us-east4-b', + 'cloud.platform': 'gcp_app_engine', + 'cloud.provider': 'gcp', + 'cloud.region': 'us-east4', + 'faas.instance': 'fake-instance', + 'faas.name': 'fake-service', + 'faas.version': 'fake-version', }); }); + + it('detects a App Engine Flex resource', async () => { + envStub.GAE_SERVICE = 'fake-service'; + envStub.GAE_VERSION = 'fake-version'; + envStub.GAE_INSTANCE = 'fake-instance'; + metadataStub.instance + .withArgs('zone') + .resolves('projects/233510669999/zones/us-east4-b'); + + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, { + 'cloud.account.id': 'fake-project-id', + 'cloud.availability_zone': 'us-east4-b', + 'cloud.platform': 'gcp_app_engine', + 'cloud.provider': 'gcp', + 'cloud.region': 'us-east4', + 'faas.instance': 'fake-instance', + 'faas.name': 'fake-service', + 'faas.version': 'fake-version', + }); + }); + + it('detects empty resource when nothing else can be detected', async () => { + // gcp-metadata throws when it can't access the metadata server + metadataStub.instance.rejects(); + metadataStub.project.rejects(); + + const resource = await detectAndWait(); + assert.deepStrictEqual(resource.attributes, {}); + }); }); diff --git a/packages/resource-detector-gcp/test/detectors/faas.test.ts b/packages/resource-detector-gcp/test/detectors/faas.test.ts new file mode 100644 index 0000000000..4629172544 --- /dev/null +++ b/packages/resource-detector-gcp/test/detectors/faas.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import * as metadata from 'gcp-metadata'; + +import * as faas from '../../src/detectors/faas'; +import * as assert from 'assert'; +import { BigNumber } from 'bignumber.js'; + +describe('FaaS (Cloud Run/Functions)', () => { + let metadataStub: sinon.SinonStubbedInstance; + let envStub: NodeJS.ProcessEnv; + beforeEach(() => { + metadataStub = sinon.stub(metadata); + envStub = sinon.replace(process, 'env', {}); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('detects when running on Cloud Run', async () => { + envStub.K_CONFIGURATION = 'fake-configuration'; + const onCloudRun = await faas.onCloudRun(); + assert(onCloudRun); + }); + it('detects when not running on Cloud Run', async () => { + const onCloudRun = await faas.onCloudRun(); + assert(!onCloudRun); + }); + + it('detects when running on Cloud Functions', async () => { + envStub.FUNCTION_TARGET = 'fake-function-target'; + const onCloudFunctions = await faas.onCloudFunctions(); + assert(onCloudFunctions); + }); + it('detects when not running on Cloud Functions', async () => { + const onCloudFunctions = await faas.onCloudFunctions(); + assert(!onCloudFunctions); + }); + + it('detects FaaS name', async () => { + envStub.K_SERVICE = 'fake-service'; + const faasName = await faas.faasName(); + assert.strictEqual(faasName, 'fake-service'); + }); + + it('detects FaaS version', async () => { + envStub.K_REVISION = 'fake-revision'; + const faasVersion = await faas.faasVersion(); + assert.strictEqual(faasVersion, 'fake-revision'); + }); + + describe('detects FaaS id', () => { + it('as a number', async () => { + metadataStub.instance.withArgs('id').resolves(12345); + + const faasInstance = await faas.faasInstance(); + assert.strictEqual(faasInstance, '12345'); + }); + + it('as a BigNumber', async () => { + metadataStub.instance + .withArgs('id') + .resolves(new BigNumber('2459451723172637654')); + + const faasInstance = await faas.faasInstance(); + assert.strictEqual(faasInstance, '2459451723172637654'); + }); + }); + + it('detects FaaS region', async () => { + metadataStub.instance + .withArgs('region') + .resolves('projects/233510669999/regions/us-east4'); + + const faasRegion = await faas.faasCloudRegion(); + assert.deepStrictEqual(faasRegion, 'us-east4'); + }); +}); diff --git a/packages/resource-detector-gcp/test/detectors/gae.test.ts b/packages/resource-detector-gcp/test/detectors/gae.test.ts new file mode 100644 index 0000000000..15d493d635 --- /dev/null +++ b/packages/resource-detector-gcp/test/detectors/gae.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2023 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import * as metadata from 'gcp-metadata'; + +import * as gae from '../../src/detectors/gae'; +import * as assert from 'assert'; + +describe('App Engine (GAE)', () => { + let metadataStub: sinon.SinonStubbedInstance; + let envStub: NodeJS.ProcessEnv; + beforeEach(() => { + metadataStub = sinon.stub(metadata); + envStub = sinon.replace(process, 'env', {}); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('detects when running on GAE', async () => { + envStub.GAE_SERVICE = 'fake-service'; + const onGae = await gae.onAppEngine(); + assert(onGae); + }); + it('detects when not running on GAE', async () => { + const onGae = await gae.onAppEngine(); + assert(!onGae); + }); + it('detects when running on GAE standard', async () => { + envStub.GAE_ENV = 'standard'; + const onGaeStandard = await gae.onAppEngineStandard(); + assert(onGaeStandard); + }); + + it('detects GAE service name', async () => { + envStub.GAE_SERVICE = 'fake-service'; + const serviceName = await gae.serviceName(); + assert.strictEqual(serviceName, 'fake-service'); + }); + + it('detects GAE service version', async () => { + envStub.GAE_VERSION = 'fake-version'; + const version = await gae.serviceVersion(); + assert.strictEqual(version, 'fake-version'); + }); + + it('detects GAE service instance', async () => { + envStub.GAE_INSTANCE = 'fake-instance'; + const instance = await gae.serviceInstance(); + assert.strictEqual(instance, 'fake-instance'); + }); + + describe('GAE flex zone and region', () => { + it('detects when correctly formatted', async () => { + metadataStub.instance + .withArgs('zone') + .resolves('projects/233510669999/zones/us-east4-b'); + + const zoneAndRegion = await gae.flexAvailabilityZoneAndRegion(); + assert.deepStrictEqual(zoneAndRegion, { + zone: 'us-east4-b', + region: 'us-east4', + }); + }); + + it('throws when incorrectly formatted', async () => { + metadataStub.instance.withArgs('zone').resolves(''); + + await assert.rejects( + gae.flexAvailabilityZoneAndRegion(), + /zone was not in the expected format/ + ); + }); + }); + + describe('GAE standard zone and region', () => { + it('detects zone', async () => { + metadataStub.instance + .withArgs('zone') + .resolves('projects/233510669999/zones/us15'); + + const zone = await gae.standardAvailabilityZone(); + assert.strictEqual(zone, 'us15'); + }); + + it('detects region', async () => { + metadataStub.instance + .withArgs('region') + .resolves('projects/233510669999/regions/us-east4'); + + const region = await gae.standardCloudRegion(); + assert.deepStrictEqual(region, 'us-east4'); + }); + }); +}); diff --git a/packages/resource-detector-gcp/test/detectors/gce.test.ts b/packages/resource-detector-gcp/test/detectors/gce.test.ts new file mode 100644 index 0000000000..c4651137d8 --- /dev/null +++ b/packages/resource-detector-gcp/test/detectors/gce.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2022 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import * as metadata from 'gcp-metadata'; + +import * as gce from '../../src/detectors/gce'; +import * as assert from 'assert'; +import { BigNumber } from 'bignumber.js'; + +describe('GCE', () => { + let metadataStub: sinon.SinonStubbedInstance; + beforeEach(() => { + metadataStub = sinon.stub(metadata); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('detects when running on GCE', async () => { + metadataStub.instance + .withArgs('machine-type') + .resolves('fake-machine-type'); + + const onGce = await gce.onGce(); + assert(onGce); + }); + + it('detects when not running on GCE', async () => { + metadataStub.instance + .withArgs('machine-type') + .rejects('attribute not found!'); + + const onGce = await gce.onGce(); + assert(!onGce); + }); + + it('detects host type', async () => { + metadataStub.instance + .withArgs('machine-type') + .resolves('fake-machine-type'); + + const hostType = await gce.hostType(); + assert.strictEqual(hostType, 'fake-machine-type'); + }); + + describe('detects host id', () => { + it('as a number', async () => { + metadataStub.instance.withArgs('id').resolves(12345); + + const hostId = await gce.hostId(); + assert.strictEqual(hostId, '12345'); + }); + + it('as a BigNumber', async () => { + metadataStub.instance + .withArgs('id') + .resolves(new BigNumber('2459451723172637654')); + + const hostId = await gce.hostId(); + assert.strictEqual(hostId, '2459451723172637654'); + }); + }); + + it('detects host name', async () => { + metadataStub.instance.withArgs('name').resolves('fake-name'); + + const hostName = await gce.hostName(); + assert.strictEqual(hostName, 'fake-name'); + }); + + describe('zone and region', () => { + it('detects when correctly formatted', async () => { + metadataStub.instance + .withArgs('zone') + .resolves('projects/233510669999/zones/us-east4-b'); + + const zoneAndRegion = await gce.availabilityZoneAndRegion(); + assert.deepStrictEqual(zoneAndRegion, { + zone: 'us-east4-b', + region: 'us-east4', + }); + }); + + it('throws when incorrectly formatted', async () => { + metadataStub.instance.withArgs('zone').resolves(''); + + await assert.rejects( + gce.availabilityZoneAndRegion(), + /zone was not in the expected format/ + ); + }); + }); +}); diff --git a/packages/resource-detector-gcp/test/detectors/gke.test.ts b/packages/resource-detector-gcp/test/detectors/gke.test.ts new file mode 100644 index 0000000000..620b38072b --- /dev/null +++ b/packages/resource-detector-gcp/test/detectors/gke.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2022 Google LLC + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import * as metadata from 'gcp-metadata'; + +import * as gke from '../../src/detectors/gke'; +import * as assert from 'assert'; + +describe('GKE', () => { + let metadataStub: sinon.SinonStubbedInstance; + let envStub: NodeJS.ProcessEnv; + beforeEach(() => { + metadataStub = sinon.stub(metadata); + envStub = sinon.replace(process, 'env', {}); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('detects when running on GKE', async () => { + envStub.KUBERNETES_SERVICE_HOST = 'fake-service-host'; + const onGke = await gke.onGke(); + assert(onGke); + }); + + it('detects when not running on GKE', async () => { + const onGke = await gke.onGke(); + assert(!onGke); + }); + + it('detects host id', async () => { + metadataStub.instance.withArgs('id').resolves(12345); + + const hostId = await gke.hostId(); + assert.strictEqual(hostId, '12345'); + }); + + it('detects cluster name', async () => { + metadataStub.instance + .withArgs('attributes/cluster-name') + .resolves('fake-cluster-name'); + + const clusterName = await gke.clusterName(); + assert.strictEqual(clusterName, 'fake-cluster-name'); + }); + + describe('zone or region', () => { + it('detects region', async () => { + metadataStub.instance + .withArgs('attributes/cluster-location') + .resolves('us-east4'); + + const zoneOrRegion = await gke.availabilityZoneOrRegion(); + assert.deepStrictEqual(zoneOrRegion, { + type: 'region', + value: 'us-east4', + }); + }); + + it('detects zone', async () => { + metadataStub.instance + .withArgs('attributes/cluster-location') + .resolves('us-east4-b'); + + const zoneOrRegion = await gke.availabilityZoneOrRegion(); + assert.deepStrictEqual(zoneOrRegion, { + type: 'zone', + value: 'us-east4-b', + }); + }); + + it('throws when incorrectly formatted', async () => { + metadataStub.instance + .withArgs('attributes/cluster-location') + .resolves(''); + + await assert.rejects( + gke.availabilityZoneOrRegion(), + /unrecognized format for cluster location/ + ); + }); + }); +});