diff --git a/cli/azd/internal/useragent.go b/cli/azd/internal/useragent.go index 4fbd1d7c768..e359e3b712f 100644 --- a/cli/azd/internal/useragent.go +++ b/cli/azd/internal/useragent.go @@ -3,36 +3,103 @@ package internal import ( "fmt" "os" + "runtime" "strings" ) -// FormatUserAgent formats the user agent with its base information (azd/), information -// that's included from the environment (`AZURE_DEV_USER_AGENT`) and any extra text passed -// via 'extra'. -func FormatUserAgent(extras []string) string { +const userSpecifiedAgentEnvironmentVariableName = "AZURE_DEV_USER_AGENT" +const githubActionsEnvironmentVariableName = "GITHUB_ACTIONS" + +const azDevProductIdentifierKey = "azdev" +const templateProductIdentifierKey = "azdtempl" +const githubActionsProductIdentifierKey = "GhActions" + +type UserAgent struct { + // Azure Developer CLI product identifier. Formatted as `azdev/` + azDevCliIdentifier string + + // (Optional) User specified identifier, set from `AZURE_DEV_USER_AGENT` environment variable + userSpecifiedIdentifier string + + // (Optional) Identifier for the template used, if applicable. Formatted as `azdtempl/` + templateIdentifier string + + // (Optional) Identifier for GitHub Actions, if applicable + githubActionsIdentifier string +} + +func (userAgent *UserAgent) String() string { + var sb strings.Builder + sb.WriteString(userAgent.azDevCliIdentifier) + appendIdentifier(&sb, userAgent.userSpecifiedIdentifier) + appendIdentifier(&sb, userAgent.templateIdentifier) + appendIdentifier(&sb, userAgent.githubActionsIdentifier) + + return sb.String() +} + +func appendIdentifier(sb *strings.Builder, identifier string) { + if identifier != "" { + sb.WriteString(" " + identifier) + } +} + +func makeUserAgent(template string) UserAgent { + userAgent := UserAgent{} + userAgent.azDevCliIdentifier = getAzDevCliIdentifier() + userAgent.userSpecifiedIdentifier = getUserSpecifiedIdentifier() + userAgent.githubActionsIdentifier = getGithubActionsIdentifier() + userAgent.templateIdentifier = formatTemplateIdentifier(template) + + return userAgent +} + +// MakeUserAgentString creates a user agent string that contains all necessary product identifiers, in increasing order: +// - The Azure Developer CLI version, formatted as `azdev/` +// - The user specified identifier, set from `AZURE_DEV_USER_AGENT` environment variable +// - The identifier for the template used, if applicable +// - The identifier for GitHub Actions, if applicable +// Examples (see test `TestUserAgentStringScenarios` for all scenarios ): +// - `azdev/1.0.0 (Go 1.18; windows/amd64)` +// - `azdev/1.0.0 (Go 1.18; windows/amd64) Custom-foo/1.0.0 azdtempl/my-template@1.0.0 GhActions` +func MakeUserAgentString(template string) string { + userAgent := makeUserAgent(template) + + return userAgent.String() +} + +func getAzDevCliIdentifier() string { + return fmt.Sprintf("%s/%s %s", azDevProductIdentifierKey, GetVersionNumber(), getPlatformInfo()) +} + +func getPlatformInfo() string { + return fmt.Sprintf("(Go %s; %s/%s)", runtime.Version(), runtime.GOOS, runtime.GOARCH) +} + +func getUserSpecifiedIdentifier() string { // like the Azure CLI (via it's `AZURE_HTTP_USER_AGENT` env variable) we allow for a user to append // information to the UserAgent by setting an environment variable. - devUserAgent := os.Getenv("AZURE_DEV_USER_AGENT") - - if devUserAgent != "" { - devUserAgent = " " + devUserAgent + if devUserAgent := os.Getenv(userSpecifiedAgentEnvironmentVariableName); devUserAgent != "" { + return devUserAgent } - var appendUA string + return "" +} - if len(extras) > 0 { - appendUA = " " + strings.Join(extras, " ") +func getGithubActionsIdentifier() string { + // `GITHUB_ACTIONS` must be set to 'true' if running in GitHub Actions, + // see https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables + if isRunningInGithubActions := os.Getenv(githubActionsEnvironmentVariableName); isRunningInGithubActions == "true" { + return githubActionsProductIdentifierKey } - // and by default we always include azdev and our version number. - // Ex: AZURECLI/2.34.1 (DEB) azdev/0.0.0-alpha.0 azdtempl/functestapp@v1 - return fmt.Sprintf("azdev/%s%s%s", GetVersionNumber(), devUserAgent, appendUA) + return "" } -// FormatTemplateForUserAgent formats the template into a UserAgent value. -func FormatTemplateForUserAgent(template string) string { +func formatTemplateIdentifier(template string) string { if template == "" { - template = "[none]" + return "" } - return fmt.Sprintf("azdtempl/%s", template) + + return fmt.Sprintf("%s/%s", templateProductIdentifierKey, template) } diff --git a/cli/azd/internal/useragent_test.go b/cli/azd/internal/useragent_test.go index adee6654b91..bd1bef5a467 100644 --- a/cli/azd/internal/useragent_test.go +++ b/cli/azd/internal/useragent_test.go @@ -8,28 +8,133 @@ import ( "github.com/stretchr/testify/require" ) -func TestGetUserAgent(t *testing.T) { - orig := os.Getenv("AZURE_DEV_USER_AGENT") - defer func() { os.Setenv("AZURE_DEV_USER_AGENT", orig) }() +func TestGetAzDevCliIdentifier(t *testing.T) { + version := GetVersionNumber() + require.NotEmpty(t, version) + + require.Equal(t, fmt.Sprintf("%s/%s %s", azDevProductIdentifierKey, version, getPlatformInfo()), getAzDevCliIdentifier()) +} + +func TestUserSpecifiedAgentIdentifier(t *testing.T) { + devUserAgent := "MyAgent/1.0.0" + restorer := EnvironmentVariablesSetter(map[string]string{ + userSpecifiedAgentEnvironmentVariableName: devUserAgent, + }) + + require.Equal(t, devUserAgent, getUserSpecifiedIdentifier()) + + // Empty case + os.Setenv(userSpecifiedAgentEnvironmentVariableName, "") + require.Equal(t, "", getUserSpecifiedIdentifier()) + + t.Cleanup(restorer) +} + +func TestGithubActionIdentifier(t *testing.T) { + restorer := EnvironmentVariablesSetter(map[string]string{ + githubActionsEnvironmentVariableName: "", + }) + + require.Equal(t, "", getGithubActionsIdentifier()) + + // Empty case + os.Setenv(githubActionsEnvironmentVariableName, "true") + require.Equal(t, "GhActions", getGithubActionsIdentifier()) + + t.Cleanup(restorer) +} + +func TestFormatTemplate(t *testing.T) { + require.Equal(t, "", formatTemplateIdentifier("")) + require.Equal(t, fmt.Sprintf("%s/todo-python-mongo", templateProductIdentifierKey), formatTemplateIdentifier("todo-python-mongo")) + require.Equal(t, fmt.Sprintf("%s/todo-csharp-sql@0.0.1-beta", templateProductIdentifierKey), formatTemplateIdentifier("todo-csharp-sql@0.0.1-beta")) +} + +// Scenario tests +func TestUserAgentStringScenarios(t *testing.T) { + restorer := EnvironmentVariablesSetter(map[string]string{ + userSpecifiedAgentEnvironmentVariableName: "", + githubActionsEnvironmentVariableName: "", + }) version := GetVersionNumber() require.NotEmpty(t, version) - os.Setenv("AZURE_DEV_USER_AGENT", "") + azDevIdentifier := fmt.Sprintf("azdev/%s %s", version, getPlatformInfo()) + + // Scenario: default agent + require.Equal(t, azDevIdentifier, MakeUserAgentString("")) - require.Equal(t, fmt.Sprintf("azdev/%s", version), FormatUserAgent([]string{})) - require.Equal(t, fmt.Sprintf("azdev/%s", version), FormatUserAgent(nil)) - require.Equal(t, fmt.Sprintf("azdev/%s extra values", version), FormatUserAgent([]string{"extra", "values"})) + // Scenario: user specifies agent variable + os.Setenv(userSpecifiedAgentEnvironmentVariableName, "dev_user_agent") + require.Equal(t, fmt.Sprintf("%s dev_user_agent", azDevIdentifier), MakeUserAgentString("")) + os.Setenv(userSpecifiedAgentEnvironmentVariableName, "") - os.Setenv("AZURE_DEV_USER_AGENT", "dev_user_agent") + // Scenario: running on github actions + os.Setenv(githubActionsEnvironmentVariableName, "true") + require.Equal(t, fmt.Sprintf("%s GhActions", azDevIdentifier), MakeUserAgentString("")) + os.Setenv(githubActionsEnvironmentVariableName, "") - require.Equal(t, fmt.Sprintf("azdev/%s dev_user_agent", version), FormatUserAgent([]string{})) - require.Equal(t, fmt.Sprintf("azdev/%s dev_user_agent", version), FormatUserAgent(nil)) - require.Equal(t, fmt.Sprintf("azdev/%s dev_user_agent extra values", version), FormatUserAgent([]string{"extra", "values"})) + // Scenario: template present + require.Equal(t, fmt.Sprintf("%s azdtempl/template@0.0.1", azDevIdentifier), MakeUserAgentString("template@0.0.1")) + + // Scenario: full combination + os.Setenv(userSpecifiedAgentEnvironmentVariableName, "dev_user_agent") + os.Setenv(githubActionsEnvironmentVariableName, "true") + require.Equal(t, fmt.Sprintf("%s dev_user_agent azdtempl/template@0.0.1 GhActions", azDevIdentifier), MakeUserAgentString("template@0.0.1")) + + t.Cleanup(restorer) } -func TestFormatTemplateForUserAgent(t *testing.T) { - require.Equal(t, "azdtempl/[none]", FormatTemplateForUserAgent("")) - require.Equal(t, "azdtempl/todo-python-mongo", FormatTemplateForUserAgent("todo-python-mongo")) - require.Equal(t, "azdtempl/todo-csharp-sql@0.0.1-beta", FormatTemplateForUserAgent("todo-csharp-sql@0.0.1-beta")) +func TestUserAgentString(t *testing.T) { + userAgent := UserAgent{ + azDevCliIdentifier: "cli/1.0.0", + } + + require.Equal(t, userAgent.String(), userAgent.azDevCliIdentifier) + + // Verify complete formatting + userAgent = UserAgent{ + azDevCliIdentifier: "cli/1.0.0", + userSpecifiedIdentifier: "dev-user-agent/0.0.0-beta", + templateIdentifier: "template/mytemplate@2.0.0", + githubActionsIdentifier: "gh/3.0.2", + } + + require.Equal( + t, + userAgent.String(), + fmt.Sprintf("%s %s %s %s", userAgent.azDevCliIdentifier, userAgent.userSpecifiedIdentifier, userAgent.templateIdentifier, userAgent.githubActionsIdentifier)) +} + +// EnvironmentVariablesSetter sets the provided environment variables, +// returning a function that restores the environment variables to their original values. +// Example usage: +// fn test(t *testing.T) { +// closer := helpers.EnvironmentVariablesSetter(map[string]string { "FOO_ENV": "Bar", "OTHER_FOO_ENV": "Bar2"}) +// require.Equal(t, os.GetEnv("FOO_ENV"), "Bar") +// require.Equal(t, os.GetEnv("OTHER_FOO_ENV"), "Bar2") +// t.Cleanup(closer) +// } +// +func EnvironmentVariablesSetter(envContext map[string]string) func() { + restoreContext := map[string]string{} + for key, value := range envContext { + orig, present := os.LookupEnv(key) + if present { + restoreContext[key] = orig + } + + os.Setenv(key, value) + } + + return func() { + for key := range envContext { + if restoreValue, present := restoreContext[key]; present { + os.Setenv(key, restoreValue) + } else { + os.Unsetenv(key) + } + } + } } diff --git a/cli/azd/main.go b/cli/azd/main.go index 44e8f7c30c3..702bf115cff 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -151,7 +151,7 @@ func fetchLatestVersion(version chan<- semver.Version) { log.Printf("failed to create request object: %v, skipping update check", err) } - req.Header.Set("User-Agent", internal.FormatUserAgent(nil)) + req.Header.Set("User-Agent", internal.MakeUserAgentString("")) res, err := http.DefaultClient.Do(req) if err != nil { diff --git a/cli/azd/pkg/commands/util.go b/cli/azd/pkg/commands/util.go index a0abd8b77f2..0f8b039d807 100644 --- a/cli/azd/pkg/commands/util.go +++ b/cli/azd/pkg/commands/util.go @@ -31,12 +31,14 @@ func GetAzCliFromContext(ctx context.Context) tools.AzCli { azCli = tools.NewAzCli(azCliArgs) } + selectedTemplate := "" + // Set the user agent if a template has been selected - template, ok := ctx.Value(environment.TemplateContextKey).(string) - if ok && strings.TrimSpace(template) != "" { - userAgent := internal.FormatTemplateForUserAgent(template) - azCli.SetUserAgent([]string{userAgent}) + if template, ok := ctx.Value(environment.TemplateContextKey).(string); ok && strings.TrimSpace(template) != "" { + selectedTemplate = template } + azCli.SetUserAgent(internal.MakeUserAgentString(selectedTemplate)) + return azCli } diff --git a/cli/azd/pkg/tools/azcli.go b/cli/azd/pkg/tools/azcli.go index c7e8c7ef6b1..ea3a0778f89 100644 --- a/cli/azd/pkg/tools/azcli.go +++ b/cli/azd/pkg/tools/azcli.go @@ -39,7 +39,7 @@ type AzCli interface { // SetUserAgent sets the user agent that's sent with each call to the Azure // CLI via the `AZURE_HTTP_USER_AGENT` environment variable. - SetUserAgent(extraUserAgentData []string) + SetUserAgent(userAgent string) // UserAgent gets the currently configured user agent UserAgent() string @@ -297,7 +297,7 @@ func NewAzCli(args NewAzCliArgs) AzCli { } return &azCli{ - userAgent: azdinternal.FormatUserAgent(nil), + userAgent: azdinternal.MakeUserAgentString(""), enableDebug: args.EnableDebug, enableTelemetry: args.EnableTelemetry, runWithResultFn: args.RunWithResultFn, @@ -327,8 +327,8 @@ func (cli *azCli) CheckInstalled(_ context.Context) (bool, error) { // SetUserAgent sets the user agent that's sent with each call to the Azure // CLI via the `AZURE_HTTP_USER_AGENT` environment variable. -func (cli *azCli) SetUserAgent(userAgentData []string) { - cli.userAgent = azdinternal.FormatUserAgent(userAgentData) +func (cli *azCli) SetUserAgent(userAgent string) { + cli.userAgent = userAgent } func (cli *azCli) UserAgent() string { diff --git a/cli/azd/pkg/tools/azcli_test.go b/cli/azd/pkg/tools/azcli_test.go index 84a0f102c88..08f1d381095 100644 --- a/cli/azd/pkg/tools/azcli_test.go +++ b/cli/azd/pkg/tools/azcli_test.go @@ -11,6 +11,7 @@ import ( "regexp" "testing" + "github.com/azure/azure-dev/cli/azd/internal" azdinternal "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/executil" "github.com/stretchr/testify/assert" @@ -42,7 +43,7 @@ func TestAzCli(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{ - fmt.Sprintf("AZURE_HTTP_USER_AGENT=%s", azdinternal.FormatUserAgent(nil)), + fmt.Sprintf("AZURE_HTTP_USER_AGENT=%s", azdinternal.MakeUserAgentString("")), }, env) require.Equal(t, []string{"hello", "--debug"}, commandArgs) @@ -71,7 +72,7 @@ func TestAzCli(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{ - "AZURE_HTTP_USER_AGENT=azdev/0.0.0-dev.0", + fmt.Sprintf("AZURE_HTTP_USER_AGENT=%s", azdinternal.MakeUserAgentString("")), "AZURE_CORE_COLLECT_TELEMETRY=no", }, env) @@ -85,9 +86,7 @@ func TestAZCLIWithUserAgent(t *testing.T) { EnableDebug: true, }) - tempAZCLI.SetUserAgent([]string{ - "AZTesting=yes", - }) + tempAZCLI.SetUserAgent(internal.MakeUserAgentString("AZTesting=yes")) azcli := tempAZCLI.(*azCli) @@ -95,12 +94,7 @@ func TestAZCLIWithUserAgent(t *testing.T) { userAgent := runAndCaptureUserAgent(t, azcli, account.Id) require.Contains(t, userAgent, "AZTesting=yes") - - // now disable telemetry, which doesn't appear to affect our user agent - azcli.enableTelemetry = false - - userAgentWithTelemetryDisabled := runAndCaptureUserAgent(t, azcli, account.Id) - require.Contains(t, userAgentWithTelemetryDisabled, "AZTesting=yes") + require.Contains(t, userAgent, "azdev") } func mustGetDefaultAccount(t *testing.T, azcli AzCli) AzCliSubscriptionInfo {