diff --git a/.chloggen/feat_39117.yaml b/.chloggen/feat_39117.yaml new file mode 100644 index 0000000000000..1fdb2d505a6c7 --- /dev/null +++ b/.chloggen/feat_39117.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: resourcedetection + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Add Openstack Nova resource detector to gather Openstack instance metadata as resource attributes" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [39117] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: The Openstack Nova resource detector has been added to gather metadata such as host name, ID, cloud provider, region, and availability zone as resource attributes, enhancing the observability of Openstack environments. + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: ["user"] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 73fe10835b464..0a3caa97cf680 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -210,6 +210,7 @@ processor/resourcedetectionprocessor/internal/akamai/ @open-telemetry processor/resourcedetectionprocessor/internal/digitalocean/ @open-telemetry/collector-contrib-approvers @dashpole @paulojmdias processor/resourcedetectionprocessor/internal/dynatrace/ @open-telemetry/collector-contrib-approvers @bacherfl @evan-bradley processor/resourcedetectionprocessor/internal/hetzner/ @open-telemetry/collector-contrib-approvers @Aneurysm9 @dashpole @paulojmdias +processor/resourcedetectionprocessor/internal/openstack/nova/ @open-telemetry/collector-contrib-approvers @dashpole @paulojmdias processor/resourcedetectionprocessor/internal/oraclecloud/ @open-telemetry/collector-contrib-approvers @dashpole processor/resourcedetectionprocessor/internal/scaleway/ @open-telemetry/collector-contrib-approvers @Aneurysm9 @dashpole @paulojmdias processor/resourcedetectionprocessor/internal/upcloud/ @open-telemetry/collector-contrib-approvers @dashpole @paulojmdias diff --git a/.github/ISSUE_TEMPLATE/beta_stability.yaml b/.github/ISSUE_TEMPLATE/beta_stability.yaml index 2f5b018b8109c..f0dbc6a5e85ef 100644 --- a/.github/ISSUE_TEMPLATE/beta_stability.yaml +++ b/.github/ISSUE_TEMPLATE/beta_stability.yaml @@ -211,6 +211,7 @@ body: - processor/resourcedetection/internal/digitalocean - processor/resourcedetection/internal/dynatrace - processor/resourcedetection/internal/hetzner + - processor/resourcedetection/internal/openstack/nova - processor/resourcedetection/internal/oraclecloud - processor/resourcedetection/internal/scaleway - processor/resourcedetection/internal/upcloud diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index c945d4df22816..305ee938ff2e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -214,6 +214,7 @@ body: - processor/resourcedetection/internal/digitalocean - processor/resourcedetection/internal/dynatrace - processor/resourcedetection/internal/hetzner + - processor/resourcedetection/internal/openstack/nova - processor/resourcedetection/internal/oraclecloud - processor/resourcedetection/internal/scaleway - processor/resourcedetection/internal/upcloud diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 66aaa430083a9..39a9f01fa2524 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -208,6 +208,7 @@ body: - processor/resourcedetection/internal/digitalocean - processor/resourcedetection/internal/dynatrace - processor/resourcedetection/internal/hetzner + - processor/resourcedetection/internal/openstack/nova - processor/resourcedetection/internal/oraclecloud - processor/resourcedetection/internal/scaleway - processor/resourcedetection/internal/upcloud diff --git a/.github/ISSUE_TEMPLATE/other.yaml b/.github/ISSUE_TEMPLATE/other.yaml index c1e0155086cae..6fd7f8d58a3c9 100644 --- a/.github/ISSUE_TEMPLATE/other.yaml +++ b/.github/ISSUE_TEMPLATE/other.yaml @@ -208,6 +208,7 @@ body: - processor/resourcedetection/internal/digitalocean - processor/resourcedetection/internal/dynatrace - processor/resourcedetection/internal/hetzner + - processor/resourcedetection/internal/openstack/nova - processor/resourcedetection/internal/oraclecloud - processor/resourcedetection/internal/scaleway - processor/resourcedetection/internal/upcloud diff --git a/.github/ISSUE_TEMPLATE/unmaintained.yaml b/.github/ISSUE_TEMPLATE/unmaintained.yaml index b5dbedcbf0a05..dfd85763d17f5 100644 --- a/.github/ISSUE_TEMPLATE/unmaintained.yaml +++ b/.github/ISSUE_TEMPLATE/unmaintained.yaml @@ -213,6 +213,7 @@ body: - processor/resourcedetection/internal/digitalocean - processor/resourcedetection/internal/dynatrace - processor/resourcedetection/internal/hetzner + - processor/resourcedetection/internal/openstack/nova - processor/resourcedetection/internal/oraclecloud - processor/resourcedetection/internal/scaleway - processor/resourcedetection/internal/upcloud diff --git a/.github/component_labels.txt b/.github/component_labels.txt index 9a7060039e3e0..b695d4b89b8d4 100644 --- a/.github/component_labels.txt +++ b/.github/component_labels.txt @@ -191,6 +191,7 @@ processor/resourcedetectionprocessor/internal/akamai processor/resourcedetection processor/resourcedetectionprocessor/internal/digitalocean processor/resourcedetection/internal/digitalocean processor/resourcedetectionprocessor/internal/dynatrace processor/resourcedetection/internal/dynatrace processor/resourcedetectionprocessor/internal/hetzner processor/resourcedetection/internal/hetzner +processor/resourcedetectionprocessor/internal/openstack/nova processor/resourcedetection/internal/openstack/nova processor/resourcedetectionprocessor/internal/oraclecloud processor/resourcedetection/internal/oraclecloud processor/resourcedetectionprocessor/internal/scaleway processor/resourcedetection/internal/scaleway processor/resourcedetectionprocessor/internal/upcloud processor/resourcedetection/internal/upcloud diff --git a/internal/metadataproviders/openstack/nova/metadata.go b/internal/metadataproviders/openstack/nova/metadata.go new file mode 100644 index 0000000000000..4e9a32c7fc018 --- /dev/null +++ b/internal/metadataproviders/openstack/nova/metadata.go @@ -0,0 +1,127 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package nova // import "github.com/open-telemetry/opentelemetry-collector-contrib/internal/metadataproviders/openstack/nova" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const ( + openstackMetaURL = "http://169.254.169.254/openstack/latest/meta_data.json" + ec2MetaBaseURL = "http://169.254.169.254/latest/meta-data/" +) + +type Provider interface { + Get(ctx context.Context) (Document, error) + Hostname(ctx context.Context) (string, error) + InstanceID(ctx context.Context) (string, error) + InstanceType(ctx context.Context) (string, error) +} + +type metadataClient struct { + client *http.Client +} + +var _ Provider = (*metadataClient)(nil) + +// Document is a minimal representation of OpenStack's meta_data.json +type Document struct { + AvailabilityZone string `json:"availability_zone"` + Hostname string `json:"hostname"` + Name string `json:"name"` + Meta map[string]string `json:"meta"` + ProjectID string `json:"project_id"` + UUID string `json:"uuid"` +} + +// NewProvider returns a new Nova metadata provider with a short timeout. +func NewProvider() Provider { + return &metadataClient{ + client: &http.Client{ + Timeout: 2 * time.Second, + }, + } +} + +func (c *metadataClient) getMetadata(ctx context.Context) (Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, openstackMetaURL, http.NoBody) + if err != nil { + return Document{}, err + } + + resp, err := c.client.Do(req) + if err != nil { + return Document{}, fmt.Errorf("failed to query nova metadata service: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return Document{}, fmt.Errorf("metadata service returned %d: %s", resp.StatusCode, string(body)) + } + + var doc Document + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return Document{}, fmt.Errorf("failed to decode nova metadata: %w", err) + } + + return doc, nil +} + +func (c *metadataClient) getEc2Metadata(ctx context.Context, fullURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, http.NoBody) + if err != nil { + return "", err + } + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("metadata GET %s: %w", fullURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("metadata %s returned %d: %s", fullURL, resp.StatusCode, string(b)) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *metadataClient) InstanceID(ctx context.Context) (string, error) { + doc, err := c.getMetadata(ctx) + if err != nil { + return "", err + } + if doc.UUID == "" { + return "", errors.New("instance ID (uuid) not found in metadata") + } + return doc.UUID, nil +} + +func (c *metadataClient) Hostname(ctx context.Context) (string, error) { + doc, err := c.getMetadata(ctx) + if err != nil { + return "", err + } + if doc.Hostname == "" { + return "", errors.New("hostname not found in metadata") + } + return doc.Hostname, nil +} + +func (c *metadataClient) InstanceType(ctx context.Context) (string, error) { + return c.getEc2Metadata(ctx, ec2MetaBaseURL+"instance-type") +} + +func (c *metadataClient) Get(ctx context.Context) (Document, error) { + return c.getMetadata(ctx) +} diff --git a/internal/metadataproviders/openstack/nova/metadata_test.go b/internal/metadataproviders/openstack/nova/metadata_test.go new file mode 100644 index 0000000000000..fbd089327570c --- /dev/null +++ b/internal/metadataproviders/openstack/nova/metadata_test.go @@ -0,0 +1,229 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package nova + +import ( + "bytes" + "errors" + "io" + "net/http" + "reflect" + "testing" + "time" +) + +// mockHTTPDoer allows us to fake HTTP responses from the Nova metadata service. +type mockHTTPDoer func(*http.Request) (*http.Response, error) + +func (m mockHTTPDoer) Do(req *http.Request) (*http.Response, error) { + return m(req) +} + +func TestGetNovaMetadataDocument(t *testing.T) { + dummyBody := `{ + "uuid": "12345678-abcd-ef00-1234-56789abcdef0", + "meta": { + "key1": "value1", + "key2": "value2" + }, + "hostname": "test-vm-01", + "name": "test-vm-01", + "availability_zone": "zone-a", + "project_id": "proj-123456", + "launch_index": 0 + }` + + tests := []struct { + name string + doer mockHTTPDoer + want Document + expectErr bool + }{ + { + name: "successfully retrieves Nova metadata document", + doer: func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(dummyBody)), + }, nil + }, + want: Document{ + UUID: "12345678-abcd-ef00-1234-56789abcdef0", + Meta: map[string]string{"key1": "value1", "key2": "value2"}, + Hostname: "test-vm-01", + Name: "test-vm-01", + ProjectID: "proj-123456", + AvailabilityZone: "zone-a", + }, + }, + { + name: "http error", + doer: func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("connection failed") + }, + expectErr: true, + }, + { + name: "non-200 status", + doer: func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString("fail")), + }, nil + }, + expectErr: true, + }, + { + name: "invalid json", + doer: func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("{not-json")), + }, nil + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &metadataClient{ + client: &http.Client{ + Timeout: 1 * time.Second, + Transport: roundTripperFunc(tt.doer), + }, + } + + doc, err := p.Get(t.Context()) + if tt.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !reflect.DeepEqual(doc, tt.want) { + t.Fatalf("document mismatch\n got: %+v\nwant: %+v", doc, tt.want) + } + }) + } +} + +func TestNovaProviderAccessors(t *testing.T) { + dummyBody := `{ + "uuid": "abcd-1234", + "hostname": "vm-accessor-test", + "name": "vm-accessor-test", + "project_id": "proj-xyz" + }` + + p := &metadataClient{ + client: &http.Client{ + Timeout: 1 * time.Second, + Transport: roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(dummyBody)), + }, nil + }), + }, + } + + ctx := t.Context() + + // InstanceID + id, err := p.InstanceID(ctx) + if err != nil { + t.Fatalf("unexpected error in InstanceID: %v", err) + } + if id != "abcd-1234" { + t.Fatalf("InstanceID mismatch: got %q, want %q", id, "abcd-1234") + } + + // Hostname + hn, err := p.Hostname(ctx) + if err != nil { + t.Fatalf("unexpected error in Hostname: %v", err) + } + if hn != "vm-accessor-test" { + t.Fatalf("Hostname mismatch: got %q, want %q", hn, "vm-accessor-test") + } +} + +func TestNovaInstanceType(t *testing.T) { + tests := []struct { + name string + doer roundTripperFunc + want string + expectErr bool + }{ + { + name: "successfully retrieves instance type", + doer: func(req *http.Request) (*http.Response, error) { + if req.URL.String() != ec2MetaBaseURL+"instance-type" { + t.Fatalf("expected URL %s, got %s", ec2MetaBaseURL+"instance-type", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("t3.small")), + }, nil + }, + want: "t3.small", + expectErr: false, + }, + { + name: "returns error on HTTP failure", + doer: func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("connection failed") + }, + expectErr: true, + }, + { + name: "returns error on non-200 status", + doer: func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString("not found")), + }, nil + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &metadataClient{ + client: &http.Client{ + Timeout: 1 * time.Second, + Transport: tt.doer, + }, + } + + got, err := p.InstanceType(t.Context()) + if tt.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tt.want { + t.Fatalf("InstanceType mismatch: got %q, want %q", got, tt.want) + } + }) + } +} + +// helper to implement http.RoundTripper from a function +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/processor/resourcedetectionprocessor/README.md b/processor/resourcedetectionprocessor/README.md index f7c1b011a9124..adcafea5a4d50 100644 --- a/processor/resourcedetectionprocessor/README.md +++ b/processor/resourcedetectionprocessor/README.md @@ -792,10 +792,41 @@ processors: fail_on_missing_metadata: true ``` +### Openstack Nova + +Uses the [OpenStack Nova metadata API](https://docs.openstack.org/nova/latest/user/metadata.html) to read resource information from the instance metadata service and populate related resource attributes. + +The list of the populated resource attributes can be found at [Nova Detector Resource Attributes](./internal/openstack/nova/documentation.md). + +It can also optionally capture metadata keys from the `"meta"` section of `meta_data.json` as resource attributes, using regular expressions to match the keys you want. + +Nova custom configuration example: +```yaml +processors: + resourcedetection/nova: + detectors: ["nova"] + nova: + # A list of regex's to match label keys to add as resource attributes can be specified + labels: + - ^tag1$ + - ^tag2$ + - ^label.*$ +``` + +The Nova detector will report an error in logs if the metadata endpoint is unavailable. You can configure the detector to instead fail with this flag: + +```yaml +processors: + resourcedetection/nova: + detectors: ["nova"] + nova: + fail_on_missing_metadata: true +``` + ## Configuration ```yaml -# a list of resource detectors to run, valid options are: "env", "system", "gcp", "ec2", "ecs", "elastic_beanstalk", "eks", "lambda", "azure", "aks", "heroku", "openshift", "dynatrace", "consul", "docker", "k8snode, "kubeadm", "hetzner", "akamai", "scaleway", "vultr", "oraclecloud", "digitalocean", "upcloud" +# a list of resource detectors to run, valid options are: "env", "system", "gcp", "ec2", "ecs", "elastic_beanstalk", "eks", "lambda", "azure", "aks", "heroku", "openshift", "dynatrace", "consul", "docker", "k8snode, "kubeadm", "hetzner", "akamai", "scaleway", "vultr", "oraclecloud", "digitalocean", "nova", "upcloud" detectors: [ ] # determines if existing resource attributes should be overridden or preserved, defaults to true override: diff --git a/processor/resourcedetectionprocessor/config.go b/processor/resourcedetectionprocessor/config.go index ef0df7757e615..b24712d1b7371 100644 --- a/processor/resourcedetectionprocessor/config.go +++ b/processor/resourcedetectionprocessor/config.go @@ -24,6 +24,7 @@ import ( "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/k8snode" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/kubeadm" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openshift" + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/oraclecloud" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/scaleway" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/system" @@ -97,6 +98,9 @@ type DetectorConfig struct { // OpenShift contains user-specified configurations for the OpenShift detector OpenShiftConfig openshift.Config `mapstructure:"openshift"` + // OpenShift contains user-specified configurations for the OpenShift detector + OpenStackNovaConfig nova.Config `mapstructure:"nova"` + // OracleCloud contains user-specified configurations for the OracleCloud detector OracleCloudConfig oraclecloud.Config `mapstructure:"oraclecloud"` @@ -136,6 +140,7 @@ func detectorCreateDefaultConfig() DetectorConfig { HetznerConfig: hetzner.CreateDefaultConfig(), SystemConfig: system.CreateDefaultConfig(), OpenShiftConfig: openshift.CreateDefaultConfig(), + OpenStackNovaConfig: nova.CreateDefaultConfig(), OracleCloudConfig: oraclecloud.CreateDefaultConfig(), K8SNodeConfig: k8snode.CreateDefaultConfig(), KubeadmConfig: kubeadm.CreateDefaultConfig(), @@ -178,6 +183,8 @@ func (d *DetectorConfig) GetConfigFromType(detectorType internal.DetectorType) i return d.SystemConfig case openshift.TypeStr: return d.OpenShiftConfig + case nova.TypeStr: + return d.OpenStackNovaConfig case oraclecloud.TypeStr: return d.OracleCloudConfig case k8snode.TypeStr: diff --git a/processor/resourcedetectionprocessor/factory.go b/processor/resourcedetectionprocessor/factory.go index 3593ccaf32f83..ff3c510fd088d 100644 --- a/processor/resourcedetectionprocessor/factory.go +++ b/processor/resourcedetectionprocessor/factory.go @@ -39,6 +39,7 @@ import ( "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/kubeadm" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/metadata" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openshift" + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/oraclecloud" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/scaleway" "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/system" @@ -78,6 +79,7 @@ func NewFactory() processor.Factory { scaleway.TypeStr: scaleway.NewDetector, system.TypeStr: system.NewDetector, openshift.TypeStr: openshift.NewDetector, + nova.TypeStr: nova.NewDetector, oraclecloud.TypeStr: oraclecloud.NewDetector, k8snode.TypeStr: k8snode.NewDetector, kubeadm.TypeStr: kubeadm.NewDetector, diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/config.go b/processor/resourcedetectionprocessor/internal/openstack/nova/config.go new file mode 100644 index 0000000000000..d0afc6fde82e7 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/config.go @@ -0,0 +1,34 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package nova // import "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova" + +import ( + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata" +) + +// Config defines user-specified configurations unique to the Nova detector. +type Config struct { + // Labels is a list of regex patterns to match Nova instance metadata keys + // (from the "meta" map) that should be added as resource attributes. + // Matched keys are emitted as "openstack.nova.meta.: ". + Labels []string `mapstructure:"labels"` + + // ResourceAttributes controls which standard resource attributes are enabled + // (e.g., host.id, host.name, cloud.provider, etc.). + ResourceAttributes metadata.ResourceAttributesConfig `mapstructure:"resource_attributes"` + + // FailOnMissingMetadata, if true, causes the detector to return an error + // when the Nova metadata service is unavailable or required fields are missing. + // If false (default), the detector does best-effort population. + FailOnMissingMetadata bool `mapstructure:"fail_on_missing_metadata"` +} + +// CreateDefaultConfig returns the default configuration for the Nova detector. +func CreateDefaultConfig() Config { + return Config{ + Labels: []string{}, + ResourceAttributes: metadata.DefaultResourceAttributesConfig(), + FailOnMissingMetadata: false, + } +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/documentation.md b/processor/resourcedetectionprocessor/internal/openstack/nova/documentation.md new file mode 100644 index 0000000000000..b9abb53216c6f --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/documentation.md @@ -0,0 +1,15 @@ +[comment]: <> (Code generated by mdatagen. DO NOT EDIT.) + +# novadetector + +## Resource Attributes + +| Name | Description | Values | Enabled | +| ---- | ----------- | ------ | ------- | +| cloud.account.id | The cloud account id (e.g., OpenStack project or tenant ID) | Any Str | true | +| cloud.availability_zone | The cloud availability zone | Any Str | true | +| cloud.platform | The cloud platform | Any Str | true | +| cloud.provider | The cloud provider | Any Str | true | +| host.id | The host.id | Any Str | true | +| host.name | The hostname | Any Str | true | +| host.type | The host instance type (Nova flavor name or ID) | Any Str | true | diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/generated_component_test.go b/processor/resourcedetectionprocessor/internal/openstack/nova/generated_component_test.go new file mode 100644 index 0000000000000..bdb78b3117450 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/generated_component_test.go @@ -0,0 +1,21 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package nova + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/component/componenttest" +) + +var typ = component.MustNewType("novadetector") + +func TestComponentFactoryType(t *testing.T) { + require.Equal(t, typ, NewFactory().Type()) +} + +func TestComponentConfigStruct(t *testing.T) { + require.NoError(t, componenttest.CheckConfigStruct(NewFactory().CreateDefaultConfig())) +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/generated_package_test.go b/processor/resourcedetectionprocessor/internal/openstack/nova/generated_package_test.go new file mode 100644 index 0000000000000..6d121c657b241 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/generated_package_test.go @@ -0,0 +1,13 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package nova + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_config.go b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_config.go new file mode 100644 index 0000000000000..d4a1c881e8ec6 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_config.go @@ -0,0 +1,63 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/confmap" +) + +// ResourceAttributeConfig provides common config for a particular resource attribute. +type ResourceAttributeConfig struct { + Enabled bool `mapstructure:"enabled"` + + enabledSetByUser bool +} + +func (rac *ResourceAttributeConfig) Unmarshal(parser *confmap.Conf) error { + if parser == nil { + return nil + } + err := parser.Unmarshal(rac) + if err != nil { + return err + } + rac.enabledSetByUser = parser.IsSet("enabled") + return nil +} + +// ResourceAttributesConfig provides config for novadetector resource attributes. +type ResourceAttributesConfig struct { + CloudAccountID ResourceAttributeConfig `mapstructure:"cloud.account.id"` + CloudAvailabilityZone ResourceAttributeConfig `mapstructure:"cloud.availability_zone"` + CloudPlatform ResourceAttributeConfig `mapstructure:"cloud.platform"` + CloudProvider ResourceAttributeConfig `mapstructure:"cloud.provider"` + HostID ResourceAttributeConfig `mapstructure:"host.id"` + HostName ResourceAttributeConfig `mapstructure:"host.name"` + HostType ResourceAttributeConfig `mapstructure:"host.type"` +} + +func DefaultResourceAttributesConfig() ResourceAttributesConfig { + return ResourceAttributesConfig{ + CloudAccountID: ResourceAttributeConfig{ + Enabled: true, + }, + CloudAvailabilityZone: ResourceAttributeConfig{ + Enabled: true, + }, + CloudPlatform: ResourceAttributeConfig{ + Enabled: true, + }, + CloudProvider: ResourceAttributeConfig{ + Enabled: true, + }, + HostID: ResourceAttributeConfig{ + Enabled: true, + }, + HostName: ResourceAttributeConfig{ + Enabled: true, + }, + HostType: ResourceAttributeConfig{ + Enabled: true, + }, + } +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_config_test.go b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_config_test.go new file mode 100644 index 0000000000000..285314e5b521e --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_config_test.go @@ -0,0 +1,69 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/collector/confmap/confmaptest" +) + +func TestResourceAttributesConfig(t *testing.T) { + tests := []struct { + name string + want ResourceAttributesConfig + }{ + { + name: "default", + want: DefaultResourceAttributesConfig(), + }, + { + name: "all_set", + want: ResourceAttributesConfig{ + CloudAccountID: ResourceAttributeConfig{Enabled: true}, + CloudAvailabilityZone: ResourceAttributeConfig{Enabled: true}, + CloudPlatform: ResourceAttributeConfig{Enabled: true}, + CloudProvider: ResourceAttributeConfig{Enabled: true}, + HostID: ResourceAttributeConfig{Enabled: true}, + HostName: ResourceAttributeConfig{Enabled: true}, + HostType: ResourceAttributeConfig{Enabled: true}, + }, + }, + { + name: "none_set", + want: ResourceAttributesConfig{ + CloudAccountID: ResourceAttributeConfig{Enabled: false}, + CloudAvailabilityZone: ResourceAttributeConfig{Enabled: false}, + CloudPlatform: ResourceAttributeConfig{Enabled: false}, + CloudProvider: ResourceAttributeConfig{Enabled: false}, + HostID: ResourceAttributeConfig{Enabled: false}, + HostName: ResourceAttributeConfig{Enabled: false}, + HostType: ResourceAttributeConfig{Enabled: false}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadResourceAttributesConfig(t, tt.name) + diff := cmp.Diff(tt.want, cfg, cmpopts.IgnoreUnexported(ResourceAttributeConfig{})) + require.Emptyf(t, diff, "Config mismatch (-expected +actual):\n%s", diff) + }) + } +} + +func loadResourceAttributesConfig(t *testing.T, name string) ResourceAttributesConfig { + cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml")) + require.NoError(t, err) + sub, err := cm.Sub(name) + require.NoError(t, err) + sub, err = sub.Sub("resource_attributes") + require.NoError(t, err) + cfg := DefaultResourceAttributesConfig() + require.NoError(t, sub.Unmarshal(&cfg)) + return cfg +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_resource.go b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_resource.go new file mode 100644 index 0000000000000..d41ed3681a555 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_resource.go @@ -0,0 +1,78 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/pdata/pcommon" +) + +// ResourceBuilder is a helper struct to build resources predefined in metadata.yaml. +// The ResourceBuilder is not thread-safe and must not to be used in multiple goroutines. +type ResourceBuilder struct { + config ResourceAttributesConfig + res pcommon.Resource +} + +// NewResourceBuilder creates a new ResourceBuilder. This method should be called on the start of the application. +func NewResourceBuilder(rac ResourceAttributesConfig) *ResourceBuilder { + return &ResourceBuilder{ + config: rac, + res: pcommon.NewResource(), + } +} + +// SetCloudAccountID sets provided value as "cloud.account.id" attribute. +func (rb *ResourceBuilder) SetCloudAccountID(val string) { + if rb.config.CloudAccountID.Enabled { + rb.res.Attributes().PutStr("cloud.account.id", val) + } +} + +// SetCloudAvailabilityZone sets provided value as "cloud.availability_zone" attribute. +func (rb *ResourceBuilder) SetCloudAvailabilityZone(val string) { + if rb.config.CloudAvailabilityZone.Enabled { + rb.res.Attributes().PutStr("cloud.availability_zone", val) + } +} + +// SetCloudPlatform sets provided value as "cloud.platform" attribute. +func (rb *ResourceBuilder) SetCloudPlatform(val string) { + if rb.config.CloudPlatform.Enabled { + rb.res.Attributes().PutStr("cloud.platform", val) + } +} + +// SetCloudProvider sets provided value as "cloud.provider" attribute. +func (rb *ResourceBuilder) SetCloudProvider(val string) { + if rb.config.CloudProvider.Enabled { + rb.res.Attributes().PutStr("cloud.provider", val) + } +} + +// SetHostID sets provided value as "host.id" attribute. +func (rb *ResourceBuilder) SetHostID(val string) { + if rb.config.HostID.Enabled { + rb.res.Attributes().PutStr("host.id", val) + } +} + +// SetHostName sets provided value as "host.name" attribute. +func (rb *ResourceBuilder) SetHostName(val string) { + if rb.config.HostName.Enabled { + rb.res.Attributes().PutStr("host.name", val) + } +} + +// SetHostType sets provided value as "host.type" attribute. +func (rb *ResourceBuilder) SetHostType(val string) { + if rb.config.HostType.Enabled { + rb.res.Attributes().PutStr("host.type", val) + } +} + +// Emit returns the built resource and resets the internal builder state. +func (rb *ResourceBuilder) Emit() pcommon.Resource { + r := rb.res + rb.res = pcommon.NewResource() + return r +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_resource_test.go b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_resource_test.go new file mode 100644 index 0000000000000..5009bdc87bfed --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_resource_test.go @@ -0,0 +1,76 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResourceBuilder(t *testing.T) { + for _, tt := range []string{"default", "all_set", "none_set"} { + t.Run(tt, func(t *testing.T) { + cfg := loadResourceAttributesConfig(t, tt) + rb := NewResourceBuilder(cfg) + rb.SetCloudAccountID("cloud.account.id-val") + rb.SetCloudAvailabilityZone("cloud.availability_zone-val") + rb.SetCloudPlatform("cloud.platform-val") + rb.SetCloudProvider("cloud.provider-val") + rb.SetHostID("host.id-val") + rb.SetHostName("host.name-val") + rb.SetHostType("host.type-val") + + res := rb.Emit() + assert.Equal(t, 0, rb.Emit().Attributes().Len()) // Second call should return empty Resource + + switch tt { + case "default": + assert.Equal(t, 7, res.Attributes().Len()) + case "all_set": + assert.Equal(t, 7, res.Attributes().Len()) + case "none_set": + assert.Equal(t, 0, res.Attributes().Len()) + return + default: + assert.Failf(t, "unexpected test case: %s", tt) + } + + val, ok := res.Attributes().Get("cloud.account.id") + assert.True(t, ok) + if ok { + assert.Equal(t, "cloud.account.id-val", val.Str()) + } + val, ok = res.Attributes().Get("cloud.availability_zone") + assert.True(t, ok) + if ok { + assert.Equal(t, "cloud.availability_zone-val", val.Str()) + } + val, ok = res.Attributes().Get("cloud.platform") + assert.True(t, ok) + if ok { + assert.Equal(t, "cloud.platform-val", val.Str()) + } + val, ok = res.Attributes().Get("cloud.provider") + assert.True(t, ok) + if ok { + assert.Equal(t, "cloud.provider-val", val.Str()) + } + val, ok = res.Attributes().Get("host.id") + assert.True(t, ok) + if ok { + assert.Equal(t, "host.id-val", val.Str()) + } + val, ok = res.Attributes().Get("host.name") + assert.True(t, ok) + if ok { + assert.Equal(t, "host.name-val", val.Str()) + } + val, ok = res.Attributes().Get("host.type") + assert.True(t, ok) + if ok { + assert.Equal(t, "host.type-val", val.Str()) + } + }) + } +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_status.go b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_status.go new file mode 100644 index 0000000000000..6fc3377cfccea --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/generated_status.go @@ -0,0 +1,19 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/component" +) + +var ( + Type = component.MustNewType("novadetector") + ScopeName = "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova" +) + +const ( + TracesStability = component.StabilityLevelAlpha + MetricsStability = component.StabilityLevelAlpha + LogsStability = component.StabilityLevelAlpha + ProfilesStability = component.StabilityLevelAlpha +) diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/testdata/config.yaml b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/testdata/config.yaml new file mode 100644 index 0000000000000..15013fa011de1 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata/testdata/config.yaml @@ -0,0 +1,33 @@ +default: +all_set: + resource_attributes: + cloud.account.id: + enabled: true + cloud.availability_zone: + enabled: true + cloud.platform: + enabled: true + cloud.provider: + enabled: true + host.id: + enabled: true + host.name: + enabled: true + host.type: + enabled: true +none_set: + resource_attributes: + cloud.account.id: + enabled: false + cloud.availability_zone: + enabled: false + cloud.platform: + enabled: false + cloud.provider: + enabled: false + host.id: + enabled: false + host.name: + enabled: false + host.type: + enabled: false diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/metadata.go b/processor/resourcedetectionprocessor/internal/openstack/nova/metadata.go new file mode 100644 index 0000000000000..6e3212dbb8c03 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/metadata.go @@ -0,0 +1,25 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package nova // import "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova" + +import ( + "go.opentelemetry.io/collector/component" + + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata" +) + +type dummyFactory struct{} + +func (dummyFactory) Type() component.Type { + return metadata.Type +} + +func (dummyFactory) CreateDefaultConfig() component.Config { + return struct{}{} +} + +// Necessary to satisfy mdatagen tests +func NewFactory() component.Factory { + return dummyFactory{} +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/metadata.yaml b/processor/resourcedetectionprocessor/internal/openstack/nova/metadata.yaml new file mode 100644 index 0000000000000..594a22fb02d01 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/metadata.yaml @@ -0,0 +1,42 @@ +type: novadetector + +status: + class: processor + stability: + alpha: [traces, metrics, logs, profiles] + codeowners: + active: [dashpole, paulojmdias] + +resource_attributes: + cloud.account.id: + description: The cloud account id (e.g., OpenStack project or tenant ID) + type: string + enabled: true + cloud.availability_zone: + description: The cloud availability zone + type: string + enabled: true + cloud.platform: + description: The cloud platform + type: string + enabled: true + cloud.provider: + description: The cloud provider + type: string + enabled: true + host.id: + description: The host.id + type: string + enabled: true + host.name: + description: The hostname + type: string + enabled: true + host.type: + description: The host instance type (Nova flavor name or ID) + type: string + enabled: true + +tests: + skip_lifecycle: true + skip_shutdown: true diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/nova.go b/processor/resourcedetectionprocessor/internal/openstack/nova/nova.go new file mode 100644 index 0000000000000..88c2ae1d96ea3 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/nova.go @@ -0,0 +1,127 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package nova // import "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova" + +import ( + "context" + "fmt" + "regexp" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/processor" + conventions "go.opentelemetry.io/otel/semconv/v1.6.1" + "go.uber.org/zap" + + novaprovider "github.com/open-telemetry/opentelemetry-collector-contrib/internal/metadataproviders/openstack/nova" + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal" + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata" +) + +const ( + // TypeStr is the detector type id. + TypeStr = "nova" + + // LabelPrefix is the attribute prefix for Nova metadata keys (user/tenant-provided). + LabelPrefix = "openstack.nova.meta." +) + +var _ internal.Detector = (*Detector)(nil) + +// Detector queries the OpenStack Nova metadata service and emits resource attributes. +type Detector struct { + logger *zap.Logger + rb *metadata.ResourceBuilder + metadataProvider novaprovider.Provider + labelRegexes []*regexp.Regexp + failOnMissingMetadata bool +} + +// NewDetector creates a Nova detector. +func NewDetector(set processor.Settings, dcfg internal.DetectorConfig) (internal.Detector, error) { + cfg := dcfg.(Config) + + rs, err := compileRegexes(cfg.Labels) + if err != nil { + return nil, err + } + + return &Detector{ + logger: set.Logger, + rb: metadata.NewResourceBuilder(cfg.ResourceAttributes), + metadataProvider: novaprovider.NewProvider(), + labelRegexes: rs, + failOnMissingMetadata: cfg.FailOnMissingMetadata, + }, nil +} + +func (d *Detector) Detect(ctx context.Context) (resource pcommon.Resource, schemaURL string, err error) { + if _, err = d.metadataProvider.InstanceID(ctx); err != nil { + d.logger.Debug("OpenStack Nova metadata unavailable", zap.Error(err)) + if d.failOnMissingMetadata { + return pcommon.NewResource(), "", err + } + return pcommon.NewResource(), "", nil + } + + meta, err := d.metadataProvider.Get(ctx) + if err != nil { + return pcommon.NewResource(), "", fmt.Errorf("failed getting Nova metadata: %w", err) + } + + hostname, err := d.metadataProvider.Hostname(ctx) + if err != nil { + return pcommon.NewResource(), "", fmt.Errorf("failed getting hostname: %w", err) + } + + // Optional: EC2‑compatible instance type (don’t fail if missing) + if instanceType, err := d.metadataProvider.InstanceType(ctx); err == nil && instanceType != "" { + d.rb.SetHostType(instanceType) + } else if err != nil { + d.logger.Debug("EC2-compatible instance type unavailable", zap.Error(err)) + } + + d.rb.SetCloudProvider("openstack") + d.rb.SetCloudPlatform("openstack_nova") + d.rb.SetCloudAccountID(meta.ProjectID) + d.rb.SetCloudAvailabilityZone(meta.AvailabilityZone) + d.rb.SetHostID(meta.UUID) + d.rb.SetHostName(hostname) + res := d.rb.Emit() + + // Optional: capture selected meta labels under openstack.meta. + if len(d.labelRegexes) > 0 && meta.Meta != nil { + attrs := res.Attributes() + for k, v := range meta.Meta { + if regexArrayMatch(d.labelRegexes, k) { + attrs.PutStr(LabelPrefix+k, v) + } + } + } + + return res, conventions.SchemaURL, nil +} + +func compileRegexes(pats []string) ([]*regexp.Regexp, error) { + if len(pats) == 0 { + return nil, nil + } + out := make([]*regexp.Regexp, 0, len(pats)) + for _, p := range pats { + re, err := regexp.Compile(p) + if err != nil { + return nil, err + } + out = append(out, re) + } + return out, nil +} + +func regexArrayMatch(arr []*regexp.Regexp, s string) bool { + for _, r := range arr { + if r.MatchString(s) { + return true + } + } + return false +} diff --git a/processor/resourcedetectionprocessor/internal/openstack/nova/nova_test.go b/processor/resourcedetectionprocessor/internal/openstack/nova/nova_test.go new file mode 100644 index 0000000000000..211c251991f82 --- /dev/null +++ b/processor/resourcedetectionprocessor/internal/openstack/nova/nova_test.go @@ -0,0 +1,212 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package nova + +import ( + "context" + "errors" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/processor/processortest" + "go.uber.org/zap" + + novaprovider "github.com/open-telemetry/opentelemetry-collector-contrib/internal/metadataproviders/openstack/nova" + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal/openstack/nova/internal/metadata" +) + +var errUnavailable = errors.New("nova metadata unavailable") + +// --- Provider mock --- + +type mockNovaMetadata struct { + retDoc novaprovider.Document + retErrGet error + retHostname string + retErrHostname error + retInstanceType string + retErrInstanceType error + isAvailable bool +} + +var _ novaprovider.Provider = (*mockNovaMetadata)(nil) + +func (m *mockNovaMetadata) InstanceID(_ context.Context) (string, error) { + if !m.isAvailable { + return "", errUnavailable + } + return m.retDoc.UUID, nil +} + +func (m *mockNovaMetadata) Get(_ context.Context) (novaprovider.Document, error) { + if m.retErrGet != nil { + return novaprovider.Document{}, m.retErrGet + } + return m.retDoc, nil +} + +func (m *mockNovaMetadata) Hostname(_ context.Context) (string, error) { + if m.retErrHostname != nil { + return "", m.retErrHostname + } + return m.retHostname, nil +} + +func (m *mockNovaMetadata) InstanceType(_ context.Context) (string, error) { + if m.retErrInstanceType != nil { + return "", m.retErrInstanceType + } + if m.retInstanceType != "" { + return m.retInstanceType, nil + } + return "dummy.host.type", nil +} + +// --- Constructor smoke test (matches EC2 structure) --- + +func TestNewDetector(t *testing.T) { + tests := []struct { + name string + cfg Config + shouldError bool + }{ + { + name: "Success Case Empty Config", + cfg: Config{}, + shouldError: false, + }, + { + name: "Success Case Valid Config", + cfg: Config{ + Labels: []string{"label1"}, + }, + shouldError: false, + }, + { + name: "Error Case Invalid Regex", + cfg: Config{ + Labels: []string{"*"}, + }, + shouldError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detector, err := NewDetector(processortest.NewNopSettings(processortest.NopType), tt.cfg) + if tt.shouldError { + assert.Error(t, err) + assert.Nil(t, detector) + } else { + assert.NotNil(t, detector) + assert.NoError(t, err) + } + }) + } +} + +// --- Detect() behavior tests (like EC2’s TestDetector_Detect) --- + +func TestDetector_Detect(t *testing.T) { + tests := []struct { + name string + provider novaprovider.Provider + labelRegexes []*regexp.Regexp + want pcommon.Resource + wantErr bool + failOnMissingMetadata bool + }{ + { + name: "success with availability_zone, project_id and labels", + provider: &mockNovaMetadata{ + retDoc: novaprovider.Document{ + UUID: "vm-1234", + ProjectID: "proj-xyz", + AvailabilityZone: "zone-a", + Meta: map[string]string{ + "tag1": "val1", + "tag2": "val2", + "other": "nope", + }, + }, + retHostname: "example-nova-host", + retInstanceType: "dummy.host.type", + isAvailable: true, + }, + labelRegexes: []*regexp.Regexp{regexp.MustCompile("^tag1$"), regexp.MustCompile("^tag2$")}, + want: func() pcommon.Resource { + res := pcommon.NewResource() + attr := res.Attributes() + attr.PutStr("cloud.provider", "openstack") + attr.PutStr("cloud.platform", "openstack_nova") + attr.PutStr("cloud.account.id", "proj-xyz") + attr.PutStr("cloud.availability_zone", "zone-a") + attr.PutStr("host.id", "vm-1234") + attr.PutStr("host.name", "example-nova-host") + attr.PutStr("host.type", "dummy.host.type") + attr.PutStr("openstack.nova.meta.tag1", "val1") + attr.PutStr("openstack.nova.meta.tag2", "val2") + return res + }(), + }, + { + name: "endpoint not available", + provider: &mockNovaMetadata{ + isAvailable: false, + }, + want: pcommon.NewResource(), + wantErr: false, + }, + { + name: "endpoint not available, fail_on_missing_metadata", + provider: &mockNovaMetadata{ + isAvailable: false, + }, + want: pcommon.NewResource(), + wantErr: true, + failOnMissingMetadata: true, + }, + { + name: "get fails", + provider: &mockNovaMetadata{ + isAvailable: true, + retErrGet: errors.New("get failed"), + }, + want: pcommon.NewResource(), + wantErr: true, + }, + { + name: "hostname fails", + provider: &mockNovaMetadata{ + isAvailable: true, + retErrHostname: errors.New("hostname failed"), + }, + want: pcommon.NewResource(), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Detector{ + metadataProvider: tt.provider, + logger: zap.NewNop(), + rb: metadata.NewResourceBuilder(metadata.DefaultResourceAttributesConfig()), + labelRegexes: tt.labelRegexes, + failOnMissingMetadata: tt.failOnMissingMetadata, + } + + got, _, err := d.Detect(t.Context()) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, tt.want.Attributes().AsRaw(), got.Attributes().AsRaw()) + } + }) + } +}