diff --git a/apmtest/env.go b/apmtest/env.go index e798ae9f0..c71360321 100644 --- a/apmtest/env.go +++ b/apmtest/env.go @@ -20,6 +20,8 @@ package apmtest // import "go.elastic.co/apm/apmtest" import "os" func init() { - // Disable cloud metadata sniffing by default in tests. - os.Setenv("ELASTIC_APM_CLOUD_PROVIDER", "none") + if os.Getenv("ELASTIC_APM_CLOUD_PROVIDER") == "" { + // Disable cloud metadata sniffing by default in tests. + os.Setenv("ELASTIC_APM_CLOUD_PROVIDER", "none") + } } diff --git a/features/azure_app_service_metadata.feature b/features/azure_app_service_metadata.feature new file mode 100644 index 000000000..3149e8719 --- /dev/null +++ b/features/azure_app_service_metadata.feature @@ -0,0 +1,71 @@ +Feature: Extracting Metadata for Azure App Service + + Background: + Given an instrumented application is configured to collect cloud provider metadata for azure + + Scenario Outline: Azure App Service with all environment variables present in expected format + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is not null + And cloud metadata 'account.id' is 'f5940f10-2e30-3e4d-a259-63451ba6dae4' + And cloud metadata 'provider' is 'azure' + And cloud metadata 'instance.id' is 'instance_id' + And cloud metadata 'instance.name' is 'site_name' + And cloud metadata 'project.name' is 'resource_group' + And cloud metadata 'region' is 'AustraliaEast' + Examples: + | WEBSITE_OWNER_NAME | + | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | f5940f10-2e30-3e4d-a259-63451ba6dae4+appsvc_linux_australiaeast-AustraliaEastwebspace-Linux | + + # WEBSITE_OWNER_NAME is expected to include a + character + Scenario: WEBSITE_OWNER_NAME environment variable not expected format + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4-elastic-apm-AustraliaEastwebspace | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_OWNER_NAME environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_RESOURCE_GROUP environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | WEBSITE_SITE_NAME | site_name | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_SITE_NAME environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_INSTANCE_ID | instance_id | + When cloud metadata is collected + Then cloud metadata is null + + Scenario: Missing WEBSITE_INSTANCE_ID environment variable + Given the following environment variables are present + | name | value | + | WEBSITE_OWNER_NAME | f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace | + | WEBSITE_RESOURCE_GROUP | resource_group | + | WEBSITE_SITE_NAME | site_name | + When cloud metadata is collected + Then cloud metadata is null \ No newline at end of file diff --git a/internal/apmcloudutil/azure.go b/internal/apmcloudutil/azure.go index b4ac6af4c..1485365c8 100644 --- a/internal/apmcloudutil/azure.go +++ b/internal/apmcloudutil/azure.go @@ -22,6 +22,8 @@ import ( "encoding/json" "errors" "net/http" + "os" + "strings" "go.elastic.co/apm/model" ) @@ -32,6 +34,12 @@ const ( // See: https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service func getAzureCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + // First check for Azure App Service environment variables, which can + // be done without performing any network requests. + if getAzureAppServiceCloudMetadata(ctx, out) { + return nil + } + req, err := http.NewRequest("GET", azureMetadataURL, nil) if err != nil { return err @@ -76,3 +84,46 @@ func getAzureCloudMetadata(ctx context.Context, client *http.Client, out *model. } return nil } + +func getAzureAppServiceCloudMetadata(ctx context.Context, out *model.Cloud) bool { + // WEBSITE_OWNER_NAME has the form: + // {subscription id}+{app service plan resource group}-{region}webspace{.*} + websiteOwnerName := os.Getenv("WEBSITE_OWNER_NAME") + if websiteOwnerName == "" { + return false + } + websiteInstanceID := os.Getenv("WEBSITE_INSTANCE_ID") + if websiteInstanceID == "" { + return false + } + websiteSiteName := os.Getenv("WEBSITE_SITE_NAME") + if websiteSiteName == "" { + return false + } + websiteResourceGroup := os.Getenv("WEBSITE_RESOURCE_GROUP") + if websiteResourceGroup == "" { + return false + } + + plus := strings.IndexRune(websiteOwnerName, '+') + if plus == -1 { + return false + } + out.Account = &model.CloudAccount{ID: websiteOwnerName[:plus]} + websiteOwnerName = websiteOwnerName[plus+1:] + + webspace := strings.LastIndex(websiteOwnerName, "webspace") + if webspace == -1 { + return false + } + websiteOwnerName = websiteOwnerName[:webspace] + + hyphen := strings.LastIndex(websiteOwnerName, "-") + if hyphen == -1 { + return false + } + out.Region = websiteOwnerName[hyphen+1:] + out.Instance = &model.CloudInstance{ID: websiteInstanceID, Name: websiteSiteName} + out.Project = &model.CloudProject{Name: websiteResourceGroup} + return true +} diff --git a/internal/apmcloudutil/azure_test.go b/internal/apmcloudutil/azure_test.go index 6172d8e56..6c1b7ee0a 100644 --- a/internal/apmcloudutil/azure_test.go +++ b/internal/apmcloudutil/azure_test.go @@ -21,6 +21,7 @@ import ( "context" "net/http" "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/assert" @@ -57,6 +58,42 @@ func TestAzureCloudMetadata(t *testing.T) { } } +func TestAzureAppServiceCloudMetadata(t *testing.T) { + client := &http.Client{Transport: newTargetedRoundTripper("", "testing.invalid")} + + os.Setenv("WEBSITE_OWNER_NAME", "f5940f10-2e30-3e4d-a259-63451ba6dae4+elastic-apm-AustraliaEastwebspace") + os.Setenv("WEBSITE_RESOURCE_GROUP", "resource_group") + os.Setenv("WEBSITE_SITE_NAME", "site_name") + os.Setenv("WEBSITE_INSTANCE_ID", "instance_id") + defer func() { + os.Unsetenv("WEBSITE_OWNER_NAME") + os.Unsetenv("WEBSITE_RESOURCE_GROUP") + os.Unsetenv("WEBSITE_SITE_NAME") + os.Unsetenv("WEBSITE_INSTANCE_ID") + }() + + for _, provider := range []Provider{Auto, Azure} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "azure", + Region: "AustraliaEast", + Instance: &model.CloudInstance{ + ID: "instance_id", + Name: "site_name", + }, + Project: &model.CloudProject{ + Name: "resource_group", + }, + Account: &model.CloudAccount{ + ID: "f5940f10-2e30-3e4d-a259-63451ba6dae4", + }, + }, out) + } +} + func newAzureMetadataServer() (*httptest.Server, *http.Client) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/metadata/instance/compute" { diff --git a/internal/apmgodog/doc.go b/internal/apmgodog/doc.go new file mode 100644 index 000000000..95bf276e9 --- /dev/null +++ b/internal/apmgodog/doc.go @@ -0,0 +1,19 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you 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 +// +// http://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. + +// Package apmgodog implements the Gherkin feature spec tests. +package apmgodog diff --git a/internal/apmgodog/feature_test.go b/internal/apmgodog/feature_test.go index f3d5b9e76..e67bf151b 100644 --- a/internal/apmgodog/feature_test.go +++ b/internal/apmgodog/feature_test.go @@ -17,7 +17,7 @@ // +build go1.9 -package apmgodog +package apmgodog_test import "testing" diff --git a/internal/apmgodog/formatter.go b/internal/apmgodog/formatter_test.go similarity index 99% rename from internal/apmgodog/formatter.go rename to internal/apmgodog/formatter_test.go index c085cee2c..af74d3cfe 100644 --- a/internal/apmgodog/formatter.go +++ b/internal/apmgodog/formatter_test.go @@ -17,7 +17,7 @@ // +build go1.9 -package apmgodog +package apmgodog_test import ( "fmt" diff --git a/internal/apmgodog/go.mod b/internal/apmgodog/go.mod index a7f688c97..702f6f02a 100644 --- a/internal/apmgodog/go.mod +++ b/internal/apmgodog/go.mod @@ -7,6 +7,7 @@ require ( go.elastic.co/apm v1.11.0 go.elastic.co/apm/module/apmgrpc v1.11.0 go.elastic.co/apm/module/apmhttp v1.11.0 + go.elastic.co/fastjson v1.1.0 google.golang.org/grpc v1.17.0 ) diff --git a/internal/apmgodog/suitecontext.go b/internal/apmgodog/suitecontext_test.go similarity index 81% rename from internal/apmgodog/suitecontext.go rename to internal/apmgodog/suitecontext_test.go index d5a7cb846..65f949222 100644 --- a/internal/apmgodog/suitecontext.go +++ b/internal/apmgodog/suitecontext_test.go @@ -17,10 +17,11 @@ // +build go1.9 -package apmgodog +package apmgodog_test import ( "context" + "errors" "fmt" "net" "net/http" @@ -31,6 +32,7 @@ import ( "time" "github.com/cucumber/godog" + "github.com/cucumber/godog/gherkin" "google.golang.org/grpc" "google.golang.org/grpc/codes" pb "google.golang.org/grpc/examples/helloworld/helloworld" @@ -39,6 +41,7 @@ import ( "go.elastic.co/apm" "go.elastic.co/apm/apmtest" + "go.elastic.co/apm/model" "go.elastic.co/apm/module/apmgrpc" "go.elastic.co/apm/module/apmhttp" "go.elastic.co/apm/transport" @@ -47,6 +50,7 @@ import ( type featureContext struct { apiKey string secretToken string + env []string // for subprocesses httpServer *httptest.Server httpHandler *httpHandler @@ -58,6 +62,8 @@ type featureContext struct { tracer *apmtest.RecordingTracer span *apm.Span transaction *apm.Transaction + + cloud *model.Cloud } // InitContext initialises a godoc.Suite with step definitions. @@ -98,6 +104,10 @@ func InitContext(s *godog.Suite) { c.grpcClient.Close() c.httpServer.Close() }) + s.AfterScenario(func(interface{}, error) { + c.env = nil + c.cloud = nil + }) s.Step("^an agent$", c.anAgent) s.Step("^an api key is not set in the config$", func() error { return nil }) @@ -108,6 +118,7 @@ func InitContext(s *godog.Suite) { s.Step("^an active span$", c.anActiveSpan) s.Step("^an active transaction$", c.anActiveTransaction) + // Outcome s.Step("^user sets span outcome to '(.*)'$", c.userSetsSpanOutcome) s.Step("^user sets transaction outcome to '(.*)'$", c.userSetsTransactionOutcome) s.Step("^span terminates with outcome '(.*)'$", c.spanTerminatesWithOutcome) @@ -121,11 +132,84 @@ func InitContext(s *godog.Suite) { s.Step("^transaction outcome is '(.*)'$", c.transactionOutcomeIs) s.Step("^transaction outcome is \"(.*)\"$", c.transactionOutcomeIs) + // HTTP s.Step("^an HTTP transaction with (.*) response code$", c.anHTTPTransactionWithStatusCode) s.Step("^an HTTP span with (.*) response code$", c.anHTTPSpanWithStatusCode) + // gRPC s.Step("^a gRPC transaction with '(.*)' status$", c.aGRPCTransactionWithStatusCode) s.Step("^a gRPC span with '(.*)' status$", c.aGRPCSpanWithStatusCode) + + // Cloud metadata + s.Step("an instrumented application is configured to collect cloud provider metadata for azure", func() error { + return nil + }) + s.Step("the following environment variables are present", func(kv *gherkin.DataTable) error { + for _, row := range kv.Rows[1:] { + c.env = append(c.env, row.Cells[0].Value+"="+row.Cells[1].Value) + } + return nil + }) + s.Step("cloud metadata is collected", func() error { + _, _, _, cloud, _, err := getSubprocessMetadata(append([]string{ + "ELASTIC_APM_CLOUD_PROVIDER=auto", // Explicitly enable cloud metadata detection + "http_proxy=http://proxy.invalid", // fail all HTTP requests + }, c.env...)...) + if err != nil { + return err + } + if *cloud != (model.Cloud{}) { + c.cloud = cloud + } + return nil + }) + s.Step("cloud metadata is not null", func() error { + if c.cloud == nil { + return errors.New("cloud metadata is empty") + } + return nil + }) + s.Step("cloud metadata is null", func() error { + if c.cloud != nil { + return fmt.Errorf("cloud metadata is non-empty: %+v", *c.cloud) + } + return nil + }) + s.Step("cloud metadata '(.*)' is '(.*)'", func(field, expected string) error { + var actual string + switch field { + case "account.id": + if c.cloud.Account == nil { + return errors.New("cloud.account is nil") + } + actual = c.cloud.Account.ID + case "provider": + actual = c.cloud.Provider + case "instance.id": + if c.cloud.Instance == nil { + return errors.New("cloud.instance is nil") + } + actual = c.cloud.Instance.ID + case "instance.name": + if c.cloud.Instance == nil { + return errors.New("cloud.instance is nil") + } + actual = c.cloud.Instance.Name + case "project.name": + if c.cloud.Project == nil { + return errors.New("cloud.project is nil") + } + actual = c.cloud.Project.Name + case "region": + actual = c.cloud.Region + default: + return fmt.Errorf("unexpected field %q", field) + } + if actual != expected { + return fmt.Errorf("expected %q to be %q, got %q", field, expected, actual) + } + return nil + }) } func (c *featureContext) reset() { @@ -147,7 +231,7 @@ func (c *featureContext) reset() { } func (c *featureContext) anAgent() error { - // No-op; we create the tracer as needed to test steps. + // No-op; we create the tracer in the suite setup. return nil } diff --git a/internal/apmgodog/testmain_test.go b/internal/apmgodog/testmain_test.go new file mode 100644 index 000000000..0ac40e67e --- /dev/null +++ b/internal/apmgodog/testmain_test.go @@ -0,0 +1,103 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you 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 +// +// http://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. + +package apmgodog_test + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "go.elastic.co/apm/apmtest" + "go.elastic.co/apm/model" + "go.elastic.co/fastjson" +) + +var ( + flagDumpMetadata = flag.Bool("dump-metadata", false, "Dump metadata and exit without running any tests") +) + +func TestMain(m *testing.M) { + // call flag.Parse() here if TestMain uses flags + flag.Parse() + if *flagDumpMetadata { + dumpMetadata() + os.Exit(0) + } + os.Exit(m.Run()) +} + +// getSubprocessMetadata +func getSubprocessMetadata(env ...string) (*model.System, *model.Process, *model.Service, *model.Cloud, model.StringMap, error) { + cmd := exec.Command(os.Args[0], "-dump-metadata") + cmd.Env = append(os.Environ(), env...) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return nil, nil, nil, nil, nil, err + } + + var system model.System + var process model.Process + var service model.Service + var cloud model.Cloud + var labels model.StringMap + + output := strings.TrimSpace(stdout.String()) + d := json.NewDecoder(&stdout) + if err := d.Decode(&system); err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("%s (%s)", err, output) + } + if err := d.Decode(&process); err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("%s (%s)", err, output) + } + if err := d.Decode(&service); err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("%s (%s)", err, output) + } + if err := d.Decode(&cloud); err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("%s (%s)", err, output) + } + if err := d.Decode(&labels); err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("%s (%s)", err, output) + } + return &system, &process, &service, &cloud, labels, nil +} + +func dumpMetadata() { + tracer := apmtest.NewRecordingTracer() + defer tracer.Close() + + tracer.StartTransaction("name", "type").End() + tracer.Flush(nil) + system, process, service, labels := tracer.Metadata() + cloud := tracer.CloudMetadata() + + var w fastjson.Writer + for _, m := range []fastjson.Marshaler{&system, &process, &service, &cloud, labels} { + if err := m.MarshalFastJSON(&w); err != nil { + panic(err) + } + } + os.Stdout.Write(w.Bytes()) +}