diff --git a/cli/azd/emulator/account.go b/cli/azd/emulator/account.go new file mode 100644 index 00000000000..08cab4bc6f4 --- /dev/null +++ b/cli/azd/emulator/account.go @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package emulator + +import ( + + // Importing for infrastructure provider plugin registrations + + "context" + "encoding/json" + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/cloud" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/spf13/cobra" +) + +func accountCommands() *cobra.Command { + accountGroup := &cobra.Command{ + Use: "account", + } + accountGroup.AddCommand(showCmd()) + accountGroup.AddCommand(accessTokenCmd()) + return accountGroup +} + +type accountShowOutput struct { + Id string `json:"id"` + TenantId string `json:"tenantId"` +} + +func showCmd() *cobra.Command { + showCmd := &cobra.Command{ + Use: "show", + RunE: func(cmd *cobra.Command, args []string) error { + + ctx := context.Background() + rootContainer := ioc.NewNestedContainer(nil) + ioc.RegisterInstance(rootContainer, ctx) + registerCommonDependencies(rootContainer) + + var subManager *account.SubscriptionsManager + if err := rootContainer.Resolve(&subManager); err != nil { + return err + } + var env *environment.Environment + if err := rootContainer.Resolve(&env); err != nil { + return err + } + + subId := env.GetSubscriptionId() + tenantId, err := subManager.LookupTenant(ctx, subId) + if err != nil { + return err + } + o := accountShowOutput{ + Id: subId, + TenantId: tenantId, + } + output, err := json.Marshal(o) + if err != nil { + return err + } + fmt.Println(string(output)) + return nil + }, + } + showCmd.Flags().StringP("output", "o", "", "Output format") + return showCmd +} + +func LoginScopes(cloud *cloud.Cloud) []string { + resourceManagerUrl := cloud.Configuration.Services[azcloud.ResourceManager].Endpoint + return []string{ + fmt.Sprintf("%s//.default", resourceManagerUrl), + } +} + +func accessTokenCmd() *cobra.Command { + accessTokenCmd := &cobra.Command{ + Use: "get-access-token", + RunE: func(cmd *cobra.Command, args []string) error { + + ctx := context.Background() + rootContainer := ioc.NewNestedContainer(nil) + ioc.RegisterInstance(rootContainer, ctx) + registerCommonDependencies(rootContainer) + + var cloud *cloud.Cloud + if err := rootContainer.Resolve(&cloud); err != nil { + return err + } + var envResolver environment.EnvironmentResolver + if err := rootContainer.Resolve(&envResolver); err != nil { + return err + } + var subResolver account.SubscriptionTenantResolver + if err := rootContainer.Resolve(&subResolver); err != nil { + return err + } + var credentialProvider CredentialProviderFn + if err := rootContainer.Resolve(&credentialProvider); err != nil { + return err + } + + scopes, err := cmd.Flags().GetStringArray("scope") + if err != nil { + return err + } + if len(scopes) == 0 { + scopes = auth.LoginScopes(cloud) + } + + var cred azcore.TokenCredential + tenantId := cmd.Flag("tenant").Value.String() + // 2) From azd env + if tenantId == "" { + tenantIdFromAzdEnv, err := getTenantIdFromAzdEnv(ctx, envResolver, subResolver) + if err != nil { + return err + } + tenantId = tenantIdFromAzdEnv + } + // 3) From system env + if tenantId == "" { + tenantIdFromSysEnv, err := getTenantIdFromEnv(ctx, subResolver) + if err != nil { + return err + } + tenantId = tenantIdFromSysEnv + } + + // If tenantId is still empty, the fallback is to use current logged in user's home-tenant id. + cred, err = credentialProvider(ctx, &auth.CredentialForCurrentUserOptions{ + NoPrompt: true, + TenantID: tenantId, + }) + if err != nil { + return err + } + + token, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: scopes, + }) + if err != nil { + return fmt.Errorf("fetching token: %w", err) + } + + type azEmulateAuthTokenResult struct { + AccessToken string `json:"accessToken"` + ExpiresOn string `json:"expiresOn"` + } + res := azEmulateAuthTokenResult{ + AccessToken: token.Token, + ExpiresOn: token.ExpiresOn.Format("2006-01-02 15:04:05.000000"), + } + output, err := json.Marshal(res) + if err != nil { + return err + } + fmt.Println(string(output)) + return nil + }, + } + accessTokenCmd.Flags().StringP("output", "o", "", "Output format.") + accessTokenCmd.Flags().StringArray( + "scope", []string{}, "Space-separated AAD scopes in AAD v2.0. Default to Azure Resource Manager") + accessTokenCmd.Flags().StringP("tenant", "t", "", + "Tenant ID for which the token is acquired. Only available for user"+ + " and service principal account, not for MSI or Cloud Shell account.") + return accessTokenCmd +} + +func getTenantIdFromAzdEnv( + ctx context.Context, + envResolver environment.EnvironmentResolver, + subResolver account.SubscriptionTenantResolver) (tenantId string, err error) { + azdEnv, err := envResolver(ctx) + if err != nil { + // No azd env, return empty tenantId + return tenantId, nil + } + + subIdAtAzdEnv := azdEnv.GetSubscriptionId() + if subIdAtAzdEnv == "" { + // azd env found, but missing or empty subscriptionID + return tenantId, nil + } + + tenantId, err = subResolver.LookupTenant(ctx, subIdAtAzdEnv) + if err != nil { + return tenantId, fmt.Errorf( + "resolving the Azure Directory from azd environment (%s): %w", + azdEnv.Name(), + err) + } + + return tenantId, nil +} + +func getTenantIdFromEnv( + ctx context.Context, + subResolver account.SubscriptionTenantResolver) (tenantId string, err error) { + + subIdAtSysEnv, found := os.LookupEnv(environment.SubscriptionIdEnvVarName) + if !found { + // no env var from system + return tenantId, nil + } + + tenantId, err = subResolver.LookupTenant(ctx, subIdAtSysEnv) + if err != nil { + return tenantId, fmt.Errorf( + "resolving the Azure Directory from system environment (%s): %w", environment.SubscriptionIdEnvVarName, err) + } + + return tenantId, nil +} diff --git a/cli/azd/emulator/container.go b/cli/azd/emulator/container.go new file mode 100644 index 00000000000..c0c36d7a86e --- /dev/null +++ b/cli/azd/emulator/container.go @@ -0,0 +1,758 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package emulator + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/MakeNowJust/heredoc/v2" + "github.com/azure/azure-dev/cli/azd/cmd/middleware" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/repository" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/ai" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" + "github.com/azure/azure-dev/cli/azd/pkg/azd" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/cloud" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/containerapps" + "github.com/azure/azure-dev/cli/azd/pkg/devcenter" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/helm" + "github.com/azure/azure-dev/cli/azd/pkg/httputil" + "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/keyvault" + "github.com/azure/azure-dev/cli/azd/pkg/kubelogin" + "github.com/azure/azure-dev/cli/azd/pkg/kustomize" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/platform" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/prompt" + "github.com/azure/azure-dev/cli/azd/pkg/state" + "github.com/azure/azure-dev/cli/azd/pkg/templates" + "github.com/azure/azure-dev/cli/azd/pkg/tools/azcli" + "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" + "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" + "github.com/azure/azure-dev/cli/azd/pkg/tools/git" + "github.com/azure/azure-dev/cli/azd/pkg/tools/github" + "github.com/azure/azure-dev/cli/azd/pkg/tools/javac" + "github.com/azure/azure-dev/cli/azd/pkg/tools/kubectl" + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" + "github.com/azure/azure-dev/cli/azd/pkg/tools/npm" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" + "github.com/azure/azure-dev/cli/azd/pkg/tools/swa" + "github.com/azure/azure-dev/cli/azd/pkg/workflow" + "github.com/benbjohnson/clock" + "github.com/mattn/go-colorable" + "github.com/spf13/cobra" + "golang.org/x/exp/slices" +) + +func createHttpClient() *http.Client { + return http.DefaultClient +} + +func createClock() clock.Clock { + return clock.New() +} + +type CredentialProviderFn func(context.Context, *auth.CredentialForCurrentUserOptions) (azcore.TokenCredential, error) + +type CmdAnnotations map[string]string + +// Registers common Azd dependencies +func registerCommonDependencies(container *ioc.NestedContainer) { + // Core bootstrapping registrations + ioc.RegisterInstance(container, container) + + // Standard Registrations + container.MustRegisterTransient(output.GetCommandFormatter) + + container.MustRegisterScoped(func( + rootOptions *internal.GlobalCommandOptions, + formatter output.Formatter, + cmd *cobra.Command) input.Console { + writer := cmd.OutOrStdout() + // When using JSON formatting, we want to ensure we always write messages from the console to stderr. + if formatter != nil && formatter.Kind() == output.JsonFormat { + writer = cmd.ErrOrStderr() + } + + if os.Getenv("NO_COLOR") != "" { + writer = colorable.NewNonColorable(writer) + } + + isTerminal := cmd.OutOrStdout() == os.Stdout && + cmd.InOrStdin() == os.Stdin && input.IsTerminal(os.Stdout.Fd(), os.Stdin.Fd()) + + return input.NewConsole(rootOptions.NoPrompt, isTerminal, input.Writers{Output: writer}, input.ConsoleHandles{ + Stdin: cmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }, formatter, nil) + }) + + container.MustRegisterSingleton( + func(console input.Console, rootOptions *internal.GlobalCommandOptions) exec.CommandRunner { + return exec.NewCommandRunner( + &exec.RunnerOptions{ + Stdin: console.Handles().Stdin, + Stdout: console.Handles().Stdout, + Stderr: console.Handles().Stderr, + DebugLogging: rootOptions.EnableDebugLogging, + }) + }, + ) + + client := createHttpClient() + ioc.RegisterInstance[httputil.HttpClient](container, client) + ioc.RegisterInstance[auth.HttpClient](container, client) + container.MustRegisterSingleton(func() httputil.UserAgent { + return httputil.UserAgent(internal.UserAgent()) + }) + + // Auth + container.MustRegisterSingleton(auth.NewLoggedInGuard) + container.MustRegisterSingleton(auth.NewMultiTenantCredentialProvider) + container.MustRegisterSingleton(func(mgr *auth.Manager) CredentialProviderFn { + return mgr.CredentialForCurrentUser + }) + + container.MustRegisterSingleton(func(console input.Console) io.Writer { + writer := console.Handles().Stdout + + if os.Getenv("NO_COLOR") != "" { + writer = colorable.NewNonColorable(writer) + } + + return writer + }) + + container.MustRegisterScoped(func(cmd *cobra.Command) internal.EnvFlag { + // The env flag `-e, --environment` is available on most azd commands but not all + // This is typically used to override the default environment and is used for bootstrapping other components + // such as the azd environment. + // If the flag is not available, don't panic, just return an empty string which will then allow for our default + // semantics to follow. + envValue, err := cmd.Flags().GetString(internal.EnvironmentNameFlagName) + if err != nil { + log.Printf("'%s'command asked for envFlag, but envFlag was not included in cmd.Flags().", cmd.CommandPath()) + envValue = "" + } + + return internal.EnvFlag{EnvironmentName: envValue} + }) + + container.MustRegisterSingleton(func(cmd *cobra.Command) CmdAnnotations { + return cmd.Annotations + }) + + // Azd Context + container.MustRegisterSingleton(func(lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext]) (*azdcontext.AzdContext, error) { + return lazyAzdContext.GetValue() + }) + + // Lazy loads the Azd context after the azure.yaml file becomes available + container.MustRegisterSingleton(func() *lazy.Lazy[*azdcontext.AzdContext] { + return lazy.NewLazy(azdcontext.NewAzdContext) + }) + + // Register an initialized environment based on the specified environment flag, or the default environment. + // Note that referencing an *environment.Environment in a command automatically triggers a UI prompt if the + // environment is uninitialized or a default environment doesn't yet exist. + container.MustRegisterScoped( + func(ctx context.Context, + azdContext *azdcontext.AzdContext, + envManager environment.Manager, + lazyEnv *lazy.Lazy[*environment.Environment], + envFlags internal.EnvFlag, + ) (*environment.Environment, error) { + if azdContext == nil { + return nil, azdcontext.ErrNoProject + } + + environmentName := envFlags.EnvironmentName + var err error + + env, err := envManager.LoadOrInitInteractive(ctx, environmentName) + if err != nil { + return nil, fmt.Errorf("loading environment: %w", err) + } + + // Reset lazy env value after loading or creating environment + // This allows any previous lazy instances (such as hooks) to now point to the same instance + lazyEnv.SetValue(env) + + return env, nil + }, + ) + container.MustRegisterScoped(func(lazyEnvManager *lazy.Lazy[environment.Manager]) environment.EnvironmentResolver { + return func(ctx context.Context) (*environment.Environment, error) { + azdCtx, err := azdcontext.NewAzdContext() + if err != nil { + return nil, err + } + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + + // We need to lazy load the environment manager since it depends on azd context + envManager, err := lazyEnvManager.GetValue() + if err != nil { + return nil, err + } + + return envManager.Get(ctx, defaultEnv) + } + }) + + container.MustRegisterSingleton(environment.NewLocalFileDataStore) + container.MustRegisterSingleton(environment.NewManager) + + container.MustRegisterSingleton(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[environment.LocalDataStore] { + return lazy.NewLazy(func() (environment.LocalDataStore, error) { + var localDataStore environment.LocalDataStore + err := serviceLocator.Resolve(&localDataStore) + if err != nil { + return nil, err + } + + return localDataStore, nil + }) + }) + + // Environment manager depends on azd context + container.MustRegisterSingleton( + func(serviceLocator ioc.ServiceLocator, azdContext *lazy.Lazy[*azdcontext.AzdContext]) *lazy.Lazy[environment.Manager] { + return lazy.NewLazy(func() (environment.Manager, error) { + azdCtx, err := azdContext.GetValue() + if err != nil { + return nil, err + } + + // Register the Azd context instance as a singleton in the container if now available + ioc.RegisterInstance(container, azdCtx) + + var envManager environment.Manager + err = serviceLocator.Resolve(&envManager) + if err != nil { + return nil, err + } + + return envManager, nil + }) + }, + ) + + container.MustRegisterSingleton(func( + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], + userConfigManager config.UserConfigManager, + ) (*state.RemoteConfig, error) { + var remoteStateConfig *state.RemoteConfig + + userConfig, err := userConfigManager.Load() + if err != nil { + return nil, fmt.Errorf("loading user config: %w", err) + } + + // The project config may not be available yet + // Ex) Within init phase of fingerprinting + projectConfig, _ := lazyProjectConfig.GetValue() + + // Lookup remote state config in the following precedence: + // 1. Project azure.yaml + // 2. User configuration + if projectConfig != nil && projectConfig.State != nil && projectConfig.State.Remote != nil { + remoteStateConfig = projectConfig.State.Remote + } else { + if _, err := userConfig.GetSection("state.remote", &remoteStateConfig); err != nil { + return nil, fmt.Errorf("getting remote state config: %w", err) + } + } + + return remoteStateConfig, nil + }) + + // Lazy loads an existing environment, erroring out if not available + // One can repeatedly call GetValue to wait until the environment is available. + container.MustRegisterScoped( + func( + ctx context.Context, + lazyEnvManager *lazy.Lazy[environment.Manager], + lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext], + envFlags internal.EnvFlag, + ) *lazy.Lazy[*environment.Environment] { + return lazy.NewLazy(func() (*environment.Environment, error) { + azdCtx, err := lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + environmentName := envFlags.EnvironmentName + if environmentName == "" { + environmentName, err = azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + } + + envManager, err := lazyEnvManager.GetValue() + if err != nil { + return nil, err + } + + env, err := envManager.Get(ctx, environmentName) + if err != nil { + return nil, err + } + + return env, err + }) + }, + ) + + // Project Config + container.MustRegisterScoped( + func(lazyConfig *lazy.Lazy[*project.ProjectConfig]) (*project.ProjectConfig, error) { + return lazyConfig.GetValue() + }, + ) + + // Lazy loads the project config from the Azd Context when it becomes available + container.MustRegisterScoped( + func( + ctx context.Context, + lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext], + ) *lazy.Lazy[*project.ProjectConfig] { + return lazy.NewLazy(func() (*project.ProjectConfig, error) { + azdCtx, err := lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + projectConfig, err := project.Load(ctx, azdCtx.ProjectPath()) + if err != nil { + return nil, err + } + + return projectConfig, nil + }) + }, + ) + + container.MustRegisterSingleton(func( + ctx context.Context, + userConfigManager config.UserConfigManager, + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], + lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext], + lazyLocalEnvStore *lazy.Lazy[environment.LocalDataStore], + ) (*cloud.Cloud, error) { + + // Precedence for cloud configuration: + // 1. Local environment config (.azure//config.json) + // 2. Project config (azure.yaml) + // 3. User config (~/.azure/config.json) + // Default if no cloud configured: Azure Public Cloud + + validClouds := fmt.Sprintf( + "Valid cloud names are '%s', '%s', '%s'.", + cloud.AzurePublicName, + cloud.AzureChinaCloudName, + cloud.AzureUSGovernmentName, + ) + + // Local Environment Configuration (.azure//config.json) + localEnvStore, _ := lazyLocalEnvStore.GetValue() + if azdCtx, err := lazyAzdContext.GetValue(); err == nil { + if azdCtx != nil && localEnvStore != nil { + if defaultEnvName, err := azdCtx.GetDefaultEnvironmentName(); err == nil { + if env, err := localEnvStore.Get(ctx, defaultEnvName); err == nil { + if cloudConfigurationNode, exists := env.Config.Get(cloud.ConfigPath); exists { + if value, err := cloud.ParseCloudConfig(cloudConfigurationNode); err == nil { + cloudConfig, err := cloud.NewCloud(value) + if err == nil { + return cloudConfig, nil + } + + return nil, &internal.ErrorWithSuggestion{ + Err: err, + Suggestion: fmt.Sprintf( + "Set the cloud configuration by editing the 'cloud' node in the config.json file for the %s environment\n%s", + defaultEnvName, + validClouds, + ), + } + } + } + } + } + } + } + + // Project Configuration (azure.yaml) + projConfig, err := lazyProjectConfig.GetValue() + if err == nil && projConfig != nil && projConfig.Cloud != nil { + if value, err := cloud.ParseCloudConfig(projConfig.Cloud); err == nil { + if cloudConfig, err := cloud.ParseCloudConfig(value); err == nil { + if cloud, err := cloud.NewCloud(cloudConfig); err == nil { + return cloud, nil + } else { + return nil, &internal.ErrorWithSuggestion{ + Err: err, + //nolint:lll + Suggestion: fmt.Sprintf("Set the cloud configuration by editing the 'cloud' node in the project YAML file\n%s", validClouds), + } + } + } + } + } + + // User Configuration (~/.azure/config.json) + if azdConfig, err := userConfigManager.Load(); err == nil { + if cloudConfigNode, exists := azdConfig.Get(cloud.ConfigPath); exists { + if value, err := cloud.ParseCloudConfig(cloudConfigNode); err == nil { + if cloud, err := cloud.NewCloud(value); err == nil { + return cloud, nil + } else { + return nil, &internal.ErrorWithSuggestion{ + Err: err, + Suggestion: fmt.Sprintf("Set the cloud configuration using 'azd config set cloud.name '.\n%s", validClouds), + } + } + } + } + } + + return cloud.NewCloud(&cloud.Config{Name: cloud.AzurePublicName}) + }) + + container.MustRegisterSingleton(func(cloud *cloud.Cloud) cloud.PortalUrlBase { + return cloud.PortalUrlBase + }) + + container.MustRegisterSingleton(func( + httpClient httputil.HttpClient, + userAgent httputil.UserAgent, + cloud *cloud.Cloud, + ) *azsdk.ClientOptionsBuilderFactory { + return azsdk.NewClientOptionsBuilderFactory(httpClient, string(userAgent), cloud) + }) + + container.MustRegisterSingleton(func( + clientOptionsBuilderFactory *azsdk.ClientOptionsBuilderFactory, + ) *azcore.ClientOptions { + return clientOptionsBuilderFactory.NewClientOptionsBuilder(). + WithPerCallPolicy(azsdk.NewMsCorrelationPolicy()). + BuildCoreClientOptions() + }) + + container.MustRegisterSingleton(func( + clientOptionsBuilderFactory *azsdk.ClientOptionsBuilderFactory, + ) *arm.ClientOptions { + return clientOptionsBuilderFactory.NewClientOptionsBuilder(). + WithPerCallPolicy(azsdk.NewMsCorrelationPolicy()). + BuildArmClientOptions() + }) + + container.MustRegisterSingleton(templates.NewTemplateManager) + container.MustRegisterSingleton(templates.NewSourceManager) + container.MustRegisterScoped(project.NewResourceManager) + container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[project.ResourceManager] { + return lazy.NewLazy(func() (project.ResourceManager, error) { + var resourceManager project.ResourceManager + err := serviceLocator.Resolve(&resourceManager) + + return resourceManager, err + }) + }) + container.MustRegisterScoped(project.NewProjectManager) + // Currently caches manifest across command executions + container.MustRegisterSingleton(project.NewDotNetImporter) + container.MustRegisterScoped(project.NewImportManager) + container.MustRegisterScoped(project.NewServiceManager) + + // Even though the service manager is scoped based on its use of environment we can still + // register its internal cache as a singleton to ensure operation caching is consistent across all instances + container.MustRegisterSingleton(func() project.ServiceOperationCache { + return project.ServiceOperationCache{} + }) + + container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[project.ServiceManager] { + return lazy.NewLazy(func() (project.ServiceManager, error) { + var serviceManager project.ServiceManager + err := serviceLocator.Resolve(&serviceManager) + + return serviceManager, err + }) + }) + container.MustRegisterSingleton(repository.NewInitializer) + container.MustRegisterSingleton(alpha.NewFeaturesManager) + container.MustRegisterSingleton(config.NewUserConfigManager) + container.MustRegisterSingleton(config.NewManager) + container.MustRegisterSingleton(config.NewFileConfigManager) + container.MustRegisterScoped(func() (auth.ExternalAuthConfiguration, error) { + cert := os.Getenv("AZD_AUTH_CERT") + endpoint := os.Getenv("AZD_AUTH_ENDPOINT") + key := os.Getenv("AZD_AUTH_KEY") + + client := &http.Client{} + if len(cert) > 0 { + transport, err := httputil.TlsEnabledTransport(cert) + if err != nil { + return auth.ExternalAuthConfiguration{}, + fmt.Errorf("parsing AZD_AUTH_CERT: %w", err) + } + client.Transport = transport + + endpointUrl, err := url.Parse(endpoint) + if err != nil { + return auth.ExternalAuthConfiguration{}, + fmt.Errorf("invalid AZD_AUTH_ENDPOINT value '%s': %w", endpoint, err) + } + + if endpointUrl.Scheme != "https" { + return auth.ExternalAuthConfiguration{}, + fmt.Errorf("invalid AZD_AUTH_ENDPOINT value '%s': scheme must be 'https' when certificate is provided", + endpoint) + } + } + return auth.ExternalAuthConfiguration{ + Endpoint: endpoint, + Client: client, + Key: key, + }, nil + }) + container.MustRegisterScoped(auth.NewManager) + container.MustRegisterSingleton(azcli.NewUserProfileService) + container.MustRegisterSingleton(account.NewSubscriptionsService) + container.MustRegisterSingleton(account.NewManager) + container.MustRegisterSingleton(account.NewSubscriptionsManager) + container.MustRegisterSingleton(account.NewSubscriptionCredentialProvider) + container.MustRegisterSingleton(azcli.NewManagedClustersService) + container.MustRegisterSingleton(azcli.NewAdService) + container.MustRegisterSingleton(azcli.NewContainerRegistryService) + container.MustRegisterSingleton(containerapps.NewContainerAppService) + container.MustRegisterSingleton(keyvault.NewKeyVaultService) + container.MustRegisterScoped(project.NewContainerHelper) + container.MustRegisterSingleton(azcli.NewSpringService) + + container.MustRegisterSingleton(func(subManager *account.SubscriptionsManager) account.SubscriptionTenantResolver { + return subManager + }) + + container.MustRegisterSingleton(func() *internal.GlobalCommandOptions { + return &internal.GlobalCommandOptions{} + }) + + container.MustRegisterSingleton(func() *cobra.Command { + return &cobra.Command{} + }) + + // Tools + container.MustRegisterSingleton(func( + rootOptions *internal.GlobalCommandOptions, + credentialProvider account.SubscriptionCredentialProvider, + httpClient httputil.HttpClient, + armClientOptions *arm.ClientOptions, + ) azcli.AzCli { + return azcli.NewAzCli( + credentialProvider, + httpClient, + azcli.NewAzCliArgs{ + EnableDebug: rootOptions.EnableDebugLogging, + EnableTelemetry: rootOptions.EnableTelemetry, + }, + armClientOptions, + ) + }) + container.MustRegisterSingleton(azapi.NewDeployments) + container.MustRegisterSingleton(azapi.NewDeploymentOperations) + container.MustRegisterSingleton(docker.NewDocker) + container.MustRegisterSingleton(dotnet.NewDotNetCli) + container.MustRegisterSingleton(git.NewGitCli) + container.MustRegisterSingleton(github.NewGitHubCli) + container.MustRegisterSingleton(javac.NewCli) + container.MustRegisterSingleton(kubectl.NewKubectl) + container.MustRegisterSingleton(maven.NewMavenCli) + container.MustRegisterSingleton(kubelogin.NewCli) + container.MustRegisterSingleton(helm.NewCli) + container.MustRegisterSingleton(kustomize.NewCli) + container.MustRegisterSingleton(npm.NewNpmCli) + container.MustRegisterSingleton(python.NewPythonCli) + container.MustRegisterSingleton(swa.NewSwaCli) + container.MustRegisterScoped(ai.NewPythonBridge) + container.MustRegisterScoped(project.NewAiHelper) + + // Provisioning + container.MustRegisterSingleton(infra.NewAzureResourceManager) + container.MustRegisterScoped(provisioning.NewManager) + container.MustRegisterScoped(provisioning.NewPrincipalIdProvider) + container.MustRegisterScoped(prompt.NewDefaultPrompter) + + // Other + container.MustRegisterSingleton(createClock) + + // Service Targets + serviceTargetMap := map[project.ServiceTargetKind]any{ + project.NonSpecifiedTarget: project.NewAppServiceTarget, + project.AppServiceTarget: project.NewAppServiceTarget, + project.AzureFunctionTarget: project.NewFunctionAppTarget, + project.ContainerAppTarget: project.NewContainerAppTarget, + project.StaticWebAppTarget: project.NewStaticWebAppTarget, + project.AksTarget: project.NewAksTarget, + project.SpringAppTarget: project.NewSpringAppTarget, + project.DotNetContainerAppTarget: project.NewDotNetContainerAppTarget, + project.AiEndpointTarget: project.NewAiEndpointTarget, + } + + for target, constructor := range serviceTargetMap { + container.MustRegisterNamedScoped(string(target), constructor) + } + + // Languages + frameworkServiceMap := map[project.ServiceLanguageKind]any{ + project.ServiceLanguageNone: project.NewNoOpProject, + project.ServiceLanguageDotNet: project.NewDotNetProject, + project.ServiceLanguageCsharp: project.NewDotNetProject, + project.ServiceLanguageFsharp: project.NewDotNetProject, + project.ServiceLanguagePython: project.NewPythonProject, + project.ServiceLanguageJavaScript: project.NewNpmProject, + project.ServiceLanguageTypeScript: project.NewNpmProject, + project.ServiceLanguageJava: project.NewMavenProject, + project.ServiceLanguageDocker: project.NewDockerProject, + project.ServiceLanguageSwa: project.NewSwaProject, + } + + for language, constructor := range frameworkServiceMap { + container.MustRegisterNamedScoped(string(language), constructor) + } + + container.MustRegisterNamedScoped(string(project.ServiceLanguageDocker), project.NewDockerProjectAsFrameworkService) + + // Platform configuration + container.MustRegisterSingleton(func(lazyConfig *lazy.Lazy[*platform.Config]) (*platform.Config, error) { + return lazyConfig.GetValue() + }) + + container.MustRegisterSingleton(func( + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], + userConfigManager config.UserConfigManager, + ) *lazy.Lazy[*platform.Config] { + return lazy.NewLazy(func() (*platform.Config, error) { + // First check `azure.yaml` for platform configuration section + projectConfig, err := lazyProjectConfig.GetValue() + if err == nil && projectConfig != nil && projectConfig.Platform != nil { + return projectConfig.Platform, nil + } + + // Fallback to global user configuration + config, err := userConfigManager.Load() + if err != nil { + return nil, fmt.Errorf("loading user config: %w", err) + } + + var platformConfig *platform.Config + _, err = config.GetSection("platform", &platformConfig) + if err != nil { + return nil, fmt.Errorf("getting platform config: %w", err) + } + + // If we still don't have a platform configuration, check the OS environment + // We check the OS environment instead of AZD environment because the global platform configuration + // cannot be known at this time in the azd bootstrapping process. + if platformConfig == nil { + if envPlatformType, has := os.LookupEnv(environment.PlatformTypeEnvVarName); has { + platformConfig = &platform.Config{ + Type: platform.PlatformKind(envPlatformType), + } + } + } + + if platformConfig == nil || platformConfig.Type == "" { + return nil, platform.ErrPlatformConfigNotFound + } + + // Validate platform type + supportedPlatformKinds := []string{ + string(devcenter.PlatformKindDevCenter), + string(azd.PlatformKindDefault), + } + if !slices.Contains(supportedPlatformKinds, string(platformConfig.Type)) { + return nil, fmt.Errorf( + heredoc.Doc(`platform type '%s' is not supported. Valid values are '%s'. + Run %s to set or %s to reset. (%w)`), + platformConfig.Type, + strings.Join(supportedPlatformKinds, ","), + output.WithBackticks("azd config set platform.type "), + output.WithBackticks("azd config unset platform.type"), + platform.ErrPlatformNotSupported, + ) + } + + return platformConfig, nil + }) + }) + + // Platform Providers + platformProviderMap := map[platform.PlatformKind]any{ + azd.PlatformKindDefault: azd.NewDefaultPlatform, + devcenter.PlatformKindDevCenter: devcenter.NewPlatform, + } + + for provider, constructor := range platformProviderMap { + platformName := fmt.Sprintf("%s-platform", provider) + container.MustRegisterNamedSingleton(platformName, constructor) + } + + container.MustRegisterSingleton(func(s ioc.ServiceLocator) (workflow.AzdCommandRunner, error) { + var rootCmd *cobra.Command + if err := s.ResolveNamed("root-cmd", &rootCmd); err != nil { + return nil, err + } + return &workflowCmdAdapter{cmd: rootCmd}, nil + + }) + container.MustRegisterSingleton(workflow.NewRunner) +} + +// workflowCmdAdapter adapts a cobra command to the workflow.AzdCommandRunner interface +type workflowCmdAdapter struct { + cmd *cobra.Command +} + +func (w *workflowCmdAdapter) SetArgs(args []string) { + w.cmd.SetArgs(args) +} + +// ExecuteContext implements workflow.AzdCommandRunner +func (w *workflowCmdAdapter) ExecuteContext(ctx context.Context) error { + childCtx := middleware.WithChildAction(ctx) + return w.cmd.ExecuteContext(childCtx) +} + +// ArmClientInitializer is a function definition for all Azure SDK ARM Client +type ArmClientInitializer[T comparable] func( + subscriptionId string, + credentials azcore.TokenCredential, + armClientOptions *arm.ClientOptions, +) (T, error) diff --git a/cli/azd/emulator/root.go b/cli/azd/emulator/root.go new file mode 100644 index 00000000000..5a6524092a7 --- /dev/null +++ b/cli/azd/emulator/root.go @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package emulator + +import ( + + // Importing for infrastructure provider plugin registrations + + "github.com/spf13/cobra" +) + +func NewRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "az", + Short: "Emulation mode for Azure CLI", + } + rootCmd.AddCommand(versionCmd()) + rootCmd.AddCommand(accountCommands()) + return rootCmd +} diff --git a/cli/azd/emulator/version.go b/cli/azd/emulator/version.go new file mode 100644 index 00000000000..ecb9ab3786a --- /dev/null +++ b/cli/azd/emulator/version.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package emulator + +import ( + + // Importing for infrastructure provider plugin registrations + + "fmt" + + "github.com/spf13/cobra" +) + +const ( + emulatedAzVersion = `{"azure-cli": "2.61.0","azure-cli-core": "2.61.0","azure-cli-telemetry": "1.1.0","extensions": {}}` +) + +func versionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(emulatedAzVersion) + }, + } + cmd.Flags().StringP("output", "o", "", "Output format") + return cmd +} diff --git a/cli/azd/main.go b/cli/azd/main.go index d65c7f5160d..f6c0cbe5699 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -24,6 +24,7 @@ import ( azcorelog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log" "github.com/azure/azure-dev/cli/azd/cmd" + "github.com/azure/azure-dev/cli/azd/emulator" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/telemetry" "github.com/azure/azure-dev/cli/azd/pkg/config" @@ -55,6 +56,15 @@ func main() { log.Printf("azd version: %s", internal.Version) + if osutil.IsAzEmulator() { + log.Println("Running az emulation") + emulateErr := emulator.NewRootCmd().ExecuteContext(ctx) + if emulateErr != nil { + os.Exit(1) + } + os.Exit(0) + } + ts := telemetry.GetTelemetrySystem() latest := make(chan semver.Version) diff --git a/cli/azd/pkg/osutil/env.go b/cli/azd/pkg/osutil/env.go index 010a7c358ae..3bc5a0e4c58 100644 --- a/cli/azd/pkg/osutil/env.go +++ b/cli/azd/pkg/osutil/env.go @@ -4,6 +4,7 @@ package osutil import ( + "fmt" "os" "runtime" ) @@ -25,3 +26,18 @@ func GetNewLineSeparator() string { return "\n" } } + +const ( + emulatorEnvName string = "AZURE_AZ_EMULATOR" +) + +// IsAzEmulator returns true if the AZURE_AZ_EMULATOR environment variable is defined. +// It does not matter the value of the environment variable, as long as it is defined. +func IsAzEmulator() bool { + _, emulateEnvVarDefined := os.LookupEnv(emulatorEnvName) + return emulateEnvVarDefined +} + +func AzEmulateKey() string { + return fmt.Sprintf("%s=%s", emulatorEnvName, "true") +} diff --git a/cli/azd/pkg/tools/terraform/az_emulator.go b/cli/azd/pkg/tools/terraform/az_emulator.go new file mode 100644 index 00000000000..066a7fef534 --- /dev/null +++ b/cli/azd/pkg/tools/terraform/az_emulator.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package terraform + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" +) + +// creates a copy of azd binary and renames it to az and returns the path to it +func emulateAzFromPath() (string, error) { + path, err := os.Executable() + if err != nil { + return "", fmt.Errorf("azd binary not found in PATH: %w", err) + } + azdConfigPath, err := config.GetUserConfigDir() + if err != nil { + return "", fmt.Errorf("could not get user config dir: %w", err) + } + emuPath, err := os.MkdirTemp(filepath.Join(azdConfigPath, "bin"), "azEmulate") + if err != nil { + return "", fmt.Errorf("could not create directory for azEmulate: %w", err) + } + err = os.MkdirAll(emuPath, osutil.PermissionDirectoryOwnerOnly) + if err != nil { + return "", fmt.Errorf("could not create directory for azEmulate: %w", err) + } + emuPath = filepath.Join(emuPath, strings.ReplaceAll(filepath.Base(path), filepath.Base(path), "az")) + + srcFile, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("opening src: %w", err) + } + defer srcFile.Close() + + destFile, err := os.OpenFile(emuPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("creating dest: %w", err) + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return "", fmt.Errorf("copying binary: %w", err) + } + + return filepath.Dir(emuPath), nil +} diff --git a/cli/azd/pkg/tools/terraform/terraform.go b/cli/azd/pkg/tools/terraform/terraform.go index ab1d854d8e7..897c1951212 100644 --- a/cli/azd/pkg/tools/terraform/terraform.go +++ b/cli/azd/pkg/tools/terraform/terraform.go @@ -8,8 +8,10 @@ import ( "encoding/json" "fmt" "log" + "os" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/blang/semver/v4" ) @@ -97,7 +99,7 @@ func (cli *terraformCli) runCommand(ctx context.Context, args ...string) (exec.R NewRunArgs("terraform", args...). WithEnv(cli.env) - return cli.commandRunner.Run(ctx, runArgs) + return withAzEmulator(ctx, cli.commandRunner, runArgs) } func (cli *terraformCli) runInteractive(ctx context.Context, args ...string) (exec.RunResult, error) { @@ -106,7 +108,20 @@ func (cli *terraformCli) runInteractive(ctx context.Context, args ...string) (ex WithEnv(cli.env). WithInteractive(true) - return cli.commandRunner.Run(ctx, runArgs) + return withAzEmulator(ctx, cli.commandRunner, runArgs) +} + +func withAzEmulator(ctx context.Context, commandRunner exec.CommandRunner, runArgs exec.RunArgs) (exec.RunResult, error) { + azEmulatorPath, err := emulateAzFromPath() + if err != nil { + return exec.RunResult{}, fmt.Errorf("emulating az path: %w", err) + } + defer os.RemoveAll(azEmulatorPath) + runArgs.Env = append(runArgs.Env, + osutil.AzEmulateKey(), + fmt.Sprintf("PATH=%s", azEmulatorPath), + ) + return commandRunner.Run(ctx, runArgs) } func (cli *terraformCli) unmarshalCliVersion(ctx context.Context, component string) (string, error) { diff --git a/cli/azd/pkg/tools/terraform/terraform_test.go b/cli/azd/pkg/tools/terraform/terraform_test.go index d1fcac299ca..c8d114f6b8d 100644 --- a/cli/azd/pkg/tools/terraform/terraform_test.go +++ b/cli/azd/pkg/tools/terraform/terraform_test.go @@ -2,6 +2,8 @@ package terraform import ( "context" + "path/filepath" + "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/exec" @@ -11,16 +13,32 @@ import ( func Test_WithEnv(t *testing.T) { ran := false - expectedEnvVars := []string{"TF_DATA_DIR=MYDIR"} + expectedEnvVars := []string{"TF_DATA_DIR=MYDIR", "AZURE_AZ_EMULATOR=true"} mockContext := mocks.NewMockContext(context.Background()) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return args.Cmd == "terraform" }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { ran = true - require.Len(t, expectedEnvVars, 1) - require.Equal(t, expectedEnvVars, args.Env) - + require.GreaterOrEqual(t, len(args.Env), 2) + for _, expectedEnvVar := range expectedEnvVars { + require.Contains(t, args.Env, expectedEnvVar) + } + var pathKey, pathValue string + for _, envV := range args.Env { + parts := strings.Split(envV, "=") + pathKey = parts[0] + if pathKey == "PATH" { + if len(parts) > 1 { + pathValue = parts[1] + } + break + } + } + // can't match pathValue as it is different depending on the OS. + // So just check that it is not empty and contains the path to emulate + require.NotEmpty(t, pathValue) + require.Contains(t, pathValue, string(filepath.Separator)+"azEmulate") return exec.NewRunResult(0, "", ""), nil })