diff --git a/.vscode/cspell.global.yaml b/.vscode/cspell.global.yaml index b79f8d6a33..ad3d85fad6 100644 --- a/.vscode/cspell.global.yaml +++ b/.vscode/cspell.global.yaml @@ -58,6 +58,7 @@ ignoreWords: - containerservice - databricks - dedb + - devcenter - devcontainer - dnsz - evgd diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index dcd55284ee..4c9ef0800d 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -19,6 +19,7 @@ armappconfiguration armappplatform armcognitiveservices asyncmy +armresourcegraph asyncpg azapi AZCLI @@ -62,6 +63,9 @@ csharpapp csharpapptest cupaloy deletedservices +devcenter +devcenters +devcentersdk devel discarder docf @@ -98,6 +102,7 @@ LASTEXITCODE ldflags lechnerc77 libc +mergo mgmt mgutz microsoftonline @@ -136,6 +141,8 @@ pyvenv reauthentication relogin remarshal +repourl +resourcegraph restoreapp retriable rzip diff --git a/cli/azd/cmd/cobra_builder.go b/cli/azd/cmd/cobra_builder.go index b331e9534d..010cf1a462 100644 --- a/cli/azd/cmd/cobra_builder.go +++ b/cli/azd/cmd/cobra_builder.go @@ -107,7 +107,6 @@ func (cb *CobraBuilder) configureActionResolver(cmd *cobra.Command, descriptor * // Registers the following to enable injection into actions that require them ioc.RegisterInstance(cb.container, cb.runner) ioc.RegisterInstance(cb.container, middleware.MiddlewareContext(cb.runner)) - ioc.RegisterInstance(cb.container, ctx) ioc.RegisterInstance(cb.container, cmd) ioc.RegisterInstance(cb.container, args) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 21815f3832..e449b6a8fd 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -2,13 +2,14 @@ package cmd import ( "context" - "encoding/json" + "errors" "fmt" "io" "os" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/repository" @@ -16,27 +17,28 @@ import ( "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/azsdk/storage" + "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/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/httputil" + "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" - infraBicep "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/bicep" - infraTerraform "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/terraform" "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/lazy" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/pipeline" + "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/bicep" "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" @@ -47,10 +49,10 @@ import ( "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/tools/terraform" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "golang.org/x/exp/slices" ) // Registers a singleton action initializer for the specified action name @@ -227,11 +229,21 @@ func registerCommonDependencies(container *ioc.NestedContainer) { } }) - container.RegisterSingleton(storage.NewBlobClient) - container.RegisterSingleton(storage.NewBlobSdkClient) container.RegisterSingleton(environment.NewLocalFileDataStore) container.RegisterSingleton(environment.NewManager) + container.RegisterSingleton(func() *lazy.Lazy[environment.LocalDataStore] { + return lazy.NewLazy(func() (environment.LocalDataStore, error) { + var localDataStore environment.LocalDataStore + err := container.Resolve(&localDataStore) + if err != nil { + return nil, err + } + + return localDataStore, nil + }) + }) + // Environment manager depends on azd context container.RegisterSingleton(func(azdContext *lazy.Lazy[*azdcontext.AzdContext]) *lazy.Lazy[environment.Manager] { return lazy.NewLazy(func() (environment.Manager, error) { @@ -253,23 +265,17 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }) }) - // Remote Environment State Providers - remoteStateProviderMap := map[environment.RemoteKind]any{ - environment.RemoteKindAzureBlobStorage: environment.NewStorageBlobDataStore, - } - - for remoteKind, constructor := range remoteStateProviderMap { - if err := container.RegisterNamedSingleton(string(remoteKind), constructor); err != nil { - panic(fmt.Errorf("registering remote state provider %s: %w", remoteKind, err)) - } - } - container.RegisterSingleton(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() @@ -280,56 +286,14 @@ func registerCommonDependencies(container *ioc.NestedContainer) { if projectConfig != nil && projectConfig.State != nil && projectConfig.State.Remote != nil { remoteStateConfig = projectConfig.State.Remote } else { - userConfig, err := userConfigManager.Load() - if err != nil { - return nil, fmt.Errorf("loading user config: %w", err) - } - - remoteState, ok := userConfig.Get("state.remote") - if ok { - jsonBytes, err := json.Marshal(remoteState) - if err != nil { - return nil, fmt.Errorf("marshalling remote state: %w", err) - } - - if err := json.Unmarshal(jsonBytes, &remoteStateConfig); err != nil { - return nil, fmt.Errorf("unmarshalling remote state: %w", err) - } + if _, err := userConfig.GetSection("state.remote", &remoteStateConfig); err != nil { + return nil, fmt.Errorf("getting remote state config: %w", err) } } return remoteStateConfig, nil }) - container.RegisterSingleton(func( - remoteStateConfig *state.RemoteConfig, - projectConfig *project.ProjectConfig, - ) (*storage.AccountConfig, error) { - if remoteStateConfig == nil { - return nil, nil - } - - var storageAccountConfig *storage.AccountConfig - jsonBytes, err := json.Marshal(remoteStateConfig.Config) - if err != nil { - return nil, fmt.Errorf("marshalling remote state config: %w", err) - } - - if err := json.Unmarshal(jsonBytes, &storageAccountConfig); err != nil { - return nil, fmt.Errorf("unmarshalling remote state config: %w", err) - } - - // If a container name has not been explicitly configured - // Default to use the project name as the container name - if storageAccountConfig.ContainerName == "" { - // Azure blob storage containers must be lowercase and can only container alphanumeric characters and hyphens - // We will do our best to preserve the original project name by forcing to lowercase. - storageAccountConfig.ContainerName = strings.ToLower(projectConfig.Name) - } - - return storageAccountConfig, nil - }) - // Lazy loads an existing environment, erroring out if not available // One can repeatedly call GetValue to wait until the environment is available. container.RegisterSingleton( @@ -399,6 +363,20 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }) }) + container.RegisterSingleton(func( + ctx context.Context, + credential azcore.TokenCredential, + httpClient httputil.HttpClient, + ) (*armresourcegraph.Client, error) { + options := azsdk. + DefaultClientOptionsBuilder(ctx, httpClient, "azd"). + BuildArmClientOptions() + + return armresourcegraph.NewClient(credential, options) + }) + + container.RegisterSingleton(templates.NewTemplateManager) + container.RegisterSingleton(templates.NewSourceManager) container.RegisterSingleton(project.NewResourceManager) container.RegisterSingleton(project.NewProjectManager) container.RegisterSingleton(project.NewServiceManager) @@ -407,8 +385,6 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.RegisterSingleton(config.NewUserConfigManager) container.RegisterSingleton(config.NewManager) container.RegisterSingleton(config.NewFileConfigManager) - container.RegisterSingleton(templates.NewTemplateManager) - container.RegisterSingleton(templates.NewSourceManager) container.RegisterSingleton(auth.NewManager) container.RegisterSingleton(azcli.NewUserProfileService) container.RegisterSingleton(account.NewSubscriptionsService) @@ -446,7 +422,6 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }) container.RegisterSingleton(azapi.NewDeployments) container.RegisterSingleton(azapi.NewDeploymentOperations) - container.RegisterSingleton(bicep.NewBicepCli) container.RegisterSingleton(docker.NewDocker) container.RegisterSingleton(dotnet.NewDotNetCli) container.RegisterSingleton(git.NewGitCli) @@ -457,25 +432,13 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.RegisterSingleton(npm.NewNpmCli) container.RegisterSingleton(python.NewPythonCli) container.RegisterSingleton(swa.NewSwaCli) - container.RegisterSingleton(terraform.NewTerraformCli) // Provisioning + container.RegisterSingleton(infra.NewAzureResourceManager) container.RegisterTransient(provisioning.NewManager) container.RegisterSingleton(provisioning.NewPrincipalIdProvider) container.RegisterSingleton(prompt.NewDefaultPrompter) - // Provisioning Providers - provisionProviderMap := map[provisioning.ProviderKind]any{ - provisioning.Bicep: infraBicep.NewBicepProvider, - provisioning.Terraform: infraTerraform.NewTerraformProvider, - } - - for provider, constructor := range provisionProviderMap { - if err := container.RegisterNamedTransient(string(provider), constructor); err != nil { - panic(fmt.Errorf("registering IaC provider %s: %w", provider, err)) - } - } - // Other container.RegisterSingleton(createClock) @@ -534,6 +497,72 @@ func registerCommonDependencies(container *ioc.NestedContainer) { } } + // Platform configuration + container.RegisterSingleton(func() *lazy.Lazy[*platform.Config] { + return lazy.NewLazy(func() (*platform.Config, error) { + var platformConfig *platform.Config + err := container.Resolve(&platformConfig) + + return platformConfig, err + }) + }) + + container.RegisterSingleton(func( + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], + userConfigManager config.UserConfigManager, + ) (*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 + ok, err := config.GetSection("platform", &platformConfig) + if err != nil { + return nil, fmt.Errorf("getting platform config: %w", err) + } + + if !ok { + return nil, errors.New("platform config not found") + } + + // Validate platform type + supportedPlatformKinds := []string{ + string(devcenter.PlatformKindDevCenter), + string(azd.PlatformKindDefault), + } + if !slices.Contains(supportedPlatformKinds, string(platformConfig.Type)) { + return nil, fmt.Errorf( + "platform kind '%s' is not supported. Valid values are '%s', %w", + platformConfig.Type, + strings.Join(supportedPlatformKinds, ","), + 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) + if err := container.RegisterNamedSingleton(platformName, constructor); err != nil { + panic(fmt.Errorf("registering platform provider %s: %w", provider, err)) + } + } + // Required for nested actions called from composite actions like 'up' registerActionInitializer[*initAction](container, "azd-init-action") registerActionInitializer[*provisionAction](container, "azd-provision-action") diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index cbef65280a..6fbcb13b8c 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -188,13 +188,17 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { switch initTypeSelect { case initAppTemplate: tracing.SetUsageAttributes(fields.InitMethod.String("template")) - err := i.initializeTemplate(ctx, azdCtx) + template, err := i.initializeTemplate(ctx, azdCtx) if err != nil { return nil, err } - err = i.initializeEnv(ctx, azdCtx) - if err != nil { + var templateMetadata *templates.Metadata + if template != nil { + templateMetadata = &template.Metadata + } + + if err := i.initializeEnv(ctx, azdCtx, templateMetadata); err != nil { return nil, err } case initFromApp: @@ -220,13 +224,13 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { } err = i.repoInitializer.InitFromApp(ctx, azdCtx, func() error { - return i.initializeEnv(ctx, azdCtx) + return i.initializeEnv(ctx, azdCtx, nil) }) if err != nil { return nil, err } case initEnvironment: - err = i.initializeEnv(ctx, azdCtx) + err = i.initializeEnv(ctx, azdCtx, nil) if err != nil { return nil, err } @@ -275,16 +279,18 @@ func promptInitType(console input.Console, ctx context.Context) (initType, error func (i *initAction) initializeTemplate( ctx context.Context, - azdCtx *azdcontext.AzdContext) error { + azdCtx *azdcontext.AzdContext) (*templates.Template, error) { err := i.repoInitializer.PromptIfNonEmpty(ctx, azdCtx) if err != nil { - return err + return nil, err } + var template *templates.Template + if i.flags.templatePath == "" { - template, err := templates.PromptTemplate(ctx, "Select a project template:", i.templateManager, i.console) + template, err = templates.PromptTemplate(ctx, "Select a project template:", i.templateManager, i.console) if err != nil { - return err + return nil, err } if template != nil { @@ -293,28 +299,30 @@ func (i *initAction) initializeTemplate( } if i.flags.templatePath != "" { - gitUri, err := templates.Absolute(i.flags.templatePath) - if err != nil { - return err + if template == nil { + template = &templates.Template{ + RepositoryPath: i.flags.templatePath, + } } - err = i.repoInitializer.Initialize(ctx, azdCtx, gitUri, i.flags.templateBranch) + err = i.repoInitializer.Initialize(ctx, azdCtx, template, i.flags.templateBranch) if err != nil { - return fmt.Errorf("init from template repository: %w", err) + return nil, fmt.Errorf("init from template repository: %w", err) } } else { err := i.repoInitializer.InitializeMinimal(ctx, azdCtx) if err != nil { - return fmt.Errorf("init empty repository: %w", err) + return nil, fmt.Errorf("init empty repository: %w", err) } } - return nil + return template, nil } func (i *initAction) initializeEnv( ctx context.Context, - azdCtx *azdcontext.AzdContext) error { + azdCtx *azdcontext.AzdContext, + templateMetadata *templates.Metadata) error { envName, err := azdCtx.GetDefaultEnvironmentName() if err != nil { return fmt.Errorf("retrieving default environment name: %w", err) @@ -359,6 +367,23 @@ func (i *initAction) initializeEnv( return fmt.Errorf("saving default environment: %w", err) } + // If the template includes any metadata values, set them in the environment + if templateMetadata != nil { + for key, value := range templateMetadata.Variables { + env.DotenvSet(key, value) + } + + for key, value := range templateMetadata.Config { + if err := env.Config.Set(key, value); err != nil { + return fmt.Errorf("setting environment config: %w", err) + } + } + + if err := envManager.Save(ctx, env); err != nil { + return fmt.Errorf("saving environment: %w", err) + } + } + return nil } diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index b670657545..3d8033af63 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -18,6 +18,8 @@ import ( "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/exec" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/spf13/pflag" "go.opentelemetry.io/otel/attribute" @@ -26,13 +28,15 @@ import ( // Telemetry middleware tracks telemetry for the given action type TelemetryMiddleware struct { - options *Options + options *Options + lazyPlatformConfig *lazy.Lazy[*platform.Config] } // Creates a new Telemetry middleware instance -func NewTelemetryMiddleware(options *Options) Middleware { +func NewTelemetryMiddleware(options *Options, lazyPlatformConfig *lazy.Lazy[*platform.Config]) Middleware { return &TelemetryMiddleware{ - options: options, + options: options, + lazyPlatformConfig: lazyPlatformConfig, } } @@ -65,6 +69,12 @@ func (m *TelemetryMiddleware) Run(ctx context.Context, next NextFn) (*actions.Ac span.SetAttributes(fields.CmdArgsCount.Int(len(m.options.Args))) + // Set the platform type when available + // Valid platform types are validating in the platform config resolver and will error here if not known & valid + if platformConfig, err := m.lazyPlatformConfig.GetValue(); err == nil && platformConfig != nil { + span.SetAttributes(fields.PlatformTypeKey.String(string(platformConfig.Type))) + } + defer func() { // Include any usage attributes set span.SetAttributes(tracing.GetUsageAttributes()...) diff --git a/cli/azd/cmd/middleware/telemetry_test.go b/cli/azd/cmd/middleware/telemetry_test.go index 7622d14816..31f084fe10 100644 --- a/cli/azd/cmd/middleware/telemetry_test.go +++ b/cli/azd/cmd/middleware/telemetry_test.go @@ -13,6 +13,8 @@ import ( "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/exec" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mocktracing" "github.com/stretchr/testify/require" @@ -20,6 +22,12 @@ import ( ) func Test_Telemetry_Run(t *testing.T) { + lazyPlatformConfig := lazy.NewLazy(func() (*platform.Config, error) { + return &platform.Config{ + Type: "devcenter", + }, nil + }) + t.Run("WithRootAction", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) @@ -28,7 +36,7 @@ func Test_Telemetry_Run(t *testing.T) { Name: "provision", isChildAction: false, } - middleware := NewTelemetryMiddleware(options) + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig) ran := false var actualContext context.Context @@ -58,7 +66,7 @@ func Test_Telemetry_Run(t *testing.T) { Name: "provision", isChildAction: true, } - middleware := NewTelemetryMiddleware(options) + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig) ran := false var actualContext context.Context diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index a0223cdec2..f9aaf96257 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "fmt" "log" "os" @@ -14,7 +15,9 @@ import ( // Importing for infrastructure provider plugin registrations + "github.com/azure/azure-dev/cli/azd/pkg/azd" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/telemetry" @@ -25,7 +28,7 @@ import ( // Creates the root Cobra command for AZD. // staticHelp - False, except for running for doc generation // middlewareChain - nil, except for running unit tests -func NewRootCmd(staticHelp bool, middlewareChain []*actions.MiddlewareRegistration) *cobra.Command { +func NewRootCmd(ctx context.Context, staticHelp bool, middlewareChain []*actions.MiddlewareRegistration) *cobra.Command { prevDir := "" opts := &internal.GlobalCommandOptions{GenerateStaticHelp: staticHelp} opts.EnableTelemetry = telemetry.IsTelemetryEnabled() @@ -162,7 +165,7 @@ func NewRootCmd(staticHelp bool, middlewareChain []*actions.MiddlewareRegistrati GroupingOptions: actions.CommandGroupOptions{ RootLevelHelp: actions.CmdGroupConfig, }, - }).AddFlagCompletion("template", templateNameCompletion) + }) root. Add("restore", &actions.ActionDescriptorOptions{ @@ -309,10 +312,17 @@ func NewRootCmd(staticHelp bool, middlewareChain []*actions.MiddlewareRegistrati return !descriptor.Options.DisableTelemetry }) + // Register common dependencies for the IoC container + ioc.RegisterInstance(ioc.Global, ctx) registerCommonDependencies(ioc.Global) - cobraBuilder := NewCobraBuilder(ioc.Global) + + // Initialize the platform specific components for the IoC container + if _, err := platform.Initialize(ioc.Global, azd.PlatformKindDefault); err != nil { + panic(err) + } // Compose the hierarchy of action descriptions into cobra commands + cobraBuilder := NewCobraBuilder(ioc.Global) cmd, err := cobraBuilder.BuildCommand(root) if err != nil { diff --git a/cli/azd/cmd/templates.go b/cli/azd/cmd/templates.go index 256da997a9..f163857153 100644 --- a/cli/azd/cmd/templates.go +++ b/cli/azd/cmd/templates.go @@ -8,10 +8,8 @@ import ( "errors" "fmt" "io" - "net/http" "github.com/azure/azure-dev/cli/azd/cmd/actions" - "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" @@ -19,31 +17,6 @@ import ( "github.com/spf13/cobra" ) -func templateNameCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - templateManager, err := templates.NewTemplateManager( - templates.NewSourceManager( - config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())), - http.DefaultClient, - ), - ) - if err != nil { - cobra.CompError(fmt.Sprintf("Error creating template manager: %s", err.Error())) - return []string{}, cobra.ShellCompDirectiveError - } - - templates, err := templateManager.ListTemplates(cmd.Context(), nil) - if err != nil { - cobra.CompError(fmt.Sprintf("Error listing templates: %s", err)) - return []string{}, cobra.ShellCompDirectiveError - } - - templateNames := make([]string, len(templates)) - for i, v := range templates { - templateNames[i] = v.Name - } - return templateNames, cobra.ShellCompDirectiveDefault -} - func templatesActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { group := root.Add("template", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ diff --git a/cli/azd/cmd/usage_test.go b/cli/azd/cmd/usage_test.go index 3d600f0b02..64e5da1670 100644 --- a/cli/azd/cmd/usage_test.go +++ b/cli/azd/cmd/usage_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "html/template" "strings" "testing" @@ -21,7 +22,7 @@ import ( func TestUsage(t *testing.T) { // disable rich formatting output t.Setenv("TERM", "dumb") - root := NewRootCmd(false, nil) + root := NewRootCmd(context.Background(), false, nil) usageSnapshot(t, root) } diff --git a/cli/azd/docs/docgen.go b/cli/azd/docs/docgen.go index 66ac3a60d8..5e3271949f 100644 --- a/cli/azd/docs/docgen.go +++ b/cli/azd/docs/docgen.go @@ -5,6 +5,7 @@ package main import ( "bytes" + "context" "fmt" "io" "io/fs" @@ -53,7 +54,7 @@ func main() { // staticHelp is true to inform commands to use generate help text instead // of generating help text that includes execution-specific state. - cmd := azd.NewRootCmd(true, nil) + cmd := azd.NewRootCmd(context.Background(), true, nil) basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md" filename := filepath.Join("./md", basename) diff --git a/cli/azd/internal/repository/initializer.go b/cli/azd/internal/repository/initializer.go index 32847766d4..82494cb807 100644 --- a/cli/azd/internal/repository/initializer.go +++ b/cli/azd/internal/repository/initializer.go @@ -19,6 +19,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/templates" "github.com/azure/azure-dev/cli/azd/pkg/tools/git" "github.com/azure/azure-dev/cli/azd/resources" "github.com/otiai10/copy" @@ -45,7 +46,7 @@ func NewInitializer( func (i *Initializer) Initialize( ctx context.Context, azdCtx *azdcontext.AzdContext, - templateUrl string, + template *templates.Template, templateBranch string) error { var err error stepMessage := fmt.Sprintf("Downloading template code to: %s", output.WithLinkFormat("%s", azdCtx.ProjectDirectory())) @@ -66,6 +67,11 @@ func (i *Initializer) Initialize( target := azdCtx.ProjectDirectory() + templateUrl, err := templates.Absolute(template.RepositoryPath) + if err != nil { + return err + } + filesWithExecPerms, err := i.fetchCode(ctx, templateUrl, templateBranch, staging) if err != nil { return err @@ -101,6 +107,10 @@ func (i *Initializer) Initialize( return err } + if err := i.initializeProject(ctx, azdCtx, &template.Metadata); err != nil { + return fmt.Errorf("initializing project: %w", err) + } + err = i.gitInitialize(ctx, target, filesWithExecPerms, isEmpty) if err != nil { return err @@ -237,6 +247,31 @@ func (i *Initializer) ensureGitRepository(ctx context.Context, repoPath string) return nil } +// Initialize the project with any metadata values from the template +func (i *Initializer) initializeProject( + ctx context.Context, + azdCtx *azdcontext.AzdContext, + templateMetaData *templates.Metadata, +) error { + if templateMetaData == nil || len(templateMetaData.Project) == 0 { + return nil + } + + projectPath := azdCtx.ProjectPath() + projectConfig, err := project.LoadConfig(ctx, projectPath) + if err != nil { + return fmt.Errorf("loading project config: %w", err) + } + + for key, value := range templateMetaData.Project { + if err := projectConfig.Set(key, value); err != nil { + return fmt.Errorf("setting project config: %w", err) + } + } + + return project.SaveConfig(ctx, projectConfig, projectPath) +} + func parseExecutableFiles(stagedFilesOutput string) ([]string, error) { scanner := bufio.NewScanner(strings.NewReader(stagedFilesOutput)) executableFiles := []string{} @@ -293,17 +328,23 @@ func (i *Initializer) InitializeMinimal(ctx context.Context, azdCtx *azdcontext. return err } - err = os.MkdirAll(projectConfig.Infra.Path, osutil.PermissionDirectory) + // Default infra path if not specified + infraPath := projectConfig.Infra.Path + if infraPath == "" { + infraPath = bicep.Defaults.Path + } + + err = os.MkdirAll(infraPath, osutil.PermissionDirectory) if err != nil { return err } module := projectConfig.Infra.Module if projectConfig.Infra.Module == "" { - module = bicep.DefaultModule + module = bicep.Defaults.Module } - mainPath := filepath.Join(projectConfig.Infra.Path, module) + mainPath := filepath.Join(infraPath, module) retryInfix := ".azd" err = i.writeFileSafe( ctx, diff --git a/cli/azd/internal/repository/initializer_test.go b/cli/azd/internal/repository/initializer_test.go index e72ee933ec..5bfe48e141 100644 --- a/cli/azd/internal/repository/initializer_test.go +++ b/cli/azd/internal/repository/initializer_test.go @@ -16,78 +16,43 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "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/templates" "github.com/azure/azure-dev/cli/azd/pkg/tools/git" + "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type testCase struct { + name string + templateDir string + // Files that will be mocked to be executable when fetched remotely. + // Equally, these files are asserted to be executable after init. + executableFiles []string +} + func Test_Initializer_Initialize(t *testing.T) { - tests := []struct { - name string - templateDir string - // Files that will be mocked to be executable when fetched remotely. - // Equally, these files are asserted to be executable after init. - executableFiles []string - }{ + tests := []testCase{ {"RegularTemplate", "template", []string{"script/test.sh"}}, {"MinimalTemplate", "template-minimal", []string{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { projectDir := t.TempDir() - ctx := context.Background() azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) - console := mockinput.NewMockConsole() - realRunner := exec.NewCommandRunner(nil) - mockRunner := mockexec.NewMockCommandRunner() - mockRunner.When(func(args exec.RunArgs, command string) bool { return true }). - RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - // Stub out git clone, otherwise run actual command - if slices.Contains(args.Args, "clone") && slices.Contains(args.Args, "local") { - stagingDir := args.Args[len(args.Args)-1] - copyTemplate(t, testDataPath(tt.templateDir), stagingDir) - - gitArgs := exec.NewRunArgs("git", "-C", stagingDir) + mockContext := mocks.NewMockContext(context.Background()) + mockGitClone(t, mockContext, "https://github.com/Azure-Samples/local", tt) - // Mock clone by creating a git repository locally - _, err := realRunner.Run(ctx, gitArgs.AppendParams("init")) - require.NoError(t, err) - - _, err = realRunner.Run(ctx, gitArgs.AppendParams("add", "*")) - require.NoError(t, err) - - for _, file := range tt.executableFiles { - _, err = realRunner.Run( - ctx, - gitArgs.AppendParams("update-index", "--chmod=+x", file)) - require.NoError(t, err) - - // Mocks the correct behavior in *nix when the file lands on the filesystem. - // git would have automatically set the correct file executable permissions. - // - // Note that `git update-index --chmod=+x` simply updates the tracked permissions in git, - // but does not update the files directly, hence this is needed. - if runtime.GOOS != "windows" { - err = os.Chmod(filepath.Join(stagingDir, file), 0755) - require.NoError(t, err) - } - } - - return exec.NewRunResult(0, "", ""), nil - } - - return realRunner.Run(ctx, args) - }) - - i := NewInitializer(console, git.NewGitCli(mockRunner)) - err := i.Initialize(ctx, azdCtx, "local", "") + i := NewInitializer(mockContext.Console, git.NewGitCli(mockContext.CommandRunner)) + err := i.Initialize(*mockContext.Context, azdCtx, &templates.Template{RepositoryPath: "local"}, "") require.NoError(t, err) verifyTemplateCopied(t, testDataPath(tt.templateDir), projectDir, verifyOptions{}) - verifyExecutableFilePermissions(t, ctx, i.gitCli, projectDir, tt.executableFiles) + verifyExecutableFilePermissions(t, *mockContext.Context, i.gitCli, projectDir, tt.executableFiles) require.FileExists(t, filepath.Join(projectDir, ".gitignore")) require.FileExists(t, azdCtx.ProjectPath()) @@ -96,6 +61,40 @@ func Test_Initializer_Initialize(t *testing.T) { } } +func Test_Initializer_DevCenter(t *testing.T) { + projectDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + mockContext := mocks.NewMockContext(context.Background()) + testMetadata := testCase{ + name: "devcenter", + templateDir: "template", + } + mockGitClone(t, mockContext, "https://github.com/Azure-Samples/local", testMetadata) + + template := &templates.Template{ + RepositoryPath: "local", + Metadata: templates.Metadata{ + Project: map[string]string{ + "platform.type": "devcenter", + "platform.config.name": "DEVCENTER_NAME", + "platform.config.project": "DEVCENTER_PROJECT", + "platform.config.environmentDefinition": "DEVCENTER_ENV_DEFINITION", + }, + }, + } + + i := NewInitializer(mockContext.Console, git.NewGitCli(mockContext.CommandRunner)) + err := i.Initialize(*mockContext.Context, azdCtx, template, "") + require.NoError(t, err) + + prj, err := project.Load(*mockContext.Context, azdCtx.ProjectPath()) + require.NoError(t, err) + require.Equal(t, prj.Platform.Type, platform.PlatformKind("devcenter")) + require.Equal(t, prj.Platform.Config["name"], "DEVCENTER_NAME") + require.Equal(t, prj.Platform.Config["project"], "DEVCENTER_PROJECT") + require.Equal(t, prj.Platform.Config["environmentDefinition"], "DEVCENTER_ENV_DEFINITION") +} + func Test_Initializer_InitializeWithOverwritePrompt(t *testing.T) { templateDir := "template" tests := []struct { @@ -133,7 +132,8 @@ func Test_Initializer_InitializeWithOverwritePrompt(t *testing.T) { mockRunner.When(func(args exec.RunArgs, command string) bool { return true }). RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { // Stub out git clone, otherwise run actual command - if slices.Contains(args.Args, "clone") && slices.Contains(args.Args, "local") { + if slices.Contains(args.Args, "clone") && + slices.Contains(args.Args, "https://github.com/Azure-Samples/local") { stagingDir := args.Args[len(args.Args)-1] copyTemplate(t, testDataPath(templateDir), stagingDir) _, err := realRunner.Run(context.Background(), exec.NewRunArgs("git", "-C", stagingDir, "init")) @@ -146,7 +146,7 @@ func Test_Initializer_InitializeWithOverwritePrompt(t *testing.T) { }) i := NewInitializer(console, git.NewGitCli(mockRunner)) - err = i.Initialize(context.Background(), azdCtx, "local", "") + err = i.Initialize(context.Background(), azdCtx, &templates.Template{RepositoryPath: "local"}, "") require.NoError(t, err) switch tt.selection { @@ -675,3 +675,47 @@ func TestInitializer_writeFileSafe(t *testing.T) { }) } } + +func mockGitClone(t *testing.T, mockContext *mocks.MockContext, templatePath string, testCase testCase) { + realRunner := exec.NewCommandRunner(nil) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return true }). + RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + // Stub out git clone, otherwise run actual command + if slices.Contains(args.Args, "clone") && slices.Contains(args.Args, templatePath) { + stagingDir := args.Args[len(args.Args)-1] + copyTemplate(t, testDataPath(testCase.templateDir), stagingDir) + + gitArgs := exec.NewRunArgs("git", "-C", stagingDir) + + // Mock clone by creating a git repository locally + _, err := realRunner.Run(*mockContext.Context, gitArgs.AppendParams("init")) + require.NoError(t, err) + + _, err = realRunner.Run(*mockContext.Context, gitArgs.AppendParams("add", "*")) + require.NoError(t, err) + + for _, file := range testCase.executableFiles { + _, err = realRunner.Run( + *mockContext.Context, + gitArgs.AppendParams("update-index", "--chmod=+x", file), + ) + require.NoError(t, err) + + // Mocks the correct behavior in *nix when the file lands on the filesystem. + // git would have automatically set the correct file executable permissions. + // + // Note that `git update-index --chmod=+x` simply updates the tracked permissions in git, + // but does not update the files directly, hence this is needed. + if runtime.GOOS != "windows" { + err = os.Chmod(filepath.Join(stagingDir, file), 0755) + require.NoError(t, err) + } + } + + return exec.NewRunResult(0, "", ""), nil + } + + return realRunner.Run(*mockContext.Context, args) + }) +} diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 21d848d7a8..47547a886d 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -89,6 +89,11 @@ const ( ProjectServiceLanguageKey = attribute.Key("project.service.language") ) +// Platform related attributes for integrations like devcenter / ADE +const ( + PlatformTypeKey = attribute.Key("platform.type") +) + // Environment related attributes const ( // Hashed environment name diff --git a/cli/azd/main.go b/cli/azd/main.go index 8147119328..1315cb4ef7 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -58,7 +58,7 @@ func main() { latest := make(chan semver.Version) go fetchLatestVersion(latest) - cmdErr := cmd.NewRootCmd(false, nil).ExecuteContext(ctx) + cmdErr := cmd.NewRootCmd(ctx, false, nil).ExecuteContext(ctx) if !isJsonOutput() { if firstNotice := telemetry.FirstNotice(); firstNotice != "" { diff --git a/cli/azd/pkg/azd/default.go b/cli/azd/pkg/azd/default.go new file mode 100644 index 0000000000..ce758aa067 --- /dev/null +++ b/cli/azd/pkg/azd/default.go @@ -0,0 +1,123 @@ +package azd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azsdk/storage" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + infraBicep "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/bicep" + infraTerraform "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/terraform" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "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/state" + "github.com/azure/azure-dev/cli/azd/pkg/templates" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" + "github.com/azure/azure-dev/cli/azd/pkg/tools/terraform" +) + +const PlatformKindDefault platform.PlatformKind = "default" + +// DefaultPlatform manages the Azd configuration of the default platform +type DefaultPlatform struct { +} + +// NewDefaultPlatform creates a new instance of the default platform +func NewDefaultPlatform() platform.Provider { + return &DefaultPlatform{} +} + +// Name returns the name of the platform +func (p *DefaultPlatform) Name() string { + return "default" +} + +// IsEnabled returns true when the platform is enabled +func (p *DefaultPlatform) IsEnabled() bool { + return true +} + +// ConfigureContainer configures the IoC container for the default platform components +func (p *DefaultPlatform) ConfigureContainer(container *ioc.NestedContainer) error { + // Tools + container.RegisterSingleton(terraform.NewTerraformCli) + container.RegisterSingleton(bicep.NewBicepCli) + + // Provisioning Providers + provisionProviderMap := map[provisioning.ProviderKind]any{ + provisioning.Bicep: infraBicep.NewBicepProvider, + provisioning.Terraform: infraTerraform.NewTerraformProvider, + } + + for provider, constructor := range provisionProviderMap { + if err := container.RegisterNamedTransient(string(provider), constructor); err != nil { + panic(fmt.Errorf("registering IaC provider %s: %w", provider, err)) + } + } + + // Function to determine the default IaC provider when provisioning + container.RegisterSingleton(func() provisioning.DefaultProviderResolver { + return func() (provisioning.ProviderKind, error) { + return provisioning.Bicep, nil + } + }) + + // Remote Environment State Providers + remoteStateProviderMap := map[environment.RemoteKind]any{ + environment.RemoteKindAzureBlobStorage: environment.NewStorageBlobDataStore, + } + + for remoteKind, constructor := range remoteStateProviderMap { + if err := container.RegisterNamedSingleton(string(remoteKind), constructor); err != nil { + panic(fmt.Errorf("registering remote state provider %s: %w", remoteKind, err)) + } + } + + container.RegisterSingleton(func( + remoteStateConfig *state.RemoteConfig, + projectConfig *project.ProjectConfig, + ) (*storage.AccountConfig, error) { + if remoteStateConfig == nil { + return nil, nil + } + + var storageAccountConfig *storage.AccountConfig + jsonBytes, err := json.Marshal(remoteStateConfig.Config) + if err != nil { + return nil, fmt.Errorf("marshalling remote state config: %w", err) + } + + if err := json.Unmarshal(jsonBytes, &storageAccountConfig); err != nil { + return nil, fmt.Errorf("unmarshalling remote state config: %w", err) + } + + // If a container name has not been explicitly configured + // Default to use the project name as the container name + if storageAccountConfig.ContainerName == "" { + // Azure blob storage containers must be lowercase and can only container alphanumeric characters and hyphens + // We will do our best to preserve the original project name by forcing to lowercase. + storageAccountConfig.ContainerName = strings.ToLower(projectConfig.Name) + } + + return storageAccountConfig, nil + }) + + // Storage components + container.RegisterSingleton(storage.NewBlobClient) + container.RegisterSingleton(storage.NewBlobSdkClient) + + // Templates + + // Gets a list of default template sources used in azd. + container.RegisterSingleton(func() *templates.SourceOptions { + return &templates.SourceOptions{ + DefaultSources: []*templates.SourceConfig{}, + LoadConfiguredSources: true, + } + }) + + return nil +} diff --git a/cli/azd/pkg/azd/default_test.go b/cli/azd/pkg/azd/default_test.go new file mode 100644 index 0000000000..d2ea67bbd1 --- /dev/null +++ b/cli/azd/pkg/azd/default_test.go @@ -0,0 +1,35 @@ +package azd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/stretchr/testify/require" +) + +func Test_DefaultPlatform_IsEnabled(t *testing.T) { + t.Run("Enabled", func(t *testing.T) { + defaultPlatform := NewDefaultPlatform() + require.True(t, defaultPlatform.IsEnabled()) + }) +} + +func Test_DefaultPlatform_ConfigureContainer(t *testing.T) { + t.Run("Success", func(t *testing.T) { + defaultPlatform := NewDefaultPlatform() + container := ioc.NewNestedContainer(nil) + err := defaultPlatform.ConfigureContainer(container) + require.NoError(t, err) + + var provisionResolver provisioning.DefaultProviderResolver + err = container.Resolve(&provisionResolver) + require.NoError(t, err) + require.NotNil(t, provisionResolver) + + expected := provisioning.Bicep + actual, err := provisionResolver() + require.NoError(t, err) + require.Equal(t, expected, actual) + }) +} diff --git a/cli/azd/pkg/config/config.go b/cli/azd/pkg/config/config.go index 6fb5dabb96..b6e8fd01e9 100644 --- a/cli/azd/pkg/config/config.go +++ b/cli/azd/pkg/config/config.go @@ -7,6 +7,7 @@ package config import ( + "encoding/json" "fmt" "strings" ) @@ -16,6 +17,8 @@ import ( type Config interface { Raw() map[string]any Get(path string) (any, bool) + GetString(path string) (string, bool) + GetSection(path string, section any) (bool, error) Set(path string, value any) error Unset(path string) error IsEmpty() bool @@ -146,3 +149,32 @@ func (c *config) Get(path string) (any, bool) { return nil, false } + +// Gets the value stored at the specified location as a string +func (c *config) GetString(path string) (string, bool) { + value, ok := c.Get(path) + if !ok { + return "", false + } + + str, ok := value.(string) + return str, ok +} + +func (c *config) GetSection(path string, section any) (bool, error) { + sectionConfig, ok := c.Get(path) + if !ok { + return false, nil + } + + jsonBytes, err := json.Marshal(sectionConfig) + if err != nil { + return true, fmt.Errorf("marshalling section config: %w", err) + } + + if err := json.Unmarshal(jsonBytes, section); err != nil { + return true, fmt.Errorf("unmarshalling section config: %w", err) + } + + return true, nil +} diff --git a/cli/azd/pkg/config/config_test.go b/cli/azd/pkg/config/config_test.go index fc20680589..798af38e52 100644 --- a/cli/azd/pkg/config/config_test.go +++ b/cli/azd/pkg/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "testing" + "github.com/azure/azure-dev/cli/azd/pkg/convert" "github.com/stretchr/testify/require" ) @@ -26,7 +27,7 @@ func Test_SetGetUnsetWithValue(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - azdConfig := NewConfig(nil) + azdConfig := NewEmptyConfig() err := azdConfig.Set(test.path, test.value) require.NoError(t, err) @@ -48,7 +49,7 @@ func Test_SetGetUnsetRootNodeWithChildren(t *testing.T) { expectedLocation := "westus2" expectedEmail := "john.doe@contoso.com" - azdConfig := NewConfig(nil) + azdConfig := NewEmptyConfig() _ = azdConfig.Set("defaults.location", expectedLocation) _ = azdConfig.Set("defaults.subscription", "SUBSCRIPTION_ID") _ = azdConfig.Set("user.email", expectedEmail) @@ -78,7 +79,7 @@ func Test_SetGetUnsetRootNodeWithChildren(t *testing.T) { func Test_IsEmpty(t *testing.T) { t.Run("Empty", func(t *testing.T) { - azdConfig := NewConfig(nil) + azdConfig := NewEmptyConfig() require.True(t, azdConfig.IsEmpty()) }) @@ -94,3 +95,144 @@ func Test_IsEmpty(t *testing.T) { require.False(t, azdConfig.IsEmpty()) }) } + +func Test_GetString(t *testing.T) { + t.Run("ValidString", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("a.b.c", "apple") + require.NoError(t, err) + + value, ok := azdConfig.GetString("a.b.c") + require.Equal(t, "apple", value) + require.True(t, ok) + }) + + t.Run("EmptyString", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("a.b.c", "") + require.NoError(t, err) + + value, ok := azdConfig.GetString("a.b.c") + require.Equal(t, "", value) + require.True(t, ok) + }) + + t.Run("NonStringValue", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("a.b.c", 1) + require.NoError(t, err) + + value, ok := azdConfig.GetString("a.b.c") + require.Equal(t, "", value) + require.False(t, ok) + }) + + t.Run("NilValue", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("a.b.c", nil) + require.NoError(t, err) + + value, ok := azdConfig.GetString("a.b.c") + require.Equal(t, "", value) + require.False(t, ok) + }) +} + +func Test_GetSection(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &testConfig{ + A: "apple", + B: "banana", + C: "cherry", + } + + values, err := convert.ToMap(expected) + require.NoError(t, err) + + azdConfig := NewEmptyConfig() + err = azdConfig.Set("parent.section", values) + require.NoError(t, err) + + var actual *testConfig + ok, err := azdConfig.GetSection("parent.section", &actual) + require.True(t, ok) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("NotFound", func(t *testing.T) { + azdConfig := NewEmptyConfig() + + var actual *testConfig + ok, err := azdConfig.GetSection("parent.section", &actual) + require.False(t, ok) + require.NoError(t, err) + }) + + t.Run("NotStruct", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("parent.section", "apple") + require.NoError(t, err) + + var actual *testConfig + ok, err := azdConfig.GetSection("parent.section", &actual) + require.True(t, ok) + require.Error(t, err) + }) + + t.Run("Empty", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("parent.section", map[string]any{}) + require.NoError(t, err) + + var actual *testConfig + ok, err := azdConfig.GetSection("parent.section", &actual) + require.True(t, ok) + require.NoError(t, err) + require.Equal(t, "", actual.A) + require.Equal(t, "", actual.B) + require.Equal(t, "", actual.C) + }) + + t.Run("PartialSection", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("parent.section.A", "apple") + require.NoError(t, err) + err = azdConfig.Set("parent.section.B", "banana") + require.NoError(t, err) + + var actual *testConfig + ok, err := azdConfig.GetSection("parent.section", &actual) + require.True(t, ok) + require.NoError(t, err) + require.Equal(t, "apple", actual.A) + require.Equal(t, "banana", actual.B) + require.Equal(t, "", actual.C) + }) + + t.Run("ExtraProps", func(t *testing.T) { + azdConfig := NewEmptyConfig() + err := azdConfig.Set("parent.section.A", "apple") + require.NoError(t, err) + err = azdConfig.Set("parent.section.B", "banana") + require.NoError(t, err) + err = azdConfig.Set("parent.section.C", "cherry") + require.NoError(t, err) + err = azdConfig.Set("parent.section.D", "durian") + require.NoError(t, err) + + var actual *testConfig + ok, err := azdConfig.GetSection("parent.section", &actual) + require.True(t, ok) + require.NoError(t, err) + require.Equal(t, "apple", actual.A) + require.Equal(t, "banana", actual.B) + require.Equal(t, "cherry", actual.C) + }) +} + +type testConfig struct { + A string + B string + C string +} diff --git a/cli/azd/pkg/convert/util.go b/cli/azd/pkg/convert/util.go index 3569bec831..de38de95f6 100644 --- a/cli/azd/pkg/convert/util.go +++ b/cli/azd/pkg/convert/util.go @@ -1,5 +1,11 @@ package convert +import ( + "encoding/json" + "fmt" + "reflect" +) + // Converts a pointer to a value type // If the ptr is nil returns default value, otherwise the value of value of the pointer func ToValueWithDefault[T any](ptr *T, defaultValue T) T { @@ -7,6 +13,10 @@ func ToValueWithDefault[T any](ptr *T, defaultValue T) T { return defaultValue } + if str, ok := any(ptr).(*string); ok && *str == "" { + return defaultValue + } + return *ptr } @@ -14,3 +24,43 @@ func ToValueWithDefault[T any](ptr *T, defaultValue T) T { func RefOf[T any](value T) *T { return &value } + +// Attempts to convert the specified value to a string, otherwise returns the default value +func ToStringWithDefault(value any, defaultValue string) string { + if value == nil { + return defaultValue + } + + kind := reflect.TypeOf(value).Kind() + switch kind { + case reflect.Pointer: + if ptr, ok := value.(*string); ok && *ptr != "" { + return *ptr + } + case reflect.String: + if str, ok := value.(string); ok && str != "" { + return str + } + } + + return defaultValue +} + +// Converts the specified value to a map. +func ToMap(value any) (map[string]any, error) { + if value == nil { + return nil, nil + } + + jsonValue, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("failed to convert value to json: %w", err) + } + + mapValue := map[string]any{} + if err := json.Unmarshal(jsonValue, &mapValue); err != nil { + return nil, fmt.Errorf("failed to convert value to map: %w", err) + } + + return mapValue, nil +} diff --git a/cli/azd/pkg/convert/util_test.go b/cli/azd/pkg/convert/util_test.go new file mode 100644 index 0000000000..b165dd48c0 --- /dev/null +++ b/cli/azd/pkg/convert/util_test.go @@ -0,0 +1,118 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_ToStringWithDefault(t *testing.T) { + type testCase struct { + name string + input interface{} + expected interface{} + } + + testCases := []testCase{ + { + name: "ValidString", + input: "apple", + expected: "apple", + }, + { + name: "NotString", + input: 1, + expected: "default", + }, + { + name: "EmptyString", + input: "", + expected: "default", + }, + { + name: "Nil", + input: nil, + expected: "default", + }, + { + name: "StringPointer", + input: RefOf("apple"), + expected: "apple", + }, + { + name: "NotStringPointer", + input: RefOf(1), + expected: "default", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := ToStringWithDefault(tc.input, "default") + require.Equal(t, tc.expected, actual) + }) + } +} + +func Test_ToValueWithDefault(t *testing.T) { + t.Run("String", func(t *testing.T) { + value := ToValueWithDefault(RefOf("apple"), "default") + require.Equal(t, "apple", value) + }) + + t.Run("Int", func(t *testing.T) { + value := ToValueWithDefault(RefOf(1), 0) + require.Equal(t, 1, value) + }) + + t.Run("Nil", func(t *testing.T) { + value := ToValueWithDefault(nil, "default") + require.Equal(t, "default", value) + }) + + t.Run("EmptyString", func(t *testing.T) { + value := ToValueWithDefault(RefOf(""), "default") + require.Equal(t, "default", value) + }) +} + +func Test_RefOf(t *testing.T) { + t.Run("String", func(t *testing.T) { + value := RefOf("apple") + require.Equal(t, "apple", *value) + }) + + t.Run("Int", func(t *testing.T) { + value := RefOf(1) + require.Equal(t, 1, *value) + }) +} + +type Person struct { + Name string + Address string +} + +func Test_ToMap(t *testing.T) { + t.Run("ValidStruct", func(t *testing.T) { + input := Person{ + Name: "John Doe", + Address: "123 Main St", + } + expected := map[string]interface{}{ + "Name": "John Doe", + "Address": "123 Main St", + } + actual, err := ToMap(input) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("EmptyStruct", func(t *testing.T) { + input := struct{}{} + expected := map[string]interface{}{} + actual, err := ToMap(input) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) +} diff --git a/cli/azd/pkg/devcenter/config.go b/cli/azd/pkg/devcenter/config.go new file mode 100644 index 0000000000..bd582152c9 --- /dev/null +++ b/cli/azd/pkg/devcenter/config.go @@ -0,0 +1,54 @@ +package devcenter + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/platform" +) + +const ( + // Environment variable names + DevCenterNameEnvName = "AZURE_DEVCENTER_NAME" + DevCenterCatalogEnvName = "AZURE_DEVCENTER_CATALOG" + DevCenterProjectEnvName = "AZURE_DEVCENTER_PROJECT" + DevCenterEnvTypeEnvName = "AZURE_DEVCENTER_ENVIRONMENT_TYPE" + DevCenterEnvDefinitionEnvName = "AZURE_DEVCENTER_ENVIRONMENT_DEFINITION" + DevCenterEnvUser = "AZURE_DEVCENTER_ENVIRONMENT_USER" + + // Environment configuration paths + DevCenterNamePath = ConfigPath + ".name" + DevCenterCatalogPath = ConfigPath + ".catalog" + DevCenterProjectPath = ConfigPath + ".project" + DevCenterEnvTypePath = ConfigPath + ".environmentType" + DevCenterEnvDefinitionPath = ConfigPath + ".environmentDefinition" + DevCenterUserPath = ConfigPath + ".user" + + PlatformKindDevCenter platform.PlatformKind = "devcenter" +) + +// Config provides the Azure DevCenter configuration used for devcenter enabled projects +type Config struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Catalog string `json:"catalog,omitempty" yaml:"catalog,omitempty"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` + EnvironmentType string `json:"environmentType,omitempty" yaml:"environmentType,omitempty"` + EnvironmentDefinition string `json:"environmentDefinition,omitempty" yaml:"environmentDefinition,omitempty"` + User string `json:"user,omitempty" yaml:"user,omitempty"` +} + +// EnsureValid ensures the devcenter configuration is valid to continue with provisioning +func (c *Config) EnsureValid() error { + if c.Name == "" { + return fmt.Errorf("devcenter name is required") + } + + if c.Project == "" { + return fmt.Errorf("devcenter project is required") + } + + if c.EnvironmentDefinition == "" { + return fmt.Errorf("devcenter environment definition is required") + } + + return nil +} diff --git a/cli/azd/pkg/devcenter/devcenter.go b/cli/azd/pkg/devcenter/devcenter.go new file mode 100644 index 0000000000..d831f030ef --- /dev/null +++ b/cli/azd/pkg/devcenter/devcenter.go @@ -0,0 +1,78 @@ +package devcenter + +import ( + "encoding/json" + "fmt" +) + +// Merges supplemental configuration into the base config only if the key/value doesn't already exist in the base config +// Example: If the base config is a fully configured object, then any supplemental configuration will be ignored +func MergeConfigs(configs ...*Config) *Config { + configLens := len(configs) + + if configLens == 0 { + panic("no configs provided") + } + + if configLens == 1 { + return configs[0] + } + + destConfig := configs[0] + + mergedConfig := &Config{ + Name: destConfig.Name, + Catalog: destConfig.Catalog, + Project: destConfig.Project, + EnvironmentType: destConfig.EnvironmentType, + EnvironmentDefinition: destConfig.EnvironmentDefinition, + } + + for _, config := range configs[1:] { + if config == nil { + continue + } + + if config.Name != "" && mergedConfig.Name == "" { + mergedConfig.Name = config.Name + } + + if config.Catalog != "" && mergedConfig.Catalog == "" { + mergedConfig.Catalog = config.Catalog + } + + if config.Project != "" && mergedConfig.Project == "" { + mergedConfig.Project = config.Project + } + + if config.EnvironmentType != "" && mergedConfig.EnvironmentType == "" { + mergedConfig.EnvironmentType = config.EnvironmentType + } + + if config.EnvironmentDefinition != "" && mergedConfig.EnvironmentDefinition == "" { + mergedConfig.EnvironmentDefinition = config.EnvironmentDefinition + } + + if config.User != "" && mergedConfig.User == "" { + mergedConfig.User = config.User + } + } + + return mergedConfig +} + +// ParseConfig attempts to parse a partial JSON configuration into a devcenter configuration +func ParseConfig(partialConfig any) (*Config, error) { + var config *Config + + jsonBytes, err := json.Marshal(partialConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal dev center configuration: %w", err) + } + + if err := json.Unmarshal(jsonBytes, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal dev center configuration: %w", err) + } + + return config, nil +} diff --git a/cli/azd/pkg/devcenter/devcenter_test.go b/cli/azd/pkg/devcenter/devcenter_test.go new file mode 100644 index 0000000000..adcfb84e65 --- /dev/null +++ b/cli/azd/pkg/devcenter/devcenter_test.go @@ -0,0 +1,292 @@ +package devcenter + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_ParseConfig(t *testing.T) { + t.Run("Success", func(t *testing.T) { + partialConfig := map[string]any{ + "name": "DEVCENTER_NAME", + "project": "PROJECT", + "environmentDefinition": "ENVIRONMENT_DEFINITION", + } + + config, err := ParseConfig(partialConfig) + require.NoError(t, err) + require.Equal(t, "DEVCENTER_NAME", config.Name) + require.Equal(t, "PROJECT", config.Project) + require.Equal(t, "ENVIRONMENT_DEFINITION", config.EnvironmentDefinition) + }) + + t.Run("Failure", func(t *testing.T) { + partialConfig := "not a map" + config, err := ParseConfig(partialConfig) + require.Error(t, err) + require.Nil(t, config) + }) +} + +func Test_MergeConfigs(t *testing.T) { + t.Run("MergeMissingValues", func(t *testing.T) { + baseConfig := &Config{ + Name: "DEVCENTER_NAME", + Project: "PROJECT", + EnvironmentDefinition: "ENVIRONMENT_DEFINITION", + } + + overrideConfig := &Config{ + EnvironmentType: "Dev", + } + + mergedConfig := MergeConfigs(baseConfig, overrideConfig) + + require.Equal(t, "DEVCENTER_NAME", mergedConfig.Name) + require.Equal(t, "PROJECT", mergedConfig.Project) + require.Equal(t, "ENVIRONMENT_DEFINITION", mergedConfig.EnvironmentDefinition) + require.Equal(t, "Dev", mergedConfig.EnvironmentType) + }) + + t.Run("OverrideEmpty", func(t *testing.T) { + baseConfig := &Config{} + + overrideConfig := &Config{ + Name: "OVERRIDE", + Project: "OVERRIDE", + EnvironmentDefinition: "OVERRIDE", + Catalog: "OVERRIDE", + EnvironmentType: "OVERRIDE", + } + + mergedConfig := MergeConfigs(baseConfig, overrideConfig) + + require.Equal(t, "OVERRIDE", mergedConfig.Name) + require.Equal(t, "OVERRIDE", mergedConfig.Project) + require.Equal(t, "OVERRIDE", mergedConfig.EnvironmentDefinition) + require.Equal(t, "OVERRIDE", mergedConfig.Catalog) + require.Equal(t, "OVERRIDE", mergedConfig.EnvironmentType) + }) + + // The base config is a full configuration so there isn't anything to override + t.Run("NoOverride", func(t *testing.T) { + baseConfig := &Config{ + Name: "DEVCENTER_NAME", + Project: "PROJECT", + EnvironmentDefinition: "ENVIRONMENT_DEFINITION", + Catalog: "CATALOG", + EnvironmentType: "ENVIRONMENT_TYPE", + } + + overrideConfig := &Config{ + Name: "OVERRIDE", + Project: "OVERRIDE", + EnvironmentDefinition: "OVERRIDE", + Catalog: "OVERRIDE", + EnvironmentType: "OVERRIDE", + } + + mergedConfig := MergeConfigs(baseConfig, overrideConfig) + + require.Equal(t, "DEVCENTER_NAME", mergedConfig.Name) + require.Equal(t, "PROJECT", mergedConfig.Project) + require.Equal(t, "ENVIRONMENT_DEFINITION", mergedConfig.EnvironmentDefinition) + require.Equal(t, "CATALOG", mergedConfig.Catalog) + require.Equal(t, "ENVIRONMENT_TYPE", mergedConfig.EnvironmentType) + }) +} + +type mockDevCenterManager struct { + mock.Mock +} + +func (m *mockDevCenterManager) WritableProjects(ctx context.Context) ([]*devcentersdk.Project, error) { + args := m.Called(ctx) + return args.Get(0).([]*devcentersdk.Project), args.Error(1) +} + +func (m *mockDevCenterManager) WritableProjectsWithFilter( + ctx context.Context, + devCenterFilter DevCenterFilterPredicate, + projectFilter ProjectFilterPredicate, +) ([]*devcentersdk.Project, error) { + args := m.Called(ctx, devCenterFilter, projectFilter) + return args.Get(0).([]*devcentersdk.Project), args.Error(1) +} + +func (m *mockDevCenterManager) Deployment( + ctx context.Context, + env *devcentersdk.Environment, + filter DeploymentFilterPredicate, +) (infra.Deployment, error) { + args := m.Called(ctx, env, filter) + return args.Get(0).(infra.Deployment), args.Error(1) +} + +func (m *mockDevCenterManager) LatestArmDeployment( + ctx context.Context, + env *devcentersdk.Environment, + filter DeploymentFilterPredicate, +) (*armresources.DeploymentExtended, error) { + args := m.Called(ctx, env, filter) + return args.Get(0).(*armresources.DeploymentExtended), args.Error(1) +} + +func (m *mockDevCenterManager) Outputs( + ctx context.Context, + env *devcentersdk.Environment, +) (map[string]provisioning.OutputParameter, error) { + args := m.Called(ctx, env) + + outputs, ok := args.Get(0).(map[string]provisioning.OutputParameter) + if ok { + return outputs, args.Error(1) + } + + return nil, args.Error(1) +} + +var mockDevCenterList []*devcentersdk.DevCenter = []*devcentersdk.DevCenter{ + { + //nolint:lll + Id: "/subscriptions/SUBSCRIPTION_01/resourceGroups/RESOURCE_GROUP_01/providers/Microsoft.DevCenter/devcenters/DEV_CENTER_01", + SubscriptionId: "SUBSCRIPTION_01", + ResourceGroup: "RESOURCE_GROUP_01", + Name: "DEV_CENTER_01", + ServiceUri: "https://DEV_CENTER_01.eastus2.devcenter.azure.com", + }, + { + //nolint:lll + Id: "/subscriptions/SUBSCRIPTION_02/resourceGroups/RESOURCE_GROUP_02/providers/Microsoft.DevCenter/devcenters/DEV_CENTER_02", + SubscriptionId: "SUBSCRIPTION_02", + ResourceGroup: "RESOURCE_GROUP_02", + Name: "DEV_CENTER_02", + ServiceUri: "https://DEV_CENTER_02.eastus2.devcenter.azure.com", + }, +} + +var mockProjects []*devcentersdk.Project = []*devcentersdk.Project{ + { + Id: "/projects/Project1", + Name: "Project1", + DevCenter: mockDevCenterList[0], + }, + { + Id: "/projects/Project2", + Name: "Project2", + DevCenter: mockDevCenterList[0], + }, + { + Id: "/projects/Project3", + Name: "Project3", + DevCenter: mockDevCenterList[1], + }, + { + Id: "/projects/Project4", + Name: "Project4", + DevCenter: mockDevCenterList[1], + }, +} + +var mockEnvironmentTypes []*devcentersdk.EnvironmentType = []*devcentersdk.EnvironmentType{ + { + Name: "EnvType_01", + DeploymentTargetId: "/subscriptions/SUBSCRIPTION_01/", + Status: "Enabled", + }, + { + Name: "EnvType_02", + DeploymentTargetId: "/subscriptions/SUBSCRIPTION_01/", + Status: "Enabled", + }, + { + Name: "EnvType_03", + DeploymentTargetId: "/subscriptions/SUBSCRIPTION_02/", + Status: "Enabled", + }, + { + Name: "EnvType_04", + DeploymentTargetId: "/subscriptions/SUBSCRIPTION_02/", + Status: "Enabled", + }, +} + +var mockEnvDefinitions []*devcentersdk.EnvironmentDefinition = []*devcentersdk.EnvironmentDefinition{ + { + Id: "/projects/Project1/catalogs/SampleCatalog/environmentDefinitions/WebApp", + Name: "EnvDefinition_01", + CatalogName: "SampleCatalog", + Description: "Description of WebApp", + TemplatePath: "azuredeploy.json", + Parameters: []devcentersdk.Parameter{ + { + Id: "repoUrl", + Name: "repoUrl", + Type: devcentersdk.ParameterTypeString, + Default: "https://github.com/Azure-Samples/todo-nodejs-mongo", + }, + }, + }, + { + Id: "/projects/Project1/catalogs/SampleCatalog/environmentDefinitions/EnvDefinition_02", + Name: "EnvDefinition_02", + CatalogName: "SampleCatalog", + Description: "Description of EnvDefinition_02", + TemplatePath: "azuredeploy.json", + Parameters: []devcentersdk.Parameter{ + { + Id: "repoUrl", + Name: "repoUrl", + Type: devcentersdk.ParameterTypeString, + Default: "https://github.com/Azure-Samples/todo-nodejs-mongo-aca", + }, + }, + }, + { + Id: "/projects/Project1/catalogs/SampleCatalog/environmentDefinitions/EnvDefinition_03", + Name: "EnvDefinition_03", + CatalogName: "SampleCatalog", + Description: "Description of EnvDefinition_03", + TemplatePath: "azuredeploy.json", + Parameters: []devcentersdk.Parameter{ + { + Id: "repoUrl", + Name: "repoUrl", + Type: devcentersdk.ParameterTypeString, + Default: "https://github.com/Azure-Samples/todo-nodejs-mongo-swa-func", + }, + }, + }, + { + Id: "/projects/Project1/catalogs/SampleCatalog/environmentDefinitions/EnvDefinition_04", + Name: "EnvDefinition_04", + CatalogName: "SampleCatalog", + Description: "Description of EnvDefinition_04", + TemplatePath: "azuredeploy.json", + Parameters: []devcentersdk.Parameter{ + { + Id: "repoUrl", + Name: "repoUrl", + Type: devcentersdk.ParameterTypeString, + Default: "https://github.com/Azure-Samples/todo-nodejs-mongo-swa-func", + }, + { + Id: "param01", + Name: "Param 1", + Type: devcentersdk.ParameterTypeString, + }, + { + Id: "param02", + Name: "Param 2", + Type: devcentersdk.ParameterTypeString, + }, + }, + }, +} diff --git a/cli/azd/pkg/devcenter/environment_store.go b/cli/azd/pkg/devcenter/environment_store.go new file mode 100644 index 0000000000..af35c19ca3 --- /dev/null +++ b/cli/azd/pkg/devcenter/environment_store.go @@ -0,0 +1,212 @@ +package devcenter + +import ( + "context" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/contracts" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" +) + +const ( + ConfigPath = "platform.config" + RemoteKindDevCenter environment.RemoteKind = "devcenter" +) + +// EnvironmentStore is a remote environment data store for devcenter environments +type EnvironmentStore struct { + config *Config + devCenterClient devcentersdk.DevCenterClient + prompter *Prompter + manager Manager +} + +// NewEnvironmentStore creates a new devcenter environment store +func NewEnvironmentStore( + config *Config, + devCenterClient devcentersdk.DevCenterClient, + prompter *Prompter, + manager Manager, +) environment.RemoteDataStore { + return &EnvironmentStore{ + config: config, + devCenterClient: devCenterClient, + prompter: prompter, + manager: manager, + } +} + +// EnvPath returns the path for the environment +func (s *EnvironmentStore) EnvPath(env *environment.Environment) string { + return fmt.Sprintf("projects/%s/users/me/environments/%s", s.config.Project, env.GetEnvName()) +} + +// ConfigPath returns the path for the environment configuration +func (s *EnvironmentStore) ConfigPath(env *environment.Environment) string { + return "" +} + +// List returns a list of environments for the devcenter configuration +func (s *EnvironmentStore) List(ctx context.Context) ([]*contracts.EnvListEnvironment, error) { + // If we don't have a valid devcenter configuration yet + // then prompt the user to initialize the correct configuration then provide the listing + if err := s.config.EnsureValid(); err != nil { + updatedConfig, err := s.prompter.PromptForConfig(ctx) + if err != nil { + return nil, fmt.Errorf("DevCenter configuration is not valid. Confirm your configuration and try again, %w", err) + } + + s.config = updatedConfig + } + + matches, err := s.matchingEnvironments(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to get devcenter environment list: %w", err) + } + + envListEnvs := []*contracts.EnvListEnvironment{} + for _, environment := range matches { + envListEnvs = append(envListEnvs, &contracts.EnvListEnvironment{ + Name: environment.Name, + DotEnvPath: environment.ResourceGroupId, + }) + } + + return envListEnvs, nil +} + +// Get returns the environment for the given name +func (s *EnvironmentStore) Get(ctx context.Context, name string) (*environment.Environment, error) { + // If the devcenter configuration is not valid then we don't have enough information to query for the environment + if err := s.config.EnsureValid(); err != nil { + return nil, fmt.Errorf("%s %w, %w", name, environment.ErrNotFound, err) + } + + filter := func(env *devcentersdk.Environment) bool { + return s.envDefFilter(env) && strings.EqualFold(env.Name, name) + } + + matchingEnvs, err := s.matchingEnvironments(ctx, filter) + if err != nil { + return nil, err + } + + if len(matchingEnvs) == 0 { + return nil, fmt.Errorf("%s %w", name, environment.ErrNotFound) + } + + if len(matchingEnvs) > 1 { + return nil, fmt.Errorf("multiple environments found with name '%s'", name) + } + + matchingEnv := matchingEnvs[0] + env := environment.New(matchingEnv.Name) + + if err := s.Reload(ctx, env); err != nil { + return nil, err + } + + return env, nil +} + +// Reload reloads the environment from the remote data store +func (s *EnvironmentStore) Reload(ctx context.Context, env *environment.Environment) error { + filter := func(e *devcentersdk.Environment) bool { + return s.envDefFilter(e) && strings.EqualFold(e.Name, env.GetEnvName()) + } + + envList, err := s.matchingEnvironments(ctx, filter) + if err != nil { + return err + } + + if len(envList) != 1 { + return environment.ErrNotFound + } + + environment, err := s.devCenterClient. + DevCenterByName(s.config.Name). + ProjectByName(s.config.Project). + EnvironmentsByUser(envList[0].User). + EnvironmentByName(env.GetEnvName()). + Get(ctx) + + if err != nil { + return fmt.Errorf("failed to get devcenter environment: %w", err) + } + + outputs, err := s.manager.Outputs(ctx, environment) + if err != nil { + return fmt.Errorf("failed to get environment outputs: %w", err) + } + + // Set the environment variables for the environment + for key, outputParam := range outputs { + env.DotenvSet(key, fmt.Sprintf("%v", outputParam.Value)) + } + + // Set the devcenter configuration for the environment + if err := env.Config.Set(DevCenterNamePath, s.config.Name); err != nil { + return err + } + if err := env.Config.Set(DevCenterProjectPath, s.config.Project); err != nil { + return err + } + if err := env.Config.Set(DevCenterCatalogPath, environment.CatalogName); err != nil { + return err + } + if err := env.Config.Set(DevCenterEnvTypePath, environment.EnvironmentType); err != nil { + return err + } + if err := env.Config.Set(DevCenterEnvDefinitionPath, environment.EnvironmentDefinitionName); err != nil { + return err + } + if err := env.Config.Set(DevCenterUserPath, environment.User); err != nil { + return err + } + + return nil +} + +// Save saves the environment to the remote data store +// DevCenter doesn't implement any APIs for saving environment configuration / metadata +// outside of the environment definition itself or the ARM deployment outputs +func (s *EnvironmentStore) Save(ctx context.Context, env *environment.Environment) error { + return nil +} + +// matchingEnvironments returns a list of environments matching the configured environment definition +func (s *EnvironmentStore) matchingEnvironments( + ctx context.Context, + filter EnvironmentFilterPredicate, +) ([]*devcentersdk.Environment, error) { + environmentListResponse, err := s.devCenterClient. + DevCenterByName(s.config.Name). + ProjectByName(s.config.Project). + Environments(). + Get(ctx) + + if err != nil { + return nil, fmt.Errorf("failed to get devcenter environment list: %w", err) + } + + if filter == nil { + filter = s.envDefFilter + } + + // Filter the environment list to those matching the configured environment definition + matches := []*devcentersdk.Environment{} + for _, environment := range environmentListResponse.Value { + if filter(environment) { + matches = append(matches, environment) + } + } + + return matches, nil +} + +func (s *EnvironmentStore) envDefFilter(env *devcentersdk.Environment) bool { + return env.EnvironmentDefinitionName == s.config.EnvironmentDefinition +} diff --git a/cli/azd/pkg/devcenter/environment_store_test.go b/cli/azd/pkg/devcenter/environment_store_test.go new file mode 100644 index 0000000000..85b034cd47 --- /dev/null +++ b/cli/azd/pkg/devcenter/environment_store_test.go @@ -0,0 +1,238 @@ +package devcenter + +import ( + "context" + "fmt" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockdevcentersdk" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var mockEnvironments []*devcentersdk.Environment = []*devcentersdk.Environment{ + { + ProvisioningState: "Succeeded", + ResourceGroupId: "/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP_NAME", + Name: "user01-project1-dev-01", + CatalogName: "SampleCatalog", + EnvironmentDefinitionName: "WebApp", + EnvironmentType: "Dev", + User: "me", + }, + { + ProvisioningState: "Succeeded", + ResourceGroupId: "/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP_NAME", + Name: "user01-project1-dev-02", + CatalogName: "SampleCatalog", + EnvironmentDefinitionName: "WebApp", + EnvironmentType: "Dev", + User: "me", + }, + { + ProvisioningState: "Succeeded", + ResourceGroupId: "/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP_NAME", + Name: "user01-project1-dev-03", + CatalogName: "SampleCatalog", + EnvironmentDefinitionName: "ContainerApp", + EnvironmentType: "Dev", + User: "me", + }, +} + +func Test_EnvironmentStore_List(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentsByProject(mockContext, "Project1", mockEnvironments) + + t.Run("AllMatchingEnvironments", func(t *testing.T) { + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "WebApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + store := newEnvironmentStoreForTest(t, mockContext, config, nil) + envList, err := store.List(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, envList) + require.Len(t, envList, 2) + }) + + t.Run("SomeMatchingEnvironments", func(t *testing.T) { + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "ContainerApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + store := newEnvironmentStoreForTest(t, mockContext, config, nil) + envList, err := store.List(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, envList) + require.Len(t, envList, 1) + }) + + t.Run("NoMatchingEnvironments", func(t *testing.T) { + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "FunctionApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + store := newEnvironmentStoreForTest(t, mockContext, config, nil) + envList, err := store.List(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, envList) + require.Len(t, envList, 0) + }) +} + +func Test_EnvironmentStore_Get(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentsByProject(mockContext, "Project1", mockEnvironments) + + t.Run("Exists", func(t *testing.T) { + mockdevcentersdk.MockGetEnvironment(mockContext, "Project1", "me", mockEnvironments[0].Name, mockEnvironments[0]) + + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "WebApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + outputs := map[string]provisioning.OutputParameter{ + "KEY1": { + Type: "string", + Value: "value1", + }, + "KEY2": { + Type: "string", + Value: "value2", + }, + } + + manager := &mockDevCenterManager{} + manager. + On("Outputs", *mockContext.Context, mock.AnythingOfType("*devcentersdk.Environment")). + Return(outputs, nil) + + store := newEnvironmentStoreForTest(t, mockContext, config, manager) + env, err := store.Get(*mockContext.Context, mockEnvironments[0].Name) + require.NoError(t, err) + require.NotNil(t, env) + require.Equal(t, mockEnvironments[0].Name, env.GetEnvName()) + require.Equal(t, "value1", env.Getenv("KEY1")) + require.Equal(t, "value2", env.Getenv("KEY2")) + + devCenterNode, ok := env.Config.Get("platform.config") + require.True(t, ok) + + envConfig, err := ParseConfig(devCenterNode) + require.NoError(t, err) + require.Equal(t, *envConfig, *config) + }) + + t.Run("DoesNotExist", func(t *testing.T) { + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "WebApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + store := newEnvironmentStoreForTest(t, mockContext, config, nil) + env, err := store.Get(*mockContext.Context, "not-found") + require.ErrorIs(t, err, environment.ErrNotFound) + require.Nil(t, env) + }) +} + +func Test_EnvironmentStore_GetEnvPath(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "WebApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + store := newEnvironmentStoreForTest(t, mockContext, config, nil) + env := environment.New(mockEnvironments[0].Name) + path := store.EnvPath(env) + require.Equal(t, fmt.Sprintf("projects/%s/users/me/environments/%s", config.Project, env.GetEnvName()), path) +} + +func Test_EnvironmentStore_Save(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + config := &Config{ + Name: "DEV_CENTER_01", + Project: "Project1", + EnvironmentDefinition: "WebApp", + Catalog: "SampleCatalog", + EnvironmentType: "Dev", + User: "me", + } + + store := newEnvironmentStoreForTest(t, mockContext, config, nil) + err := store.Save(*mockContext.Context, environment.New(mockEnvironments[0].Name)) + require.NoError(t, err) +} + +func newEnvironmentStoreForTest( + t *testing.T, + mockContext *mocks.MockContext, + config *Config, + manager Manager, +) environment.RemoteDataStore { + coreOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildCoreClientOptions() + + armOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildArmClientOptions() + + resourceGraphClient, err := armresourcegraph.NewClient(mockContext.Credentials, armOptions) + require.NoError(t, err) + + devCenterClient, err := devcentersdk.NewDevCenterClient( + mockContext.Credentials, + coreOptions, + resourceGraphClient, + ) + + require.NoError(t, err) + + if manager == nil { + manager = &mockDevCenterManager{} + } + prompter := NewPrompter(config, mockContext.Console, manager, devCenterClient) + + return NewEnvironmentStore(config, devCenterClient, prompter, manager) +} diff --git a/cli/azd/pkg/devcenter/manager.go b/cli/azd/pkg/devcenter/manager.go new file mode 100644 index 0000000000..4c726e1a84 --- /dev/null +++ b/cli/azd/pkg/devcenter/manager.go @@ -0,0 +1,323 @@ +package devcenter + +import ( + "context" + "fmt" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "go.uber.org/multierr" + "golang.org/x/exp/slices" +) + +// DeploymentFilterPredicate is a predicate function for filtering deployments +type DeploymentFilterPredicate func(d *armresources.DeploymentExtended) bool + +// ProjectFilterPredicate is a predicate function for filtering projects +type ProjectFilterPredicate func(p *devcentersdk.Project) bool + +// DevCenterFilterPredicate is a predicate function for filtering dev centers +type DevCenterFilterPredicate func(dc *devcentersdk.DevCenter) bool + +// EnvironmentFilterPredicate is a predicate function for filtering environments +type EnvironmentFilterPredicate func(e *devcentersdk.Environment) bool + +type Manager interface { + // WritableProjects gets a list of ADE projects that a user has write permissions + WritableProjects(ctx context.Context) ([]*devcentersdk.Project, error) + // WritableProjectsWithFilter gets a list of ADE projects that a user has write permissions for deployment + WritableProjectsWithFilter( + ctx context.Context, + devCenterFilter DevCenterFilterPredicate, + projectFilter ProjectFilterPredicate, + ) ([]*devcentersdk.Project, error) + // Deployment gets the Resource Group scoped deployment for the specified devcenter environment + Deployment( + ctx context.Context, + env *devcentersdk.Environment, + filter DeploymentFilterPredicate, + ) (infra.Deployment, error) + // LatestArmDeployment gets the latest ARM deployment for the specified devcenter environment + LatestArmDeployment( + ctx context.Context, + env *devcentersdk.Environment, + filter DeploymentFilterPredicate, + ) (*armresources.DeploymentExtended, error) + // Outputs gets the outputs for the specified devcenter environment + Outputs( + ctx context.Context, + env *devcentersdk.Environment, + ) (map[string]provisioning.OutputParameter, error) +} + +// Manager provides a common set of methods for interactive with a devcenter and its environments +type manager struct { + config *Config + client devcentersdk.DevCenterClient + deploymentsService azapi.Deployments + deploymentOperations azapi.DeploymentOperations +} + +// NewManager creates a new devcenter manager +func NewManager( + config *Config, + client devcentersdk.DevCenterClient, + deploymentsService azapi.Deployments, + deploymentOperations azapi.DeploymentOperations, +) Manager { + return &manager{ + config: config, + client: client, + deploymentsService: deploymentsService, + deploymentOperations: deploymentOperations, + } +} + +// WritableProjectsWithFilter gets a list of ADE projects that a user has write permissions for deployment +func (m *manager) WritableProjectsWithFilter( + ctx context.Context, + devCenterFilter DevCenterFilterPredicate, + projectFilter ProjectFilterPredicate, +) ([]*devcentersdk.Project, error) { + writableProjects, err := m.WritableProjects(ctx) + if err != nil { + return nil, err + } + + if devCenterFilter == nil { + devCenterFilter = func(dc *devcentersdk.DevCenter) bool { + return true + } + } + + if projectFilter == nil { + projectFilter = func(p *devcentersdk.Project) bool { + return true + } + } + + filteredProjects := []*devcentersdk.Project{} + for _, project := range writableProjects { + if devCenterFilter(project.DevCenter) && projectFilter(project) { + filteredProjects = append(filteredProjects, project) + } + } + + return filteredProjects, nil +} + +// Gets a list of ADE projects that a user has write permissions +// Write permissions of a project allow the user to create new environment in the project +func (m *manager) WritableProjects(ctx context.Context) ([]*devcentersdk.Project, error) { + devCenterList, err := m.client.DevCenters().Get(ctx) + if err != nil { + return nil, fmt.Errorf("failed getting dev centers: %w", err) + } + + projectsChan := make(chan *devcentersdk.Project) + errorsChan := make(chan error) + + // Perform the lookup and checking for projects in parallel to speed up the process + var wg sync.WaitGroup + + for _, devCenter := range devCenterList.Value { + wg.Add(1) + + go func(dc *devcentersdk.DevCenter) { + defer wg.Done() + + projects, err := m.client. + DevCenterByEndpoint(dc.ServiceUri). + Projects(). + Get(ctx) + + if err != nil { + errorsChan <- err + return + } + + for _, project := range projects.Value { + wg.Add(1) + + go func(p *devcentersdk.Project) { + defer wg.Done() + + hasWriteAccess := m.client. + DevCenterByEndpoint(p.DevCenter.ServiceUri). + ProjectByName(p.Name). + Permissions(). + HasWriteAccess(ctx) + + if hasWriteAccess { + projectsChan <- p + } + }(project) + } + }(devCenter) + } + + go func() { + wg.Wait() + close(projectsChan) + close(errorsChan) + }() + + var doneGroup sync.WaitGroup + doneGroup.Add(2) + + var allErrors error + writeableProjects := []*devcentersdk.Project{} + + go func() { + defer doneGroup.Done() + + for project := range projectsChan { + writeableProjects = append(writeableProjects, project) + } + }() + + go func() { + defer doneGroup.Done() + + for err := range errorsChan { + allErrors = multierr.Append(allErrors, err) + } + }() + + // Wait for all the projects and errors to be processed from channels + doneGroup.Wait() + + if allErrors != nil { + return nil, allErrors + } + + return writeableProjects, nil +} + +// Deployment gets the Resource Group scoped deployment for the specified devcenter environment +func (m *manager) Deployment( + ctx context.Context, + env *devcentersdk.Environment, + filter DeploymentFilterPredicate, +) (infra.Deployment, error) { + resourceGroupId, err := devcentersdk.NewResourceGroupId(env.ResourceGroupId) + if err != nil { + return nil, fmt.Errorf("failed parsing resource group id: %w", err) + } + + latestDeployment, err := m.LatestArmDeployment(ctx, env, filter) + if err != nil { + return nil, fmt.Errorf("failed getting latest deployment: %w", err) + } + + return infra.NewResourceGroupDeployment( + m.deploymentsService, + m.deploymentOperations, + resourceGroupId.SubscriptionId, + resourceGroupId.Name, + *latestDeployment.Name, + ), nil +} + +// LatestArmDeployment gets the latest ARM deployment for the specified devcenter environment +// When a filter is applied the latest deployment that matches the filter will be returned +func (m *manager) LatestArmDeployment( + ctx context.Context, + env *devcentersdk.Environment, + filter DeploymentFilterPredicate, +) (*armresources.DeploymentExtended, error) { + resourceGroupId, err := devcentersdk.NewResourceGroupId(env.ResourceGroupId) + if err != nil { + return nil, fmt.Errorf("failed parsing resource group id: %w", err) + } + + scope := infra.NewResourceGroupScope( + m.deploymentsService, + m.deploymentOperations, + resourceGroupId.SubscriptionId, + resourceGroupId.Name, + ) + + deployments, err := scope.ListDeployments(ctx) + if err != nil { + return nil, fmt.Errorf("failed listing deployments: %w", err) + } + + slices.SortFunc(deployments, func(x, y *armresources.DeploymentExtended) bool { + return x.Properties.Timestamp.After(*y.Properties.Timestamp) + }) + + latestDeploymentIndex := slices.IndexFunc(deployments, func(d *armresources.DeploymentExtended) bool { + tagDevCenterName, devCenterOk := d.Tags[DeploymentTagDevCenterName] + tagProjectName, projectOk := d.Tags[DeploymentTagDevCenterProject] + tagEnvTypeName, envTypeOk := d.Tags[DeploymentTagEnvironmentType] + tagEnvName, envOk := d.Tags[DeploymentTagEnvironmentName] + + if !devCenterOk || !projectOk || !envTypeOk || !envOk { + return false + } + + if *tagDevCenterName == m.config.Name || + *tagProjectName == m.config.Project || + *tagEnvTypeName == m.config.EnvironmentType || + *tagEnvName == env.Name { + + if filter == nil { + return true + } + + return filter(d) + } + + return false + }) + + if latestDeploymentIndex == -1 { + return nil, fmt.Errorf("failed to find latest deployment") + } + + return deployments[latestDeploymentIndex], nil +} + +// Outputs gets the outputs for the latest deployment of the specified environment +// Right now this will retrieve the outputs from the latest azure deployment +// Long term this will call into ADE Outputs API +func (m *manager) Outputs( + ctx context.Context, + env *devcentersdk.Environment, +) (map[string]provisioning.OutputParameter, error) { + resourceGroupId, err := devcentersdk.NewResourceGroupId(env.ResourceGroupId) + if err != nil { + return nil, fmt.Errorf("failed parsing resource group id: %w", err) + } + + latestDeployment, err := m.LatestArmDeployment(ctx, env, nil) + if err != nil { + return nil, fmt.Errorf("failed getting latest deployment: %w", err) + } + + outputs := createOutputParameters(azapi.CreateDeploymentOutput(latestDeployment.Properties.Outputs)) + + // Set up AZURE_SUBSCRIPTION_ID and AZURE_RESOURCE_GROUP environment variables + // These are required for azd deploy to work as expected + if _, exists := outputs[environment.SubscriptionIdEnvVarName]; !exists { + outputs[environment.SubscriptionIdEnvVarName] = provisioning.OutputParameter{ + Type: provisioning.ParameterTypeString, + Value: resourceGroupId.SubscriptionId, + } + } + + if _, exists := outputs[environment.ResourceGroupEnvVarName]; !exists { + outputs[environment.ResourceGroupEnvVarName] = provisioning.OutputParameter{ + Type: provisioning.ParameterTypeString, + Value: resourceGroupId.Name, + } + } + + return outputs, nil +} diff --git a/cli/azd/pkg/devcenter/platform.go b/cli/azd/pkg/devcenter/platform.go new file mode 100644 index 0000000000..c7b5a6d306 --- /dev/null +++ b/cli/azd/pkg/devcenter/platform.go @@ -0,0 +1,187 @@ +package devcenter + +import ( + "context" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "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/httputil" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "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/state" + "github.com/azure/azure-dev/cli/azd/pkg/templates" +) + +// Platform manages the Azd configuration of the devcenter platform +type Platform struct { + config *platform.Config +} + +func NewPlatform(config *platform.Config) platform.Provider { + return &Platform{ + config: config, + } +} + +// Name returns the name of the platform +func (p *Platform) Name() string { + return "devcenter" +} + +// IsEnabled returns true if the devcenter platform is enabled +func (p *Platform) IsEnabled() bool { + return p.config.Type == PlatformKindDevCenter +} + +// ConfigureContainer configures the IoC container for the devcenter platform components +func (p *Platform) ConfigureContainer(container *ioc.NestedContainer) error { + // DevCenter Config + container.RegisterSingleton(func( + ctx context.Context, + lazyAzdCtx *lazy.Lazy[*azdcontext.AzdContext], + userConfigManager config.UserConfigManager, + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], + lazyLocalEnvStore *lazy.Lazy[environment.LocalDataStore], + ) (*Config, error) { + // Load deventer configuration in the following precedence: + // 1. Environment variables (AZURE_DEVCENTER_*) + // 2. Azd Environment configuration (devCenter node) + // 3. Azd Project configuration from azure.yaml (devCenter node) + // 4. Azd user configuration from config.json (devCenter node) + + // Shell environment variables + envVarConfig := &Config{ + Name: os.Getenv(DevCenterCatalogEnvName), + Project: os.Getenv(DevCenterProjectEnvName), + Catalog: os.Getenv(DevCenterCatalogEnvName), + EnvironmentType: os.Getenv(DevCenterEnvTypeEnvName), + EnvironmentDefinition: os.Getenv(DevCenterEnvDefinitionEnvName), + User: os.Getenv(DevCenterEnvUser), + } + + azdCtx, _ := lazyAzdCtx.GetValue() + localEnvStore, _ := lazyLocalEnvStore.GetValue() + + // Local environment configuration + var environmentConfig *Config + if azdCtx != nil && localEnvStore != nil { + defaultEnvName, err := azdCtx.GetDefaultEnvironmentName() + if err != nil { + environmentConfig = &Config{} + } else { + // Attempt to load any devcenter configuration from local environment + env, err := localEnvStore.Get(ctx, defaultEnvName) + if err == nil { + devCenterNode, exists := env.Config.Get(ConfigPath) + if exists { + value, err := ParseConfig(devCenterNode) + if err != nil { + return nil, err + } + + environmentConfig = value + } + } + } + } + + // User Configuration + var userConfig *Config + azdConfig, err := userConfigManager.Load() + if err != nil { + userConfig = &Config{} + } else { + devCenterNode, exists := azdConfig.Get(ConfigPath) + if exists { + value, err := ParseConfig(devCenterNode) + if err != nil { + return nil, err + } + + userConfig = value + } + } + + // Project Configuration + var projectConfig *Config + projConfig, _ := lazyProjectConfig.GetValue() + if projConfig != nil && projConfig.Platform != nil { + value, err := ParseConfig(projConfig.Platform.Config) + if err == nil { + projectConfig = value + } + } + + return MergeConfigs( + envVarConfig, + environmentConfig, + projectConfig, + userConfig, + ), nil + }) + + // Override default provision provider + container.RegisterSingleton(func() provisioning.DefaultProviderResolver { + return func() (provisioning.ProviderKind, error) { + return ProvisionKindDevCenter, nil + } + }) + + // Override default template sources + container.RegisterSingleton(func() *templates.SourceOptions { + return &templates.SourceOptions{ + DefaultSources: []*templates.SourceConfig{SourceDevCenter}, + LoadConfiguredSources: false, + } + }) + + // Configure remote environment storage + container.RegisterSingleton(func() *state.RemoteConfig { + return &state.RemoteConfig{ + Backend: string(RemoteKindDevCenter), + } + }) + + // Provision Provider + if err := container.RegisterNamedSingleton(string(ProvisionKindDevCenter), NewProvisionProvider); err != nil { + return err + } + + // Remote Environment Storage + if err := container.RegisterNamedSingleton(string(RemoteKindDevCenter), NewEnvironmentStore); err != nil { + return err + } + + // Template Sources + if err := container.RegisterNamedSingleton(string(SourceKindDevCenter), NewTemplateSource); err != nil { + return err + } + + container.RegisterSingleton(NewManager) + container.RegisterSingleton(NewPrompter) + + // Other devcenter components + container.RegisterSingleton(func( + ctx context.Context, + credential azcore.TokenCredential, + httpClient httputil.HttpClient, + resourceGraphClient *armresourcegraph.Client, + ) (devcentersdk.DevCenterClient, error) { + options := azsdk. + DefaultClientOptionsBuilder(ctx, httpClient, "azd"). + BuildCoreClientOptions() + + return devcentersdk.NewDevCenterClient(credential, options, resourceGraphClient) + }) + + return nil +} diff --git a/cli/azd/pkg/devcenter/platform_test.go b/cli/azd/pkg/devcenter/platform_test.go new file mode 100644 index 0000000000..00602db5bf --- /dev/null +++ b/cli/azd/pkg/devcenter/platform_test.go @@ -0,0 +1,52 @@ +package devcenter + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/platform" + "github.com/stretchr/testify/require" +) + +func Test_Platform_IsEnabled(t *testing.T) { + t.Run("Enabled", func(t *testing.T) { + config := &platform.Config{ + Type: PlatformKindDevCenter, + } + + devCenterPlatform := NewPlatform(config) + require.True(t, devCenterPlatform.IsEnabled()) + }) + t.Run("Disabled", func(t *testing.T) { + config := &platform.Config{ + Type: platform.PlatformKind("default"), + } + + devCenterPlatform := NewPlatform(config) + require.False(t, devCenterPlatform.IsEnabled()) + }) +} + +func Test_Platform_ConfigureContainer(t *testing.T) { + t.Run("Success", func(t *testing.T) { + config := &platform.Config{ + Type: PlatformKindDevCenter, + } + + devCenterPlatform := NewPlatform(config) + container := ioc.NewNestedContainer(nil) + err := devCenterPlatform.ConfigureContainer(container) + require.NoError(t, err) + + var provisionResolver provisioning.DefaultProviderResolver + err = container.Resolve(&provisionResolver) + require.NoError(t, err) + require.NotNil(t, provisionResolver) + + expected := ProvisionKindDevCenter + actual, err := provisionResolver() + require.NoError(t, err) + require.Equal(t, expected, actual) + }) +} diff --git a/cli/azd/pkg/devcenter/prompter.go b/cli/azd/pkg/devcenter/prompter.go new file mode 100644 index 0000000000..acf124bf31 --- /dev/null +++ b/cli/azd/pkg/devcenter/prompter.go @@ -0,0 +1,299 @@ +package devcenter + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "golang.org/x/exp/slices" +) + +// Prompter provides a common set of methods for prompting the user for devcenter configuration values +type Prompter struct { + config *Config + console input.Console + manager Manager + devCenterClient devcentersdk.DevCenterClient +} + +// NewPrompter creates a new devcenter prompter +func NewPrompter( + config *Config, + console input.Console, + manager Manager, + devCenterClient devcentersdk.DevCenterClient, +) *Prompter { + return &Prompter{ + config: config, + console: console, + manager: manager, + devCenterClient: devCenterClient, + } +} + +// PromptForConfig prompts the user for devcenter configuration values that have not been previously set +func (p *Prompter) PromptForConfig(ctx context.Context) (*Config, error) { + if p.config.Project == "" { + project, err := p.PromptProject(ctx, p.config.Name) + if err != nil { + return nil, err + } + p.config.Name = project.DevCenter.Name + p.config.Project = project.Name + } + + if p.config.EnvironmentDefinition == "" { + envDefinition, err := p.PromptEnvironmentDefinition(ctx, p.config.Name, p.config.Project) + if err != nil { + return nil, err + } + p.config.Catalog = envDefinition.CatalogName + p.config.EnvironmentDefinition = envDefinition.Name + } + + return p.config, nil +} + +// PromptProject prompts the user to select a project for the specified devcenter +// If the user only has access to a single project, then that project will be returned +func (p *Prompter) PromptProject(ctx context.Context, devCenterName string) (*devcentersdk.Project, error) { + writeableProjects, err := p.manager.WritableProjects(ctx) + if err != nil { + return nil, err + } + + slices.SortFunc(writeableProjects, func(x, y *devcentersdk.Project) bool { + return x.Name < y.Name + }) + + // Filter to only projects that match the specified devcenter + filteredProjects := []*devcentersdk.Project{} + for _, project := range writeableProjects { + if devCenterName == "" || strings.EqualFold(devCenterName, project.DevCenter.Name) { + filteredProjects = append(filteredProjects, project) + } + } + + duplicateNames := []string{} + projectNames := []string{} + for _, project := range filteredProjects { + if slices.Contains(projectNames, project.Name) { + duplicateNames = append(duplicateNames, project.Name) + } + + projectNames = append(projectNames, project.Name) + } + + // Update display name of any duplicate project names + if len(duplicateNames) > 0 { + for index, project := range filteredProjects { + if slices.Contains(duplicateNames, project.Name) { + projectNames[index] = fmt.Sprintf("%s (%s)", project.Name, project.DevCenter.Name) + } + } + } + + if len(projectNames) == 1 { + return filteredProjects[0], nil + } + + selected, err := p.console.Select(ctx, input.ConsoleOptions{ + Message: "Select a project:", + Options: projectNames, + }) + + if err != nil { + return nil, err + } + + return filteredProjects[selected], nil +} + +// PromptEnvironmentType prompts the user to select an environment type for the specified devcenter and project +// If the user only has access to a single environment type, then that environment type will be returned +func (p *Prompter) PromptEnvironmentType( + ctx context.Context, + devCenterName string, + projectName string, +) (*devcentersdk.EnvironmentType, error) { + envTypesResponse, err := p.devCenterClient. + DevCenterByName(devCenterName). + ProjectByName(projectName). + EnvironmentTypes(). + Get(ctx) + + if err != nil { + return nil, err + } + + envTypes := envTypesResponse.Value + slices.SortFunc(envTypes, func(x, y *devcentersdk.EnvironmentType) bool { + return x.Name < y.Name + }) + + envTypeNames := []string{} + for _, envType := range envTypesResponse.Value { + envTypeNames = append(envTypeNames, envType.Name) + } + + if len(envTypeNames) == 1 { + return envTypes[0], nil + } + + selected, err := p.console.Select(ctx, input.ConsoleOptions{ + Message: "Select an environment type:", + Options: envTypeNames, + }) + + if err != nil { + return nil, err + } + + return envTypes[selected], nil +} + +// PromptEnvironmentDefinition prompts the user to select an environment definition for the specified devcenter and project +func (p *Prompter) PromptEnvironmentDefinition( + ctx context.Context, + devCenterName, projectName string, +) (*devcentersdk.EnvironmentDefinition, error) { + envDefinitionsResponse, err := p.devCenterClient. + DevCenterByName(devCenterName). + ProjectByName(projectName). + EnvironmentDefinitions(). + Get(ctx) + + if err != nil { + return nil, err + } + + environmentDefinitions := envDefinitionsResponse.Value + slices.SortFunc(environmentDefinitions, func(x, y *devcentersdk.EnvironmentDefinition) bool { + return x.Name < y.Name + }) + + duplicateNames := []string{} + envDefinitionNames := []string{} + for _, envDefinition := range environmentDefinitions { + if slices.Contains(envDefinitionNames, envDefinition.Name) { + duplicateNames = append(duplicateNames, envDefinition.Name) + } + + envDefinitionNames = append(envDefinitionNames, envDefinition.Name) + } + + // Update display name of any duplicate environment definition names + if len(duplicateNames) > 0 { + for index, envDefinition := range environmentDefinitions { + if slices.Contains(duplicateNames, envDefinition.Name) { + envDefinitionNames[index] = fmt.Sprintf("%s (%s)", envDefinition.Name, envDefinition.CatalogName) + } + } + } + + selected, err := p.console.Select(ctx, input.ConsoleOptions{ + Message: "Select an environment definition:", + Options: envDefinitionNames, + }) + + if err != nil { + return nil, err + } + + return environmentDefinitions[selected], nil +} + +// Prompts the user for values defined within the environment definition parameters +// Responses for prompt are stored in azd environment configuration and used for future provisioning operations +func (p *Prompter) PromptParameters( + ctx context.Context, + env *environment.Environment, + envDef *devcentersdk.EnvironmentDefinition, +) (map[string]any, error) { + paramValues := map[string]any{} + + for _, param := range envDef.Parameters { + paramPath := fmt.Sprintf("%s.%s", ProvisionParametersConfigPath, param.Id) + paramValue, exists := env.Config.Get(paramPath) + + // Only prompt for parameter values when it has not already been set in the environment configuration + if !exists { + if param.Name == "environmentName" { + paramValues[param.Id] = env.GetEnvName() + continue + } + + // Process repoUrl parameter from defaults and allowed values + if param.Name == "repoUrl" { + var repoUrlValue string + if len(param.Allowed) > 0 { + repoUrlValue = param.Allowed[0] + } else { + value, ok := param.Default.(string) + if ok { + repoUrlValue = value + } + } + + if repoUrlValue != "" { + paramValues[param.Id] = repoUrlValue + continue + } + } + + promptOptions := input.ConsoleOptions{ + DefaultValue: param.Default, + Options: param.Allowed, + Message: fmt.Sprintf("Enter a value for %s", param.Name), + Help: param.Description, + } + + switch param.Type { + case devcentersdk.ParameterTypeBool: + confirmValue, err := p.console.Confirm(ctx, promptOptions) + if err != nil { + return nil, fmt.Errorf("failed to prompt for %s: %w", param.Name, err) + } + paramValue = confirmValue + case devcentersdk.ParameterTypeString: + if param.Allowed != nil && len(param.Allowed) > 0 { + selectedIndex, err := p.console.Select(ctx, promptOptions) + if err != nil { + return nil, fmt.Errorf("failed to prompt for %s: %w", param.Name, err) + } + + paramValue = param.Allowed[selectedIndex] + } else { + promptValue, err := p.console.Prompt(ctx, promptOptions) + if err != nil { + return nil, err + } + + paramValue = promptValue + } + + case devcentersdk.ParameterTypeInt: + promptValue, err := p.console.Prompt(ctx, promptOptions) + if err != nil { + return nil, fmt.Errorf("failed to prompt for %s: %w", param.Name, err) + } + + numValue, err := strconv.Atoi(promptValue) + if err != nil { + return nil, fmt.Errorf("failed to convert %s to int: %w", param.Name, err) + } + paramValue = numValue + default: + return nil, fmt.Errorf("failed to prompt for %s, unsupported parameter type: %s", param.Name, param.Type) + } + } + + paramValues[param.Id] = paramValue + } + + return paramValues, nil +} diff --git a/cli/azd/pkg/devcenter/prompter_test.go b/cli/azd/pkg/devcenter/prompter_test.go new file mode 100644 index 0000000000..eb2bbf65f4 --- /dev/null +++ b/cli/azd/pkg/devcenter/prompter_test.go @@ -0,0 +1,353 @@ +package devcenter + +import ( + "context" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockdevcentersdk" + "github.com/stretchr/testify/require" +) + +func Test_Prompt_Project(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + selectedDevCenter := mockDevCenterList[0] + selectedProjectIndex := 1 + + manager := &mockDevCenterManager{} + manager. + On("WritableProjects", *mockContext.Context). + Return(mockProjects, nil) + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Select a project") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + return selectedProjectIndex, nil + }) + + prompter := newPrompterForTest(t, mockContext, &Config{}, manager) + selectedProject, err := prompter.PromptProject(*mockContext.Context, selectedDevCenter.Name) + require.NoError(t, err) + require.NotNil(t, selectedProject) + require.Equal(t, mockProjects[selectedProjectIndex], selectedProject) +} + +func Test_Prompt_EnvironmentType(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + selectedDevCenter := mockDevCenterList[0] + selectedProject := mockProjects[1] + + selectedIndex := 3 + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentTypes(mockContext, selectedProject.Name, mockEnvironmentTypes) + + manager := &mockDevCenterManager{} + manager. + On("WritableProjects", *mockContext.Context). + Return(mockProjects, nil) + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Select an environment type") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + return selectedIndex, nil + }) + + prompter := newPrompterForTest(t, mockContext, &Config{}, manager) + selectedEnvironmentType, err := prompter.PromptEnvironmentType( + *mockContext.Context, + selectedDevCenter.Name, + selectedProject.Name, + ) + require.NoError(t, err) + require.NotNil(t, selectedEnvironmentType) + require.Equal(t, mockEnvironmentTypes[selectedIndex], selectedEnvironmentType) +} + +func Test_Prompt_EnvironmentDefinitions(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + selectedDevCenter := mockDevCenterList[0] + selectedProject := mockProjects[1] + + selectedIndex := 2 + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, selectedProject.Name, mockEnvDefinitions) + + manager := &mockDevCenterManager{} + manager. + On("WritableProjects", *mockContext.Context). + Return(mockProjects, nil) + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Select an environment definition") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + return selectedIndex, nil + }) + + prompter := newPrompterForTest(t, mockContext, &Config{}, manager) + selectedEnvironmentType, err := prompter.PromptEnvironmentDefinition( + *mockContext.Context, + selectedDevCenter.Name, + selectedProject.Name, + ) + require.NoError(t, err) + require.NotNil(t, selectedEnvironmentType) + require.Equal(t, mockEnvDefinitions[selectedIndex], selectedEnvironmentType) +} + +func Test_Prompt_Config(t *testing.T) { + t.Run("AllValuesSet", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + selectedDevCenter := mockDevCenterList[0] + selectedProject := mockProjects[1] + selectedEnvDefinition := mockEnvDefinitions[2] + + config := &Config{ + Name: selectedDevCenter.Name, + Project: selectedProject.Name, + EnvironmentDefinition: selectedEnvDefinition.Name, + Catalog: selectedEnvDefinition.CatalogName, + } + + prompter := newPrompterForTest(t, mockContext, config, nil) + config, err := prompter.PromptForConfig(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, config) + require.Equal(t, selectedDevCenter.Name, config.Name) + require.Equal(t, selectedProject.Name, config.Project) + require.Equal(t, selectedEnvDefinition.Name, config.EnvironmentDefinition) + require.Equal(t, selectedEnvDefinition.CatalogName, config.Catalog) + }) + + t.Run("NoValuesSet", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + selectedDevCenter := mockDevCenterList[0] + selectedProject := mockProjects[1] + selectedEnvDefinition := mockEnvDefinitions[2] + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, selectedProject.Name, mockEnvDefinitions) + + manager := &mockDevCenterManager{} + manager. + On("WritableProjects", *mockContext.Context). + Return(mockProjects, nil) + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "project") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + return 1, nil + }) + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "environment definition") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + return 2, nil + }) + + prompter := newPrompterForTest(t, mockContext, &Config{}, manager) + config, err := prompter.PromptForConfig(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, config) + require.Equal(t, selectedDevCenter.Name, config.Name) + require.Equal(t, selectedProject.Name, config.Project) + require.Equal(t, selectedEnvDefinition.Name, config.EnvironmentDefinition) + require.Equal(t, selectedEnvDefinition.CatalogName, config.Catalog) + }) +} + +func Test_Prompt_Parameters(t *testing.T) { + type paramWithValue struct { + devcentersdk.Parameter + userValue any + expectedValue any + } + + t.Run("MultipleParameters", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + promptedParams := map[string]bool{} + + expectedValues := map[string]paramWithValue{ + "param1": { + Parameter: devcentersdk.Parameter{Id: "param1", Name: "Param 1", Type: devcentersdk.ParameterTypeString}, + userValue: "value1", + expectedValue: "value1", + }, + "param2": { + Parameter: devcentersdk.Parameter{Id: "param2", Name: "Param 2", Type: devcentersdk.ParameterTypeString}, + userValue: "value2", + expectedValue: "value2", + }, + "param3": { + Parameter: devcentersdk.Parameter{Id: "param3", Name: "Param 3", Type: devcentersdk.ParameterTypeBool}, + userValue: true, + expectedValue: true, + }, + "param4": { + Parameter: devcentersdk.Parameter{Id: "param4", Name: "Param 4", Type: devcentersdk.ParameterTypeInt}, + userValue: "123", + expectedValue: 123, + }, + } + + var mockPrompt = func(key string, param paramWithValue) { + if param.Type == devcentersdk.ParameterTypeBool { + mockContext.Console.WhenConfirm(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, param.Name) + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + promptedParams[key] = true + return param.userValue, nil + }) + } else { + mockContext.Console.WhenPrompt(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, param.Name) + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + promptedParams[key] = true + return param.userValue, nil + }) + } + } + + for key, param := range expectedValues { + mockPrompt(key, param) + } + + env := environment.New("Test") + envDefinition := &devcentersdk.EnvironmentDefinition{ + Parameters: []devcentersdk.Parameter{ + { + Id: "param1", + Name: "Param 1", + Type: devcentersdk.ParameterTypeString, + }, + { + Id: "param2", + Name: "Param 2", + Type: devcentersdk.ParameterTypeString, + }, + { + Id: "param3", + Name: "Param 3", + Type: devcentersdk.ParameterTypeBool, + }, + { + Id: "param4", + Name: "Param 4", + Type: devcentersdk.ParameterTypeInt, + }, + }, + } + + prompter := newPrompterForTest(t, mockContext, &Config{}, nil) + values, err := prompter.PromptParameters(*mockContext.Context, env, envDefinition) + require.NoError(t, err) + + for key, value := range values { + require.Equal(t, expectedValues[key].expectedValue, value) + } + }) + + t.Run("WithSomeSetValues", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + prompter := newPrompterForTest(t, mockContext, &Config{}, nil) + promptCalled := false + + // Only mock response for param 3 + mockContext.Console.WhenPrompt(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Param 3") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + promptCalled = true + return "value3", nil + }) + + env := environment.New("Test") + envDefinition := &devcentersdk.EnvironmentDefinition{ + Parameters: []devcentersdk.Parameter{ + { + Id: "param1", + Name: "Param 1", + Type: devcentersdk.ParameterTypeString, + }, + { + Id: "param2", + Name: "Param 2", + Type: devcentersdk.ParameterTypeString, + }, + { + Id: "param3", + Name: "Param 3", + Type: devcentersdk.ParameterTypeString, + }, + }, + } + + _ = env.Config.Set("provision.parameters.param1", "value1") + _ = env.Config.Set("provision.parameters.param2", "value2") + + values, err := prompter.PromptParameters(*mockContext.Context, env, envDefinition) + require.NoError(t, err) + require.True(t, promptCalled) + require.Equal(t, "value1", values["param1"]) + require.Equal(t, "value2", values["param2"]) + require.Equal(t, "value3", values["param3"]) + }) + + t.Run("WithAllSetValues", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + prompter := newPrompterForTest(t, mockContext, &Config{}, nil) + + env := environment.New("Test") + envDefinition := &devcentersdk.EnvironmentDefinition{ + Parameters: []devcentersdk.Parameter{ + { + Id: "param1", + Name: "Param 1", + Type: devcentersdk.ParameterTypeString, + }, + { + Id: "param2", + Name: "Param 2", + Type: devcentersdk.ParameterTypeString, + }, + }, + } + + _ = env.Config.Set("provision.parameters.param1", "value1") + _ = env.Config.Set("provision.parameters.param2", "value2") + + values, err := prompter.PromptParameters(*mockContext.Context, env, envDefinition) + require.NoError(t, err) + require.Equal(t, "value1", values["param1"]) + require.Equal(t, "value2", values["param2"]) + }) +} + +func newPrompterForTest(t *testing.T, mockContext *mocks.MockContext, config *Config, manager Manager) *Prompter { + coreOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildCoreClientOptions() + + armOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildArmClientOptions() + + resourceGraphClient, err := armresourcegraph.NewClient(mockContext.Credentials, armOptions) + require.NoError(t, err) + + devCenterClient, err := devcentersdk.NewDevCenterClient( + mockContext.Credentials, + coreOptions, + resourceGraphClient, + ) + + require.NoError(t, err) + + return NewPrompter(config, mockContext.Console, manager, devCenterClient) +} diff --git a/cli/azd/pkg/devcenter/provision_provider.go b/cli/azd/pkg/devcenter/provision_provider.go new file mode 100644 index 0000000000..1c387afdce --- /dev/null +++ b/cli/azd/pkg/devcenter/provision_provider.go @@ -0,0 +1,553 @@ +package devcenter + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "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/output" + "golang.org/x/exp/maps" +) + +const ( + ProvisionParametersConfigPath string = "provision.parameters" + ProvisionKindDevCenter provisioning.ProviderKind = "devcenter" + + // ADE environment ARM deployment tags + DeploymentTagDevCenterName = "AdeDevCenterName" + DeploymentTagDevCenterProject = "AdeProjectName" + DeploymentTagEnvironmentType = "AdeEnvironmentTypeName" + DeploymentTagEnvironmentName = "AdeEnvironmentName" +) + +// ProvisionProvider is a devcenter provider for provisioning ADE environments +type ProvisionProvider struct { + console input.Console + env *environment.Environment + envManager environment.Manager + config *Config + devCenterClient devcentersdk.DevCenterClient + resourceManager *infra.AzureResourceManager + manager Manager + prompter *Prompter + options provisioning.Options +} + +// NewProvisionProvider creates a new devcenter provider +func NewProvisionProvider( + console input.Console, + env *environment.Environment, + envManager environment.Manager, + config *Config, + devCenterClient devcentersdk.DevCenterClient, + resourceManager *infra.AzureResourceManager, + manager Manager, + prompter *Prompter, +) provisioning.Provider { + return &ProvisionProvider{ + console: console, + env: env, + envManager: envManager, + config: config, + devCenterClient: devCenterClient, + resourceManager: resourceManager, + manager: manager, + prompter: prompter, + } +} + +// Name returns the name of the provider +func (p *ProvisionProvider) Name() string { + return "Dev Center" +} + +// Initialize initializes the provider +func (p *ProvisionProvider) Initialize(ctx context.Context, projectPath string, options provisioning.Options) error { + p.options = options + + return p.EnsureEnv(ctx) +} + +// State returns the state of the environment from the most recent ARM deployment +func (p *ProvisionProvider) State( + ctx context.Context, + options *provisioning.StateOptions, +) (*provisioning.StateResult, error) { + if err := p.config.EnsureValid(); err != nil { + return nil, fmt.Errorf("invalid devcenter configuration, %w", err) + } + + envName := p.env.GetEnvName() + environment, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + Get(ctx) + + if err != nil { + return nil, fmt.Errorf("failed getting environment: %w", err) + } + + outputs, err := p.manager.Outputs(ctx, environment) + if err != nil { + return nil, fmt.Errorf("failed getting environment outputs: %w", err) + } + + return &provisioning.StateResult{ + State: &provisioning.State{ + Outputs: outputs, + }, + }, nil +} + +// Deploy deploys the environment from the configured environment definition +func (p *ProvisionProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, error) { + if err := p.config.EnsureValid(); err != nil { + return nil, fmt.Errorf("invalid devcenter configuration, %w", err) + } + + if hasInfraTemplates(p.options.Path) { + //nolint:lll + warningMsg := fmt.Sprintf( + "WARNING: IaC templates were found at '%s'. IaC templates are not supported for Dev Center environments and will be ignored.\n", + p.options.Path, + ) + + p.console.Message( + ctx, + output.WithWarningFormat(warningMsg), + ) + } + + envDef, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + CatalogByName(p.config.Catalog). + EnvironmentDefinitionByName(p.config.EnvironmentDefinition). + Get(ctx) + + if err != nil { + return nil, fmt.Errorf("failed getting environment definition: %w", err) + } + + paramValues, err := p.prompter.PromptParameters(ctx, p.env, envDef) + if err != nil { + return nil, fmt.Errorf("failed prompting for parameters: %w", err) + } + + for key, value := range paramValues { + path := fmt.Sprintf("%s.%s", ProvisionParametersConfigPath, key) + if err := p.env.Config.Set(path, value); err != nil { + return nil, fmt.Errorf("failed setting config value %s: %w", path, err) + } + } + + if err := p.envManager.Save(ctx, p.env); err != nil { + return nil, fmt.Errorf("failed saving environment: %w", err) + } + + envName := p.env.GetEnvName() + + // Check to see if an existing devcenter environment already exists + existingEnv, _ := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + Get(ctx) + + var spinnerMessage string + if existingEnv == nil { + spinnerMessage = fmt.Sprintf("Creating devcenter environment %s", output.WithHighLightFormat(envName)) + } else { + spinnerMessage = fmt.Sprintf("Updating devcenter environment %s", output.WithHighLightFormat(envName)) + } + + envSpec := devcentersdk.EnvironmentSpec{ + CatalogName: p.config.Catalog, + EnvironmentType: p.config.EnvironmentType, + EnvironmentDefinitionName: p.config.EnvironmentDefinition, + Parameters: paramValues, + } + + p.console.ShowSpinner(ctx, spinnerMessage, input.Step) + + poller, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + BeginPut(ctx, envSpec) + + if err != nil { + p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) + return nil, fmt.Errorf("failed creating environment: %w", err) + } + + p.console.StopSpinner(ctx, spinnerMessage, input.StepDone) + + pollingContext, cancel := context.WithCancel(ctx) + defer cancel() + + spinnerMessage = "Deploying dev center environment" + p.console.ShowSpinner(ctx, spinnerMessage, input.Step) + + go p.pollForEnvironment(pollingContext, envName) + + _, err = poller.PollUntilDone(ctx, nil) + if err != nil { + p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) + return nil, fmt.Errorf("failed creating environment: %w", err) + } + + environment, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + Get(ctx) + + if err != nil { + p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) + return nil, fmt.Errorf("failed getting environment: %w", err) + } + + p.console.StopSpinner(ctx, spinnerMessage, input.StepDone) + + outputs, err := p.manager.Outputs(ctx, environment) + if err != nil { + return nil, fmt.Errorf("failed getting environment outputs: %w", err) + } + + result := &provisioning.DeployResult{ + Deployment: &provisioning.Deployment{ + Parameters: createInputParameters(envDef, paramValues), + Outputs: outputs, + }, + } + + return result, nil +} + +// Preview previews the deployment of the environment from the configured environment definition +func (p *ProvisionProvider) Preview(ctx context.Context) (*provisioning.DeployPreviewResult, error) { + return nil, fmt.Errorf("preview is not supported for devcenter") +} + +// Destroy destroys the environment by deleting the ADE environment +func (p *ProvisionProvider) Destroy( + ctx context.Context, + options provisioning.DestroyOptions, +) (*provisioning.DestroyResult, error) { + if err := p.config.EnsureValid(); err != nil { + return nil, fmt.Errorf("invalid devcenter configuration, %w", err) + } + + envName := p.env.GetEnvName() + spinnerMessage := fmt.Sprintf("Deleting devcenter environment %s", output.WithHighLightFormat(envName)) + + if !options.Force() { + warningMessage := output.WithWarningFormat( + "WARNING: This will delete the following Dev Center environment and all of its resources:\n", + ) + p.console.Message(ctx, warningMessage) + + p.console.Message(ctx, fmt.Sprintf("Dev Center: %s", output.WithHighLightFormat(p.config.Name))) + p.console.Message(ctx, fmt.Sprintf("Project: %s", output.WithHighLightFormat(p.config.Project))) + p.console.Message(ctx, fmt.Sprintf("Environment Type: %s", output.WithHighLightFormat(p.config.EnvironmentType))) + p.console.Message(ctx, + fmt.Sprintf("Environment Definition: %s", output.WithHighLightFormat(p.config.EnvironmentDefinition)), + ) + p.console.Message(ctx, fmt.Sprintf("Environment: %s\n", output.WithHighLightFormat(envName))) + + confirm, err := p.console.Confirm(ctx, input.ConsoleOptions{ + Message: "Are you sure you want to continue?", + DefaultValue: false, + }) + + if err != nil { + p.console.Message(ctx, "") + p.console.ShowSpinner(ctx, spinnerMessage, input.Step) + p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) + return nil, fmt.Errorf("destroy operation interrupted: %w", err) + } + + p.console.Message(ctx, "\n") + + if !confirm { + p.console.ShowSpinner(ctx, spinnerMessage, input.Step) + p.console.StopSpinner(ctx, spinnerMessage, input.StepSkipped) + return nil, fmt.Errorf("destroy operation cancelled") + } + } + + devCenterEnv, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + Get(ctx) + + if err != nil { + return nil, fmt.Errorf("failed getting devcenter environment: %w", err) + } + + // Get environment outputs to invalidate them after destroy + outputs, err := p.manager.Outputs(ctx, devCenterEnv) + if err != nil { + return nil, fmt.Errorf("failed getting environment outputs: %w", err) + } + + p.console.ShowSpinner(ctx, spinnerMessage, input.Step) + + poller, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + BeginDelete(ctx) + + if err != nil { + p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) + return nil, fmt.Errorf("failed deleting environment: %w", err) + } + + _, err = poller.PollUntilDone(ctx, nil) + if err != nil { + p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) + return nil, fmt.Errorf("failed deleting environment: %w", err) + } + + p.console.StopSpinner(ctx, spinnerMessage, input.StepDone) + + result := &provisioning.DestroyResult{ + InvalidatedEnvKeys: maps.Keys(outputs), + } + + return result, nil +} + +// EnsureEnv ensures that the environment is configured for the Dev Center provider. +// Require selection for devcenter, project, catalog, environment type, and environment definition +func (p *ProvisionProvider) EnsureEnv(ctx context.Context) error { + // Cache config values prior to prompting user + currentConfig := *p.config + updatedConfig, err := p.prompter.PromptForConfig(ctx) + if err != nil { + return err + } + + if updatedConfig.EnvironmentType == "" { + envType, err := p.prompter.PromptEnvironmentType(ctx, updatedConfig.Name, updatedConfig.Project) + if err != nil { + return err + } + updatedConfig.EnvironmentType = envType.Name + } + + if updatedConfig.User == "" { + updatedConfig.User = "me" + } + + // Set any missing config values in environment configuration for future use + if currentConfig.Name == "" { + if err := p.env.Config.Set(DevCenterNamePath, updatedConfig.Name); err != nil { + return err + } + } + + if currentConfig.Project == "" { + if err := p.env.Config.Set(DevCenterProjectPath, updatedConfig.Project); err != nil { + return err + } + } + + if currentConfig.Catalog == "" { + if err := p.env.Config.Set(DevCenterCatalogPath, updatedConfig.Catalog); err != nil { + return err + } + } + + if currentConfig.EnvironmentType == "" { + if err := p.env.Config.Set(DevCenterEnvTypePath, updatedConfig.EnvironmentType); err != nil { + return err + } + } + + if currentConfig.EnvironmentDefinition == "" { + if err := p.env.Config.Set(DevCenterEnvDefinitionPath, updatedConfig.EnvironmentDefinition); err != nil { + return err + } + } + + if currentConfig.User == "" { + if err := p.env.Config.Set(DevCenterUserPath, updatedConfig.User); err != nil { + return err + } + } + + return nil +} + +// Polls for the ADE environment and ARM deployment to be created +func (p *ProvisionProvider) pollForEnvironment(ctx context.Context, envName string) { + initialDelay := 3 * time.Second + regularDelay := 5 * time.Second + timer := time.NewTimer(initialDelay) + pollStartTime := time.Now() + + for { + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + environment, err := p.devCenterClient. + DevCenterByName(p.config.Name). + ProjectByName(p.config.Project). + EnvironmentsByUser(p.config.User). + EnvironmentByName(envName). + Get(ctx) + + // We need to wait until the ADE environment has created the resource group + if err != nil || + environment == nil || + environment.ProvisioningState == devcentersdk.ProvisioningStateCreating || + environment.ResourceGroupId == "" { + timer.Reset(regularDelay) + continue + } + + // After the resource group has been created + // We can start polling for a new deployment that started after we started polling + deployment, err := p.manager.Deployment(ctx, environment, func(d *armresources.DeploymentExtended) bool { + return d.Properties.Timestamp.After(pollStartTime) + }) + + if err != nil || deployment == nil { + timer.Reset(regularDelay) + continue + } + + timer.Stop() + + // Finally polling for provisioning progress + go p.pollForProgress(ctx, deployment) + } + } +} + +// Polls the ARM deployment triggered by ADE and start reporting incremental provisioning progress +func (p *ProvisionProvider) pollForProgress(ctx context.Context, deployment infra.Deployment) { + // Disable reporting progress if needed + if use, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_PROVISION_PROGRESS_DISABLE")); err == nil && use { + log.Println("Disabling progress reporting since AZD_DEBUG_PROVISION_PROGRESS_DISABLE was set") + return + } + + // Report incremental progress + progressDisplay := provisioning.NewProvisioningProgressDisplay(p.resourceManager, p.console, deployment) + + initialDelay := 3 * time.Second + regularDelay := 10 * time.Second + timer := time.NewTimer(initialDelay) + queryStartTime := time.Now() + + for { + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + if err := progressDisplay.ReportProgress(ctx, &queryStartTime); err != nil { + // We don't want to fail the whole deployment if a progress reporting error occurs + log.Printf("error while reporting progress: %s", err.Error()) + } + + timer.Reset(regularDelay) + } + } +} + +func mapBicepTypeToInterfaceType(s string) provisioning.ParameterType { + switch s { + case "String", "string", "secureString", "securestring": + return provisioning.ParameterTypeString + case "Bool", "bool": + return provisioning.ParameterTypeBoolean + case "Int", "int": + return provisioning.ParameterTypeNumber + case "Object", "object", "secureObject", "secureobject": + return provisioning.ParameterTypeObject + case "Array", "array": + return provisioning.ParameterTypeArray + default: + panic(fmt.Sprintf("unexpected bicep type: '%s'", s)) + } +} + +// Creates a normalized view of the azure output parameters and resolves inconsistencies in the output parameter name +// casings. +func createOutputParameters( + deploymentOutputs map[string]azapi.AzCliDeploymentOutput, +) map[string]provisioning.OutputParameter { + outputParams := map[string]provisioning.OutputParameter{} + + for key, azureParam := range deploymentOutputs { + // To support BYOI (bring your own infrastructure) scenarios we will default to UPPER when canonical casing + // is not found in the parameters file to workaround strange azure behavior with OUTPUT values that look + // like `azurE_RESOURCE_GROUP` + paramName := strings.ToUpper(key) + + outputParams[paramName] = provisioning.OutputParameter{ + Type: mapBicepTypeToInterfaceType(azureParam.Type), + Value: azureParam.Value, + } + } + + return outputParams +} + +func createInputParameters( + environmentDefinition *devcentersdk.EnvironmentDefinition, + parameterValues map[string]any, +) map[string]provisioning.InputParameter { + inputParams := map[string]provisioning.InputParameter{} + + for _, param := range environmentDefinition.Parameters { + inputParams[param.Id] = provisioning.InputParameter{ + Type: string(param.Type), + DefaultValue: param.Default, + Value: parameterValues[param.Id], + } + } + + return inputParams +} + +// hasInfraTemplates returns true if the specified path contains any infrastructure templates +func hasInfraTemplates(path string) bool { + if _, err := os.Stat(path); err != nil && errors.Is(err, os.ErrNotExist) { + return false + } + + entries, err := os.ReadDir(path) + if errors.Is(err, os.ErrNotExist) { + return false + } + + return len(entries) > 0 +} diff --git a/cli/azd/pkg/devcenter/provision_provider_test.go b/cli/azd/pkg/devcenter/provision_provider_test.go new file mode 100644 index 0000000000..ded852fd6f --- /dev/null +++ b/cli/azd/pkg/devcenter/provision_provider_test.go @@ -0,0 +1,483 @@ +package devcenter + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/convert" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "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/tools/azcli" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockdevcentersdk" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_ProvisionProvider_Initialize(t *testing.T) { + t.Run("AllValuesSet", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + configMap, err := convert.ToMap(config) + require.NoError(t, err) + _ = env.Config.Set("platform.config", configMap) + + provider := newProvisionProviderForTest(t, mockContext, config, env, nil) + err = provider.Initialize(*mockContext.Context, "project/path", provisioning.Options{}) + require.NoError(t, err) + }) + + t.Run("PromptMissingEnvironmentType", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + configMap, err := convert.ToMap(config) + require.NoError(t, err) + _ = env.Config.Set("platform.config", configMap) + + selectedEnvironmentTypeIndex := 1 + selectedEnvironmentType := mockEnvironmentTypes[selectedEnvironmentTypeIndex] + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentTypes(mockContext, config.Project, mockEnvironmentTypes) + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "environment type") + }).Respond(selectedEnvironmentTypeIndex) + + provider := newProvisionProviderForTest(t, mockContext, config, env, nil) + err = provider.Initialize(*mockContext.Context, "project/path", provisioning.Options{}) + require.NoError(t, err) + + actualEnvironmentType, ok := env.Config.Get(DevCenterEnvTypePath) + require.True(t, ok) + require.Equal(t, selectedEnvironmentType.Name, actualEnvironmentType) + }) +} + +func Test_ProvisionProvider_Deploy(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + outputParams := map[string]provisioning.OutputParameter{ + "PARAM_01": {Type: provisioning.ParameterTypeString, Value: "value1"}, + "PARAM_02": {Type: provisioning.ParameterTypeString, Value: "value2"}, + "PARAM_03": {Type: provisioning.ParameterTypeString, Value: "value3"}, + "PARAM_04": {Type: provisioning.ParameterTypeString, Value: "value4"}, + } + + manager := &mockDevCenterManager{} + manager. + On("Outputs", *mockContext.Context, mock.AnythingOfType("*devcentersdk.Environment")). + Return(outputParams, nil) + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironmentDefinition( + mockContext, + config.Project, + config.Catalog, + config.EnvironmentDefinition, + mockEnvDefinitions[0], + ) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), mockEnvironments[0]) + mockdevcentersdk.MockPutEnvironment( + mockContext, + config.Project, + config.User, + env.GetEnvName(), + &devcentersdk.OperationStatus{ + Id: "id", + Name: mockEnvironments[0].Name, + Status: "Succeeded", + StartTime: time.Now(), + EndTime: time.Now(), + }, + ) + + provider := newProvisionProviderForTest(t, mockContext, config, env, manager) + result, err := provider.Deploy(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, result.Deployment.Outputs, outputParams) + require.Len(t, result.Deployment.Parameters, len(mockEnvDefinitions[0].Parameters)) + }) + + t.Run("SuccessWithPrompts", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + selectedEnvironmentTypeIndex := 2 + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "environment type") + }).Respond(selectedEnvironmentTypeIndex) + + mockContext.Console.WhenPrompt(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Param") + }).Respond("value") + + // Missing environment type, prompt user + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + outputParams := map[string]provisioning.OutputParameter{ + "PARAM_01": {Type: provisioning.ParameterTypeString, Value: "value1"}, + "PARAM_02": {Type: provisioning.ParameterTypeString, Value: "value2"}, + "PARAM_03": {Type: provisioning.ParameterTypeString, Value: "value3"}, + "PARAM_04": {Type: provisioning.ParameterTypeString, Value: "value4"}, + } + + manager := &mockDevCenterManager{} + manager. + On("Outputs", *mockContext.Context, mock.AnythingOfType("*devcentersdk.Environment")). + Return(outputParams, nil) + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentTypes(mockContext, config.Project, mockEnvironmentTypes) + mockdevcentersdk.MockGetEnvironmentDefinition( + mockContext, + config.Project, + config.Catalog, + config.EnvironmentDefinition, + mockEnvDefinitions[3], + ) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), mockEnvironments[0]) + mockdevcentersdk.MockPutEnvironment( + mockContext, + config.Project, + config.User, + env.GetEnvName(), + &devcentersdk.OperationStatus{ + Id: "id", + Name: mockEnvironments[0].Name, + Status: "Succeeded", + StartTime: time.Now(), + EndTime: time.Now(), + }, + ) + + provider := newProvisionProviderForTest(t, mockContext, config, env, manager) + + err := provider.Initialize(*mockContext.Context, "project/path", provisioning.Options{}) + require.NoError(t, err) + + result, err := provider.Deploy(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, mockEnvironmentTypes[selectedEnvironmentTypeIndex].Name, config.EnvironmentType) + require.Equal(t, result.Deployment.Outputs, outputParams) + require.Len(t, result.Deployment.Parameters, len(mockEnvDefinitions[3].Parameters)) + require.Equal(t, "value", result.Deployment.Parameters["param01"].Value) + require.Equal(t, "value", result.Deployment.Parameters["param02"].Value) + }) + + t.Run("FailedCreatingEnvironment", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + provider := newProvisionProviderForTest(t, mockContext, config, env, nil) + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironmentDefinition( + mockContext, + config.Project, + config.Catalog, + config.EnvironmentDefinition, + mockEnvDefinitions[0], + ) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), nil) + mockdevcentersdk.MockPutEnvironment( + mockContext, + config.Project, + config.User, + env.GetEnvName(), + &devcentersdk.OperationStatus{ + Id: "id", + Name: mockEnvironments[0].Name, + Status: "Failed", + StartTime: time.Now(), + EndTime: time.Now(), + }, + ) + + result, err := provider.Deploy(*mockContext.Context) + require.Error(t, err) + require.Nil(t, result) + }) +} + +func Test_ProvisionProvider_State(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + outputParams := map[string]provisioning.OutputParameter{ + "PARAM_01": {Type: provisioning.ParameterTypeString, Value: "value1"}, + "PARAM_02": {Type: provisioning.ParameterTypeString, Value: "value2"}, + "PARAM_03": {Type: provisioning.ParameterTypeString, Value: "value3"}, + "PARAM_04": {Type: provisioning.ParameterTypeString, Value: "value4"}, + } + + manager := &mockDevCenterManager{} + manager. + On("Outputs", *mockContext.Context, mock.AnythingOfType("*devcentersdk.Environment")). + Return(outputParams, nil) + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), mockEnvironments[0]) + + provider := newProvisionProviderForTest(t, mockContext, config, env, manager) + result, err := provider.State(*mockContext.Context, &provisioning.StateOptions{}) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.State.Outputs, len(outputParams)) + }) + + t.Run("EnvironmentNotFound", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), nil) + + provider := newProvisionProviderForTest(t, mockContext, config, env, nil) + result, err := provider.State(*mockContext.Context, &provisioning.StateOptions{}) + require.Error(t, err) + require.Nil(t, result) + }) + + t.Run("NoDeploymentOutputs", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + manager := &mockDevCenterManager{} + manager. + On("Outputs", *mockContext.Context, mock.AnythingOfType("*devcentersdk.Environment")). + Return(nil, errors.New("no outputs")) + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), mockEnvironments[0]) + + provider := newProvisionProviderForTest(t, mockContext, config, env, manager) + result, err := provider.State(*mockContext.Context, &provisioning.StateOptions{}) + require.Error(t, err) + require.Nil(t, result) + }) +} + +func Test_ProvisionProvider_Destroy(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), mockEnvironments[0]) + mockdevcentersdk.MockDeleteEnvironment( + mockContext, + config.Project, + config.User, + env.GetEnvName(), + &devcentersdk.OperationStatus{ + Id: "id", + Name: mockEnvironments[0].Name, + Status: "Succeeded", + StartTime: time.Now(), + EndTime: time.Now(), + }, + ) + + outputParams := map[string]provisioning.OutputParameter{ + "PARAM_01": {Type: provisioning.ParameterTypeString, Value: "value1"}, + "PARAM_02": {Type: provisioning.ParameterTypeString, Value: "value2"}, + "PARAM_03": {Type: provisioning.ParameterTypeString, Value: "value3"}, + "PARAM_04": {Type: provisioning.ParameterTypeString, Value: "value4"}, + } + + manager := &mockDevCenterManager{} + manager. + On("Outputs", *mockContext.Context, mock.AnythingOfType("*devcentersdk.Environment")). + Return(outputParams, nil) + + provider := newProvisionProviderForTest(t, mockContext, config, env, manager) + destroyOptions := provisioning.NewDestroyOptions(true, true) + result, err := provider.Destroy(*mockContext.Context, destroyOptions) + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, result.InvalidatedEnvKeys, "PARAM_01") + require.Contains(t, result.InvalidatedEnvKeys, "PARAM_02") + require.Contains(t, result.InvalidatedEnvKeys, "PARAM_03") + require.Contains(t, result.InvalidatedEnvKeys, "PARAM_04") + }) + + t.Run("DeploymentNotFound", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockGetEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), nil) + mockdevcentersdk.MockDeleteEnvironment(mockContext, config.Project, config.User, env.GetEnvName(), nil) + + provider := newProvisionProviderForTest(t, mockContext, config, env, nil) + destroyOptions := provisioning.NewDestroyOptions(true, true) + result, err := provider.Destroy(*mockContext.Context, destroyOptions) + require.Error(t, err) + require.Nil(t, result) + }) +} + +func Test_ProvisionProvider_Preview(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + config := &Config{ + Name: "DEV_CENTER_01", + Catalog: "SampleCatalog", + Project: "Project1", + EnvironmentType: "Dev", + EnvironmentDefinition: "WebApp", + User: "me", + } + env := environment.New("test") + + provider := newProvisionProviderForTest(t, mockContext, config, env, nil) + + // Preview is not supported in ADE - expected to fail + result, err := provider.Preview(*mockContext.Context) + require.Error(t, err) + require.Nil(t, result) +} + +func newProvisionProviderForTest( + t *testing.T, + mockContext *mocks.MockContext, + config *Config, + env *environment.Environment, + manager Manager, +) provisioning.Provider { + coreOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildCoreClientOptions() + + armOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildArmClientOptions() + + resourceGraphClient, err := armresourcegraph.NewClient(mockContext.Credentials, armOptions) + require.NoError(t, err) + + devCenterClient, err := devcentersdk.NewDevCenterClient( + mockContext.Credentials, + coreOptions, + resourceGraphClient, + ) + + require.NoError(t, err) + + azCli := azcli.NewAzCli(mockContext.SubscriptionCredentialProvider, mockContext.HttpClient, azcli.NewAzCliArgs{}) + resourceManager := infra.NewAzureResourceManager( + azCli, + azapi.NewDeploymentOperations(mockContext.SubscriptionCredentialProvider, mockContext.HttpClient), + ) + + if manager == nil { + manager = &mockDevCenterManager{} + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Save", *mockContext.Context, env).Return(nil) + + prompter := NewPrompter(config, mockContext.Console, manager, devCenterClient) + + return NewProvisionProvider( + mockContext.Console, + env, + envManager, + config, + devCenterClient, + resourceManager, + manager, + prompter, + ) +} diff --git a/cli/azd/pkg/devcenter/template_source.go b/cli/azd/pkg/devcenter/template_source.go new file mode 100644 index 0000000000..5fd0b3eb4f --- /dev/null +++ b/cli/azd/pkg/devcenter/template_source.go @@ -0,0 +1,214 @@ +package devcenter + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/templates" + "go.uber.org/multierr" + "golang.org/x/exp/slices" +) + +const ( + SourceKindDevCenter templates.SourceKind = "devcenter" +) + +var SourceDevCenter = &templates.SourceConfig{ + Key: "devcenter", + Name: "Dev Center", + Type: SourceKindDevCenter, +} + +type TemplateSource struct { + config *Config + manager Manager + devCenterClient devcentersdk.DevCenterClient +} + +func NewTemplateSource(config *Config, manager Manager, devCenterClient devcentersdk.DevCenterClient) templates.Source { + return &TemplateSource{ + config: config, + manager: manager, + devCenterClient: devCenterClient, + } +} + +func (s *TemplateSource) Name() string { + return "DevCenter" +} + +func (s *TemplateSource) ListTemplates(ctx context.Context) ([]*templates.Template, error) { + var devCenterFilter DevCenterFilterPredicate + var projectFilter ProjectFilterPredicate + + if s.config.Name != "" { + devCenterFilter = func(dc *devcentersdk.DevCenter) bool { + return strings.EqualFold(dc.Name, s.config.Name) + } + } + + if s.config.Project != "" { + projectFilter = func(p *devcentersdk.Project) bool { + return strings.EqualFold(p.Name, s.config.Project) + } + } + + projects, err := s.manager.WritableProjectsWithFilter(ctx, devCenterFilter, projectFilter) + if err != nil { + return nil, fmt.Errorf("failed getting writable projects: %w", err) + } + + templatesChan := make(chan *templates.Template) + errorsChan := make(chan error) + + // Perform the lookup and checking for projects in parallel to speed up the process + var wg sync.WaitGroup + + for _, project := range projects { + wg.Add(1) + + go func(project *devcentersdk.Project) { + defer wg.Done() + + // If a project is specified in the config then only consider templates for the specified project + if s.config.Project != "" && !strings.EqualFold(s.config.Project, project.Name) { + return + } + + envDefinitions, err := s.devCenterClient. + DevCenterByEndpoint(project.DevCenter.ServiceUri). + ProjectByName(project.Name). + EnvironmentDefinitions(). + Get(ctx) + + if err != nil { + errorsChan <- err + return + } + + for _, envDefinition := range envDefinitions.Value { + // We only want to consider environment definitions that have + // a repo url parameter as valid templates for azd + var repoUrls []string + containsRepoUrl := slices.ContainsFunc(envDefinition.Parameters, func(p devcentersdk.Parameter) bool { + if strings.EqualFold(p.Name, "repourl") { + // Repo url parameter can support multiple values + // Values can either have a default or multiple allowed values but not both + if p.Default != nil { + repoUrls = append(repoUrls, p.Default.(string)) + } else { + repoUrls = append(repoUrls, p.Allowed...) + } + return true + } + + return false + }) + + if containsRepoUrl { + definitionParts := []string{ + project.DevCenter.Name, + envDefinition.CatalogName, + envDefinition.Name, + } + definitionPath := strings.Join(definitionParts, "/") + + // List an available AZD template for each repo url that is referenced in the template + for _, url := range repoUrls { + templatesChan <- &templates.Template{ + Id: url + definitionPath, + Name: envDefinition.Name, + Source: fmt.Sprintf("%s/%s", project.DevCenter.Name, envDefinition.CatalogName), + Description: envDefinition.Description, + RepositoryPath: url, + + // Metadata will be used when creating any azd environments that are based on this template + Metadata: templates.Metadata{ + Project: map[string]string{ + "platform.type": string(PlatformKindDevCenter), + fmt.Sprintf("%s.name", ConfigPath): project.DevCenter.Name, + fmt.Sprintf("%s.catalog", ConfigPath): envDefinition.CatalogName, + fmt.Sprintf("%s.environmentDefinition", ConfigPath): envDefinition.Name, + }, + }, + } + } + } + } + }(project) + } + + go func() { + wg.Wait() + close(templatesChan) + close(errorsChan) + }() + + var doneGroup sync.WaitGroup + doneGroup.Add(2) + + var allErrors error + distinctTemplates := []*templates.Template{} + + go func() { + defer doneGroup.Done() + + for template := range templatesChan { + contains := slices.ContainsFunc(distinctTemplates, func(t *templates.Template) bool { + return t.Id == template.Id + }) + + if !contains { + distinctTemplates = append(distinctTemplates, template) + } + } + }() + + go func() { + defer doneGroup.Done() + + for err := range errorsChan { + allErrors = multierr.Append(allErrors, err) + } + }() + + // Wait for all the templates and errors to be processed from channels + doneGroup.Wait() + + if allErrors != nil { + return nil, allErrors + } + + return distinctTemplates, nil +} + +func (s *TemplateSource) GetTemplate(ctx context.Context, path string) (*templates.Template, error) { + templateList, err := s.ListTemplates(ctx) + if err != nil { + return nil, fmt.Errorf("unable to list templates: %w", err) + } + + // Attempt to match on the following: + // Raw template id + // Template path in the format: // + // Template repository path + for _, template := range templateList { + if strings.EqualFold(template.Id, path) { + return template, nil + } + + templatePath := fmt.Sprintf("%s/%s", template.Source, template.Name) + if strings.EqualFold(templatePath, path) { + return template, nil + } + + if strings.EqualFold(template.RepositoryPath, path) { + return template, nil + } + } + + return nil, fmt.Errorf("template with path '%s' was not found, %w", path, templates.ErrTemplateNotFound) +} diff --git a/cli/azd/pkg/devcenter/template_source_test.go b/cli/azd/pkg/devcenter/template_source_test.go new file mode 100644 index 0000000000..7ae296ee4c --- /dev/null +++ b/cli/azd/pkg/devcenter/template_source_test.go @@ -0,0 +1,118 @@ +package devcenter + +import ( + "context" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/pkg/templates" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockdevcentersdk" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_TemplateSource_ListTemplates(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + templateSource := newTemplateSourceForTest(t, mockContext, &Config{}, nil) + setupDevCenterSuccessMocks(t, mockContext, templateSource) + + templateList, err := templateSource.ListTemplates(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, templateList) + require.Len(t, templateList, len(mockEnvDefinitions)) + require.Len(t, templateList[0].Metadata.Project, 4) + require.Contains(t, templateList[0].Metadata.Project, "platform.config.name") + require.Contains(t, templateList[0].Metadata.Project, "platform.config.catalog") + require.Contains(t, templateList[0].Metadata.Project, "platform.config.environmentDefinition") + }) + + t.Run("Fail", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + templateSource := newTemplateSourceForTest(t, mockContext, &Config{}, nil) + setupDevCenterSuccessMocks(t, mockContext, templateSource) + // Mock will throw 404 not found for this API call causing a failure + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, "Project2", nil) + + templateList, err := templateSource.ListTemplates(*mockContext.Context) + require.Error(t, err) + require.Nil(t, templateList) + }) +} + +func Test_TemplateSource_GetTemplate(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + templateSource := newTemplateSourceForTest(t, mockContext, &Config{}, nil) + setupDevCenterSuccessMocks(t, mockContext, templateSource) + + template, err := templateSource.GetTemplate(*mockContext.Context, "DEV_CENTER_01/SampleCatalog/EnvDefinition_02") + require.NoError(t, err) + require.NotNil(t, template) + }) + + t.Run("TemplateNotFound", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + templateSource := newTemplateSourceForTest(t, mockContext, &Config{}, nil) + setupDevCenterSuccessMocks(t, mockContext, templateSource) + + // Expected to fail because the template path is not found + template, err := templateSource.GetTemplate(*mockContext.Context, "DEV_CENTER_01/SampleCatalog/NotFound") + require.ErrorIs(t, err, templates.ErrTemplateNotFound) + require.Nil(t, template) + }) +} + +func setupDevCenterSuccessMocks(t *testing.T, mockContext *mocks.MockContext, templateSource *TemplateSource) { + mockdevcentersdk.MockDevCenterGraphQuery(mockContext, mockDevCenterList) + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, "Project1", mockEnvDefinitions) + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, "Project2", []*devcentersdk.EnvironmentDefinition{}) + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, "Project3", []*devcentersdk.EnvironmentDefinition{}) + mockdevcentersdk.MockListEnvironmentDefinitions(mockContext, "Project4", []*devcentersdk.EnvironmentDefinition{}) + + if templateSource.manager == nil { + manager := &mockDevCenterManager{} + templateSource.manager = manager + } + + mocks := templateSource.manager.(*mockDevCenterManager) + + mocks. + On("WritableProjectsWithFilter", *mockContext.Context, mock.Anything, mock.Anything). + Return(mockProjects, nil) +} + +func newTemplateSourceForTest( + t *testing.T, + mockContext *mocks.MockContext, + config *Config, + manager Manager, +) *TemplateSource { + coreOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildCoreClientOptions() + + armOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, mockContext.HttpClient, "azd"). + BuildArmClientOptions() + + resourceGraphClient, err := armresourcegraph.NewClient(mockContext.Credentials, armOptions) + require.NoError(t, err) + + devCenterClient, err := devcentersdk.NewDevCenterClient( + mockContext.Credentials, + coreOptions, + resourceGraphClient, + ) + + require.NoError(t, err) + + if manager == nil { + manager = &mockDevCenterManager{} + } + + return NewTemplateSource(config, manager, devCenterClient).(*TemplateSource) +} diff --git a/cli/azd/pkg/devcentersdk/api_version_policy.go b/cli/azd/pkg/devcentersdk/api_version_policy.go new file mode 100644 index 0000000000..37bbc5f1c0 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/api_version_policy.go @@ -0,0 +1,38 @@ +package devcentersdk + +import ( + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/azure/azure-dev/cli/azd/pkg/convert" +) + +const ( + apiVersionName = "api-version" + defaultApiVersion = "2023-04-01" +) + +type apiVersionPolicy struct { + apiVersion string +} + +// Policy to ensure the AZD custom user agent is set on all HTTP requests. +func NewApiVersionPolicy(apiVersion *string) policy.Policy { + if apiVersion == nil { + apiVersion = convert.RefOf(defaultApiVersion) + } + + return &apiVersionPolicy{ + apiVersion: *apiVersion, + } +} + +// Sets the custom user-agent string on the underlying request +func (p *apiVersionPolicy) Do(req *policy.Request) (*http.Response, error) { + rawRequest := req.Raw() + queryString := rawRequest.URL.Query() + queryString.Set(apiVersionName, defaultApiVersion) + rawRequest.URL.RawQuery = queryString.Encode() + + return req.Next() +} diff --git a/cli/azd/pkg/devcentersdk/catalog_request_builders.go b/cli/azd/pkg/devcentersdk/catalog_request_builders.go new file mode 100644 index 0000000000..f65518f09f --- /dev/null +++ b/cli/azd/pkg/devcentersdk/catalog_request_builders.go @@ -0,0 +1,86 @@ +package devcentersdk + +import ( + "context" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/httputil" +) + +// Catalogs +type CatalogListRequestBuilder struct { + *EntityListRequestBuilder[CatalogListRequestBuilder] + projectName string +} + +func NewCatalogListRequestBuilder(c *devCenterClient, devCenter *DevCenter, projectName string) *CatalogListRequestBuilder { + builder := &CatalogListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, devCenter) + builder.projectName = projectName + + return builder +} + +func (c *CatalogListRequestBuilder) Get(ctx context.Context) (*CatalogListResponse, error) { + req, err := c.createRequest(ctx, http.MethodGet, fmt.Sprintf("projects/%s/catalogs", c.projectName)) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[CatalogListResponse](res) +} + +type CatalogItemRequestBuilder struct { + *EntityItemRequestBuilder[CatalogItemRequestBuilder] + projectName string +} + +func NewCatalogItemRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, + catalogName string, +) *CatalogItemRequestBuilder { + builder := &CatalogItemRequestBuilder{} + builder.EntityItemRequestBuilder = newEntityItemRequestBuilder(builder, c, devCenter, catalogName) + builder.projectName = projectName + + return builder +} + +func (c *CatalogItemRequestBuilder) EnvironmentDefinitions() *EnvironmentDefinitionListRequestBuilder { + return NewEnvironmentDefinitionListRequestBuilder(c.client, c.devCenter, c.projectName, c.id) +} + +func (c *CatalogItemRequestBuilder) EnvironmentDefinitionByName(name string) *EnvironmentDefinitionItemRequestBuilder { + return NewEnvironmentDefinitionItemRequestBuilder(c.client, c.devCenter, c.projectName, c.id, name) +} + +func (c *CatalogItemRequestBuilder) Get(ctx context.Context) (*Catalog, error) { + req, err := c.createRequest(ctx, http.MethodGet, fmt.Sprintf("projects/%s/catalogs/%s", c.projectName, c.id)) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[Catalog](res) +} diff --git a/cli/azd/pkg/devcentersdk/devcenter_request_builders.go b/cli/azd/pkg/devcentersdk/devcenter_request_builders.go new file mode 100644 index 0000000000..17204f0346 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/devcenter_request_builders.go @@ -0,0 +1,54 @@ +package devcentersdk + +import ( + "context" +) + +// DevCenters +type DevCenterListRequestBuilder struct { + *EntityListRequestBuilder[DevCenterListRequestBuilder] +} + +func NewDevCenterListRequestBuilder(c *devCenterClient) *DevCenterListRequestBuilder { + builder := &DevCenterListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, nil) + + return builder +} + +// Gets a list of applications that the current logged in user has access to. +func (c *DevCenterListRequestBuilder) Get(ctx context.Context) (*DevCenterListResponse, error) { + devCenters, err := c.client.devCenterList(ctx) + if err != nil { + return nil, err + } + + return &DevCenterListResponse{ + Value: devCenters, + }, nil +} + +type DevCenterItemRequestBuilder struct { + *EntityItemRequestBuilder[DevCenterItemRequestBuilder] +} + +func NewDevCenterItemRequestBuilder(c *devCenterClient, devCenter *DevCenter) *DevCenterItemRequestBuilder { + // Reset endpoint + c.endpoint = "" + + builder := &DevCenterItemRequestBuilder{} + builder.EntityItemRequestBuilder = newEntityItemRequestBuilder(builder, c, devCenter, "") + builder.devCenter = devCenter + + return builder +} + +func (c *DevCenterItemRequestBuilder) Projects() *ProjectListRequestBuilder { + return NewProjectListRequestBuilder(c.client, c.devCenter) +} + +func (c *DevCenterItemRequestBuilder) ProjectByName(projectName string) *ProjectItemRequestBuilder { + builder := NewProjectItemRequestBuilder(c.client, c.devCenter, projectName) + + return builder +} diff --git a/cli/azd/pkg/devcentersdk/devcentersdk.go b/cli/azd/pkg/devcentersdk/devcentersdk.go new file mode 100644 index 0000000000..be76b816f1 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/devcentersdk.go @@ -0,0 +1,12 @@ +package devcentersdk + +import ( + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" +) + +// The host name for the Graph API. +const HostName = "graph.microsoft.com" + +var ServiceConfig cloud.ServiceConfiguration = cloud.ServiceConfiguration{ + Audience: "https://management.core.windows.net", +} diff --git a/cli/azd/pkg/devcentersdk/developer_client.go b/cli/azd/pkg/devcentersdk/developer_client.go new file mode 100644 index 0000000000..1a53903e61 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/developer_client.go @@ -0,0 +1,224 @@ +package devcentersdk + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/convert" +) + +type DevCenterClient interface { + DevCenters() *DevCenterListRequestBuilder + DevCenterByEndpoint(endpoint string) *DevCenterItemRequestBuilder + DevCenterByName(name string) *DevCenterItemRequestBuilder +} + +type devCenterClient struct { + credential azcore.TokenCredential + options *azcore.ClientOptions + resourceGraphClient *armresourcegraph.Client + pipeline runtime.Pipeline + cache map[string]interface{} + endpoint string +} + +func NewDevCenterClient( + credential azcore.TokenCredential, + options *azcore.ClientOptions, + resourceGraphClient *armresourcegraph.Client, +) (DevCenterClient, error) { + options.PerCallPolicies = append(options.PerCallPolicies, NewApiVersionPolicy(nil)) + pipeline := NewPipeline(credential, ServiceConfig, options) + + return &devCenterClient{ + pipeline: pipeline, + credential: credential, + options: options, + resourceGraphClient: resourceGraphClient, + cache: map[string]interface{}{}, + }, nil +} + +func (c *devCenterClient) DevCenters() *DevCenterListRequestBuilder { + return NewDevCenterListRequestBuilder(c) +} + +func (c *devCenterClient) DevCenterByEndpoint(endpoint string) *DevCenterItemRequestBuilder { + return NewDevCenterItemRequestBuilder(c, &DevCenter{ServiceUri: endpoint}) +} + +func (c *devCenterClient) DevCenterByName(name string) *DevCenterItemRequestBuilder { + return NewDevCenterItemRequestBuilder(c, &DevCenter{Name: name}) +} + +func (c *devCenterClient) projectList(ctx context.Context) ([]*Project, error) { + projects, ok := c.cache["projects"].([]*Project) + if ok { + return projects, nil + } + + query := ` + Resources + | where type in~ ('microsoft.devcenter/projects') + | where properties['provisioningState'] =~ 'Succeeded' + | project id, location, tenantId, name, properties, type + ` + queryRequest := armresourcegraph.QueryRequest{ + Query: &query, + Options: &armresourcegraph.QueryRequestOptions{ + AllowPartialScopes: convert.RefOf(true), + }, + } + res, err := c.resourceGraphClient.Resources(ctx, queryRequest, nil) + if err != nil { + return nil, err + } + + list, ok := res.QueryResponse.Data.([]interface{}) + if !ok { + return nil, errors.New("error converting data to list") + } + + jsonBytes, err := json.Marshal(list) + if err != nil { + return nil, fmt.Errorf("failed marshalling list: %w", err) + } + + var resources []*GenericResource + err = json.Unmarshal(jsonBytes, &resources) + if err != nil { + return nil, fmt.Errorf("failed unmarshalling list: %w", err) + } + + projects = []*Project{} + for _, resource := range resources { + projectId, err := NewResourceId(resource.Id) + if err != nil { + return nil, fmt.Errorf("failed parsing resource id: %w", err) + } + + devCenterId, err := NewResourceId(resource.Properties["devCenterId"].(string)) + if err != nil { + return nil, fmt.Errorf("failed parsing dev center id: %w", err) + } + + project := &Project{ + Id: resource.Id, + Name: resource.Name, + ResourceGroup: projectId.ResourceGroup, + SubscriptionId: projectId.SubscriptionId, + Description: convert.ToStringWithDefault(resource.Properties["description"], ""), + DevCenter: &DevCenter{ + Id: devCenterId.Id, + SubscriptionId: devCenterId.SubscriptionId, + ResourceGroup: devCenterId.ResourceGroup, + Name: devCenterId.ResourceName, + ServiceUri: strings.TrimSuffix( + convert.ToStringWithDefault(resource.Properties["devCenterUri"], ""), + "/", + ), + }, + } + + projects = append(projects, project) + } + + c.cache["projects"] = projects + return projects, nil +} + +func (c *devCenterClient) projectListByDevCenter(ctx context.Context, devCenter *DevCenter) ([]*Project, error) { + allProjects, err := c.projectList(ctx) + if err != nil { + return nil, err + } + + filteredProjects := []*Project{} + for _, project := range allProjects { + hasMatchingServiceUri := devCenter.ServiceUri != "" && project.DevCenter.ServiceUri == devCenter.ServiceUri + hasMatchingDevCenterName := devCenter.Name != "" && project.DevCenter.Name == devCenter.Name + + if hasMatchingServiceUri || hasMatchingDevCenterName { + filteredProjects = append(filteredProjects, project) + } + } + + return filteredProjects, nil +} + +func (c *devCenterClient) projectByDevCenter( + ctx context.Context, + devCenter *DevCenter, + projectName string, +) (*Project, error) { + projects, err := c.projectListByDevCenter(ctx, devCenter) + if err != nil { + return nil, err + } + + matchingIndex := slices.IndexFunc(projects, func(project *Project) bool { + return project.Name == projectName + }) + + if matchingIndex < 0 { + return nil, fmt.Errorf("failed to find project '%s'", projectName) + } + + return projects[matchingIndex], nil +} + +func (c *devCenterClient) devCenterList(ctx context.Context) ([]*DevCenter, error) { + devCenters, ok := c.cache["devcenters"].([]*DevCenter) + if ok { + return devCenters, nil + } + + devCenters = []*DevCenter{} + projects, err := c.projectList(ctx) + if err != nil { + return nil, err + } + + for _, project := range projects { + exists := slices.ContainsFunc(devCenters, func(devcenter *DevCenter) bool { + return devcenter.ServiceUri == project.DevCenter.ServiceUri + }) + + if !exists { + devCenters = append(devCenters, project.DevCenter) + } + } + + c.cache["devcenters"] = devCenters + return devCenters, nil +} + +func (c *devCenterClient) host(ctx context.Context, devCenter *DevCenter) (string, error) { + devCenterList, err := c.devCenterList(ctx) + if err != nil { + return "", fmt.Errorf("failed to get dev center list: %w", err) + } + + index := slices.IndexFunc(devCenterList, func(dc *DevCenter) bool { + if devCenter.ServiceUri != "" { + return devCenter.ServiceUri == dc.ServiceUri + } else if devCenter.Name != "" { + return devCenter.Name == dc.Name + } + + return false + }) + + if index < 0 { + return "", fmt.Errorf("failed to find dev center endpoint: '%s' or name: '%s'", devCenter.ServiceUri, devCenter.Name) + } + + return devCenterList[index].ServiceUri, nil +} diff --git a/cli/azd/pkg/devcentersdk/developer_client_test.go b/cli/azd/pkg/devcentersdk/developer_client_test.go new file mode 100644 index 0000000000..87210dbcc3 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/developer_client_test.go @@ -0,0 +1,185 @@ +package devcentersdk + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +func Test_DevCenter_Client(t *testing.T) { + if os.Getenv("CI") != "" { + t.Skip("Skipping DevCenter tests in CI") + } + + mockContext := mocks.NewMockContext(context.Background()) + fileConfigManager := config.NewFileConfigManager(config.NewManager()) + authManager, err := auth.NewManager( + fileConfigManager, + config.NewUserConfigManager(fileConfigManager), + http.DefaultClient, + mockContext.Console, + ) + require.NoError(t, err) + + credentials, err := authManager.CredentialForCurrentUser(*mockContext.Context, nil) + require.NoError(t, err) + + options := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, http.DefaultClient, "azd"). + BuildCoreClientOptions() + + armOptions := azsdk. + DefaultClientOptionsBuilder(*mockContext.Context, http.DefaultClient, "azd"). + BuildArmClientOptions() + + resourceGraphClient, err := armresourcegraph.NewClient(credentials, armOptions) + require.NoError(t, err) + + client, err := NewDevCenterClient(credentials, options, resourceGraphClient) + require.NoError(t, err) + + // Get dev center list + devCenterList, err := client. + DevCenters(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, devCenterList) + + devCenterName := "wabrez-devcenter" + devCenterClient := client.DevCenterByName(devCenterName) + + // Get project list + projectList, err := devCenterClient. + Projects(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, projectList) + + projectName := "Project1" + projectClient := devCenterClient.ProjectByName(projectName) + + // Get project by name + project, err := projectClient. + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotNil(t, project) + + permissions, err := projectClient. + Permissions(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, permissions) + + // Get Catalog List + catalogList, err := projectClient. + Catalogs(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, catalogList) + + // Get Catalog by name + catalog, err := projectClient. + CatalogByName("SampleCatalog"). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotNil(t, catalog) + + // Get Environment Type List + environmentTypeList, err := projectClient. + EnvironmentTypes(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, environmentTypeList) + + // Get Environment type list by catalog + environmentTypeListByCatalog, err := projectClient. + CatalogByName("SampleCatalog"). + EnvironmentDefinitions(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, environmentTypeListByCatalog) + + // Get Environment Definition List + environmentDefinitionList, err := projectClient. + EnvironmentDefinitions(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, environmentDefinitionList) + + // Get environment list + environmentList, err := projectClient. + Environments(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, environmentList) + + // Get environments by user + userEnvironmentList, err := projectClient. + EnvironmentsByMe(). + Get(*mockContext.Context) + + require.NoError(t, err) + require.NotEmpty(t, userEnvironmentList) + + // Create environment + envName := fmt.Sprintf("env-%d", time.Now().Unix()) + + envSpec := EnvironmentSpec{ + CatalogName: "SampleCatalog", + EnvironmentDefinitionName: "Sandbox", + EnvironmentType: "Dev", + Parameters: map[string]interface{}{ + "environmentName": envName, + }, + } + + err = projectClient. + EnvironmentsByMe(). + EnvironmentByName(envName). + Put(*mockContext.Context, envSpec) + + require.NoError(t, err) + + // Delete environment + err = projectClient. + EnvironmentsByMe(). + EnvironmentByName(envName). + Delete(*mockContext.Context) + + require.NoError(t, err) + + err = projectClient. + EnvironmentsByMe(). + EnvironmentByName(envName). + Put(*mockContext.Context, envSpec) + + require.NoError(t, err) + + // Delete environment + err = projectClient. + EnvironmentsByMe(). + EnvironmentByName(envName). + Delete(*mockContext.Context) + + require.NoError(t, err) +} diff --git a/cli/azd/pkg/devcentersdk/entity_item_request_builder.go b/cli/azd/pkg/devcentersdk/entity_item_request_builder.go new file mode 100644 index 0000000000..5195f932a2 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/entity_item_request_builder.go @@ -0,0 +1,77 @@ +package devcentersdk + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +type entityItemRequestInfo struct { + selectParams []string +} + +type EntityItemRequestBuilder[T any] struct { + id string + client *devCenterClient + builder *T + requestInfo *entityItemRequestInfo + devCenter *DevCenter +} + +// Creates a new EntityItemRequestBuilder +// builder - The parent entity builder +func newEntityItemRequestBuilder[T any]( + builder *T, + client *devCenterClient, + devCenter *DevCenter, + id string, +) *EntityItemRequestBuilder[T] { + return &EntityItemRequestBuilder[T]{ + client: client, + devCenter: devCenter, + builder: builder, + id: id, + requestInfo: &entityItemRequestInfo{}, + } +} + +// Creates a HTTP request for the specified method, URL and configured request information +func (b *EntityItemRequestBuilder[T]) createRequest( + ctx context.Context, + method string, + path string, +) (*policy.Request, error) { + host, err := b.client.host(ctx, b.devCenter) + if err != nil { + return nil, fmt.Errorf("devcenter is not set, %w", err) + } + + req, err := runtime.NewRequest(ctx, method, fmt.Sprintf("%s/%s", host, path)) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + if err != nil { + return nil, err + } + + raw := req.Raw() + query := raw.URL.Query() + + if len(b.requestInfo.selectParams) > 0 { + query.Set("$select", strings.Join(b.requestInfo.selectParams, ",")) + } + + raw.URL.RawQuery = query.Encode() + + return req, err +} + +func (b *EntityItemRequestBuilder[T]) Select(params []string) *T { + b.requestInfo.selectParams = params + + return b.builder +} diff --git a/cli/azd/pkg/devcentersdk/entity_list_request_builder.go b/cli/azd/pkg/devcentersdk/entity_list_request_builder.go new file mode 100644 index 0000000000..6bd146f87b --- /dev/null +++ b/cli/azd/pkg/devcentersdk/entity_list_request_builder.go @@ -0,0 +1,80 @@ +package devcentersdk + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +type entityListRequestInfo struct { + filter *string + top *int +} + +type EntityListRequestBuilder[T any] struct { + builder *T + client *devCenterClient + requestInfo *entityListRequestInfo + devCenter *DevCenter +} + +// Creates a new EntityListRequestBuilder that provides common functionality for list operations +// include $filter, $top and $skip +func newEntityListRequestBuilder[T any]( + builder *T, + client *devCenterClient, + devCenter *DevCenter, +) *EntityListRequestBuilder[T] { + return &EntityListRequestBuilder[T]{ + builder: builder, + client: client, + requestInfo: &entityListRequestInfo{}, + devCenter: devCenter, + } +} + +// Creates a HTTP request for the specified method, URL and configured request information +func (b *EntityListRequestBuilder[T]) createRequest( + ctx context.Context, + method string, + path string, +) (*policy.Request, error) { + host, err := b.client.host(ctx, b.devCenter) + if err != nil { + return nil, fmt.Errorf("devcenter is not set, %w", err) + } + + req, err := runtime.NewRequest(ctx, method, fmt.Sprintf("%s/%s", host, path)) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + raw := req.Raw() + query := raw.URL.Query() + + if b.requestInfo.filter != nil { + query.Set("$filter", *b.requestInfo.filter) + } + + if b.requestInfo.top != nil { + query.Set("$top", fmt.Sprint((*b.requestInfo.top))) + } + + raw.URL.RawQuery = query.Encode() + + return req, err +} + +func (b *EntityListRequestBuilder[T]) Filter(filterExpression string) *T { + b.requestInfo.filter = &filterExpression + + return b.builder +} + +func (b *EntityListRequestBuilder[T]) Top(count int) *T { + b.requestInfo.top = &count + + return b.builder +} diff --git a/cli/azd/pkg/devcentersdk/environment_definition_request_builders.go b/cli/azd/pkg/devcentersdk/environment_definition_request_builders.go new file mode 100644 index 0000000000..bdfb301d2e --- /dev/null +++ b/cli/azd/pkg/devcentersdk/environment_definition_request_builders.go @@ -0,0 +1,99 @@ +package devcentersdk + +import ( + "context" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/httputil" +) + +// EnvironmentDefinitions +type EnvironmentDefinitionListRequestBuilder struct { + *EntityListRequestBuilder[EnvironmentDefinitionListRequestBuilder] + projectName string + catalogName string +} + +func NewEnvironmentDefinitionListRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, + catalogName string, +) *EnvironmentDefinitionListRequestBuilder { + builder := &EnvironmentDefinitionListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, devCenter) + builder.projectName = projectName + builder.catalogName = catalogName + + return builder +} + +func (c *EnvironmentDefinitionListRequestBuilder) Get(ctx context.Context) (*EnvironmentDefinitionListResponse, error) { + var requestPath string + if c.catalogName != "" { + requestPath = fmt.Sprintf("projects/%s/catalogs/%s/environmentDefinitions", c.projectName, c.catalogName) + } else { + requestPath = fmt.Sprintf("projects/%s/environmentDefinitions", c.projectName) + } + + req, err := c.createRequest(ctx, http.MethodGet, requestPath) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[EnvironmentDefinitionListResponse](res) +} + +type EnvironmentDefinitionItemRequestBuilder struct { + *EntityItemRequestBuilder[EnvironmentDefinitionItemRequestBuilder] + projectName string + catalogName string +} + +func NewEnvironmentDefinitionItemRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, + catalogName string, + environmentDefinitionName string, +) *EnvironmentDefinitionItemRequestBuilder { + builder := &EnvironmentDefinitionItemRequestBuilder{} + builder.EntityItemRequestBuilder = newEntityItemRequestBuilder(builder, c, devCenter, environmentDefinitionName) + builder.projectName = projectName + builder.catalogName = catalogName + + return builder +} + +func (c *EnvironmentDefinitionItemRequestBuilder) Get(ctx context.Context) (*EnvironmentDefinition, error) { + req, err := c.createRequest( + ctx, + http.MethodGet, + fmt.Sprintf("projects/%s/catalogs/%s/environmentDefinitions/%s", c.projectName, c.catalogName, c.id), + ) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[EnvironmentDefinition](res) +} diff --git a/cli/azd/pkg/devcentersdk/environment_request_builders.go b/cli/azd/pkg/devcentersdk/environment_request_builders.go new file mode 100644 index 0000000000..9abd728091 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/environment_request_builders.go @@ -0,0 +1,268 @@ +package devcentersdk + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/httputil" +) + +const ( + deployStatusInterval = 10 * time.Second +) + +// Environments +type EnvironmentListRequestBuilder struct { + *EntityListRequestBuilder[EnvironmentListRequestBuilder] + projectName string + userId string +} + +func NewEnvironmentListRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, +) *EnvironmentListRequestBuilder { + builder := &EnvironmentListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, devCenter) + builder.projectName = projectName + + return builder +} + +func (c *EnvironmentListRequestBuilder) EnvironmentByName(name string) *EnvironmentItemRequestBuilder { + return NewEnvironmentItemRequestBuilder(c.client, c.devCenter, c.projectName, c.userId, name) +} + +func (c *EnvironmentListRequestBuilder) Get(ctx context.Context) (*EnvironmentListResponse, error) { + var requestUrl string + + if c.userId != "" { + requestUrl = fmt.Sprintf("projects/%s/users/%s/environments", c.projectName, c.userId) + } else { + requestUrl = fmt.Sprintf("projects/%s/environments", c.projectName) + } + + req, err := c.createRequest(ctx, http.MethodGet, requestUrl) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[EnvironmentListResponse](res) +} + +type EnvironmentItemRequestBuilder struct { + *EntityItemRequestBuilder[EnvironmentItemRequestBuilder] + projectName string + userId string +} + +func NewEnvironmentItemRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, + userId string, + environmentName string, +) *EnvironmentItemRequestBuilder { + builder := &EnvironmentItemRequestBuilder{} + builder.EntityItemRequestBuilder = newEntityItemRequestBuilder(builder, c, devCenter, environmentName) + builder.projectName = projectName + builder.userId = userId + + return builder +} + +func (c *EnvironmentItemRequestBuilder) Get(ctx context.Context) (*Environment, error) { + requestUrl := fmt.Sprintf("projects/%s/users/%s/environments/%s", c.projectName, c.userId, c.id) + req, err := c.createRequest(ctx, http.MethodGet, requestUrl) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[Environment](res) +} + +func (c *EnvironmentItemRequestBuilder) BeginPut( + ctx context.Context, + spec EnvironmentSpec, +) (*runtime.Poller[*OperationStatus], error) { + requestUrl := fmt.Sprintf("projects/%s/users/me/environments/%s", c.projectName, c.id) + req, err := c.createRequest(ctx, http.MethodPut, requestUrl) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + err = SetHttpRequestBody(req, spec) + if err != nil { + return nil, err + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if !runtime.HasStatusCode(res, http.StatusCreated) { + return nil, runtime.NewResponseError(res) + } + + var finalResponse *OperationStatus + + pollerOptions := &runtime.NewPollerOptions[*OperationStatus]{ + Response: &finalResponse, + Handler: NewEnvironmentPollingHandler(c.client.pipeline, res), + } + + return runtime.NewPoller(res, c.client.pipeline, pollerOptions) +} + +func (c *EnvironmentItemRequestBuilder) Put( + ctx context.Context, + spec EnvironmentSpec, +) error { + poller, err := c.BeginPut(ctx, spec) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ + Frequency: deployStatusInterval, + }) + if err != nil { + return err + } + + return nil +} + +func (c *EnvironmentItemRequestBuilder) BeginDelete( + ctx context.Context, +) (*runtime.Poller[*OperationStatus], error) { + req, err := c.createRequest( + ctx, + http.MethodDelete, + fmt.Sprintf("projects/%s/users/me/environments/%s", c.projectName, c.id), + ) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if !runtime.HasStatusCode(res, http.StatusAccepted) { + return nil, runtime.NewResponseError(res) + } + + var finalResponse *OperationStatus + + pollerOptions := &runtime.NewPollerOptions[*OperationStatus]{ + Response: &finalResponse, + Handler: NewEnvironmentPollingHandler(c.client.pipeline, res), + } + + return runtime.NewPoller(res, c.client.pipeline, pollerOptions) +} + +func (c *EnvironmentItemRequestBuilder) Delete(ctx context.Context) error { + poller, err := c.BeginDelete(ctx) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ + Frequency: deployStatusInterval, + }) + if err != nil { + return err + } + + return nil +} + +type EnvironmentPollingHandler struct { + pipeline runtime.Pipeline + response *http.Response + result *OperationStatus +} + +func NewEnvironmentPollingHandler(pipeline runtime.Pipeline, res *http.Response) *EnvironmentPollingHandler { + return &EnvironmentPollingHandler{ + pipeline: pipeline, + response: res, + } +} + +func (p *EnvironmentPollingHandler) Poll(ctx context.Context) (*http.Response, error) { + location := p.response.Header.Get("Location") + if strings.TrimSpace(location) == "" { + return nil, fmt.Errorf("missing polling location header") + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, location) + if err != nil { + return nil, err + } + + response, err := p.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(response, http.StatusAccepted) && !runtime.HasStatusCode(response, http.StatusOK) { + return nil, runtime.NewResponseError(response) + } + + // If response is 202 - we're still waiting + if runtime.HasStatusCode(response, http.StatusAccepted) { + return response, nil + } + + // Status code is 200 if we get to this point - transform the response + deploymentStatus, err := httputil.ReadRawResponse[OperationStatus](response) + if err != nil { + return nil, err + } + + p.result = deploymentStatus + + return response, nil +} + +func (p *EnvironmentPollingHandler) Done() bool { + return p.result != nil && ProvisioningState(p.result.Status) == ProvisioningStateSucceeded +} + +// Gets the result of the deploy operation +func (h *EnvironmentPollingHandler) Result(ctx context.Context, out **OperationStatus) error { + *out = h.result + return nil +} diff --git a/cli/azd/pkg/devcentersdk/environment_type_request_builders.go b/cli/azd/pkg/devcentersdk/environment_type_request_builders.go new file mode 100644 index 0000000000..0093320350 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/environment_type_request_builders.go @@ -0,0 +1,89 @@ +package devcentersdk + +import ( + "context" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/httputil" +) + +// EnvironmentTypes +type EnvironmentTypeListRequestBuilder struct { + *EntityListRequestBuilder[EnvironmentTypeListRequestBuilder] + projectName string +} + +func NewEnvironmentTypeListRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, +) *EnvironmentTypeListRequestBuilder { + builder := &EnvironmentTypeListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, devCenter) + builder.projectName = projectName + + return builder +} + +func (c *EnvironmentTypeListRequestBuilder) Get(ctx context.Context) (*EnvironmentTypeListResponse, error) { + req, err := c.createRequest( + ctx, + http.MethodGet, + fmt.Sprintf("projects/%s/environmentTypes", c.projectName), + ) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[EnvironmentTypeListResponse](res) +} + +type EnvironmentTypeItemRequestBuilder struct { + *EntityItemRequestBuilder[EnvironmentTypeItemRequestBuilder] + projectName string +} + +func NewEnvironmentTypeItemRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, + environmentTypeName string, +) *EnvironmentTypeItemRequestBuilder { + builder := &EnvironmentTypeItemRequestBuilder{} + builder.EntityItemRequestBuilder = newEntityItemRequestBuilder(builder, c, devCenter, environmentTypeName) + + return builder +} + +func (c *EnvironmentTypeItemRequestBuilder) Get(ctx context.Context) (*EnvironmentType, error) { + req, err := c.createRequest( + ctx, + http.MethodGet, + fmt.Sprintf("projects/%s/environmentTypes/%s", c.projectName, c.id), + ) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + return httputil.ReadRawResponse[EnvironmentType](res) +} diff --git a/cli/azd/pkg/devcentersdk/models.go b/cli/azd/pkg/devcentersdk/models.go new file mode 100644 index 0000000000..0018157fd9 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/models.go @@ -0,0 +1,207 @@ +package devcentersdk + +import ( + "errors" + "regexp" + "time" +) + +type DevCenter struct { + Id string + SubscriptionId string + ResourceGroup string + Name string + ServiceUri string +} + +type DevCenterListResponse struct { + Value []*DevCenter `json:"value"` +} + +type Project struct { + Id string + SubscriptionId string + ResourceGroup string + Name string + Description string + DevCenter *DevCenter +} + +type GenericResource struct { + Id string `json:"id"` + Location string `json:"location"` + TenantId string `json:"tenantId"` + Name string `json:"name"` + Type string `json:"type"` + Properties map[string]interface{} `json:"properties"` +} + +type ResourceId struct { + Id string + SubscriptionId string + ResourceGroup string + Provider string + ResourcePath string + ResourceName string +} + +type ResourceGroupId struct { + Id string + SubscriptionId string + Name string +} + +//nolint:lll +var ( + resourceIdRegex = regexp.MustCompile( + `\/subscriptions\/(?P.+?)\/resourceGroups\/(?P.+?)\/providers\/(?P.+?)\/(?P.+?)\/(?P.+)`, + ) + resourceGroupIdRegex = regexp.MustCompile( + `\/subscriptions\/(?P.+?)\/resourceGroups\/(?P.+)`, + ) +) + +func NewResourceId(resourceId string) (*ResourceId, error) { + // Find matches and extract named values + matches := resourceIdRegex.FindStringSubmatch(resourceId) + + if len(matches) == 0 { + return nil, errors.New("no match found") + } + + namedValues := getRegExpNamedValues(resourceIdRegex, matches) + + return &ResourceId{ + Id: resourceId, + SubscriptionId: namedValues["subscriptionId"], + ResourceGroup: namedValues["resourceGroup"], + Provider: namedValues["resourceProvider"], + ResourcePath: namedValues["resourcePath"], + ResourceName: namedValues["resourceName"], + }, nil +} + +func NewResourceGroupId(resourceId string) (*ResourceGroupId, error) { + // Find matches and extract named values + matches := resourceGroupIdRegex.FindStringSubmatch(resourceId) + + if len(matches) == 0 { + return nil, errors.New("no match found") + } + + namedValues := getRegExpNamedValues(resourceGroupIdRegex, matches) + + return &ResourceGroupId{ + Id: resourceId, + SubscriptionId: namedValues["subscriptionId"], + Name: namedValues["resourceGroup"], + }, nil +} + +func getRegExpNamedValues(regexp *regexp.Regexp, matches []string) map[string]string { + namedValues := make(map[string]string) + + // The first element in the match slice is the entire matched string, + // so we start the loop from 1 to skip it. + for i, name := range regexp.SubexpNames()[1:] { + namedValues[name] = matches[i+1] + } + + return namedValues +} + +type ProjectListResponse struct { + Value []*Project `json:"value"` +} + +type Catalog struct { + Name string `json:"name"` +} + +type CatalogListResponse struct { + Value []*Catalog `json:"value"` +} + +type EnvironmentType struct { + Name string `json:"name"` + DeploymentTargetId string `json:"deploymentTargetId"` + Status string `json:"status"` +} + +type EnvironmentTypeListResponse struct { + Value []*EnvironmentType `json:"value"` +} + +type EnvironmentDefinition struct { + Id string `json:"id"` + Name string `json:"name"` + CatalogName string `json:"catalogName"` + Description string `json:"description"` + TemplatePath string `json:"templatePath"` + Parameters []Parameter `json:"parameters"` +} + +type EnvironmentDefinitionListResponse struct { + Value []*EnvironmentDefinition `json:"value"` +} + +type ParameterType string + +const ( + ParameterTypeString ParameterType = "string" + ParameterTypeInt ParameterType = "int" + ParameterTypeBool ParameterType = "bool" +) + +type Parameter struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type ParameterType `json:"type"` + ReadOnly bool `json:"readOnly"` + Required bool `json:"required"` + Allowed []string `json:"allowed"` + Default any `json:"default"` +} + +type ProvisioningState string + +const ( + ProvisioningStateSucceeded ProvisioningState = "Succeeded" + ProvisioningStateCreating ProvisioningState = "Creating" + ProvisioningStateDeleting ProvisioningState = "Deleting" +) + +type Environment struct { + Name string + EnvironmentType string + User string + ProvisioningState ProvisioningState + ResourceGroupId string + CatalogName string + EnvironmentDefinitionName string + Parameters map[string]any +} + +type EnvironmentListResponse struct { + Value []*Environment `json:"value"` +} + +type EnvironmentSpec struct { + CatalogName string `json:"catalogName"` + EnvironmentDefinitionName string `json:"environmentDefinitionName"` + EnvironmentType string `json:"environmentType"` + Parameters map[string]any `json:"parameters"` +} + +type EnvironmentPutResponse struct { + *Environment +} + +type OperationStatus struct { + Id string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` +} diff --git a/cli/azd/pkg/devcentersdk/permissions_request_builder.go b/cli/azd/pkg/devcentersdk/permissions_request_builder.go new file mode 100644 index 0000000000..72505b8cf7 --- /dev/null +++ b/cli/azd/pkg/devcentersdk/permissions_request_builder.go @@ -0,0 +1,84 @@ +package devcentersdk + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +type PermissionListRequestBuilder struct { + *EntityListRequestBuilder[PermissionListRequestBuilder] + projectName string +} + +func NewPermissionListRequestBuilder( + c *devCenterClient, + devCenter *DevCenter, + projectName string, +) *PermissionListRequestBuilder { + builder := &PermissionListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, devCenter) + builder.projectName = projectName + + return builder +} + +func (c *PermissionListRequestBuilder) Get(ctx context.Context) ([]*armauthorization.Permission, error) { + project, err := c.client.projectByDevCenter(ctx, c.devCenter, c.projectName) + if err != nil { + return nil, err + } + + options := azsdk.DefaultClientOptionsBuilder(ctx, c.client.options.Transport, "azd").BuildArmClientOptions() + permissionsClient, err := armauthorization.NewPermissionsClient(project.SubscriptionId, c.client.credential, options) + if err != nil { + return nil, err + } + + pager := permissionsClient.NewListForResourcePager( + project.ResourceGroup, + "Microsoft.DevCenter", + "projects", + "", + project.Name, + nil, + ) + + permissions := []*armauthorization.Permission{} + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed getting next page of subscriptions: %w", err) + } + + permissions = append(permissions, page.Value...) + } + + return permissions, nil +} + +func (c *PermissionListRequestBuilder) HasWriteAccess(ctx context.Context) bool { + return c.hasPermission(ctx, "Microsoft.DevCenter/projects/users/environments/userWrite/action") +} + +func (c *PermissionListRequestBuilder) HasDeleteAccess(ctx context.Context) bool { + return c.hasPermission(ctx, "Microsoft.DevCenter/projects/users/environments/userDelete/action") +} + +func (c *PermissionListRequestBuilder) hasPermission(ctx context.Context, permission string) bool { + permissions, err := c.Get(ctx) + if err != nil { + return false + } + + return slices.ContainsFunc(permissions, func(p *armauthorization.Permission) bool { + return slices.ContainsFunc(p.DataActions, func(action *string) bool { + return strings.EqualFold(*action, permission) + }) + }) +} diff --git a/cli/azd/pkg/devcentersdk/project_request_builders.go b/cli/azd/pkg/devcentersdk/project_request_builders.go new file mode 100644 index 0000000000..6b3b92028a --- /dev/null +++ b/cli/azd/pkg/devcentersdk/project_request_builders.go @@ -0,0 +1,104 @@ +package devcentersdk + +import ( + "context" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +// Projects +type ProjectListRequestBuilder struct { + *EntityListRequestBuilder[ProjectListRequestBuilder] +} + +func NewProjectListRequestBuilder(c *devCenterClient, devCenter *DevCenter) *ProjectListRequestBuilder { + builder := &ProjectListRequestBuilder{} + builder.EntityListRequestBuilder = newEntityListRequestBuilder(builder, c, devCenter) + + return builder +} + +func (c *ProjectListRequestBuilder) Get(ctx context.Context) (*ProjectListResponse, error) { + projects, err := c.client.projectListByDevCenter(ctx, c.devCenter) + if err != nil { + return nil, err + } + + return &ProjectListResponse{ + Value: projects, + }, nil +} + +type ProjectItemRequestBuilder struct { + *EntityItemRequestBuilder[ProjectItemRequestBuilder] +} + +func NewProjectItemRequestBuilder(c *devCenterClient, devCenter *DevCenter, projectName string) *ProjectItemRequestBuilder { + builder := &ProjectItemRequestBuilder{} + builder.EntityItemRequestBuilder = newEntityItemRequestBuilder(builder, c, devCenter, projectName) + + return builder +} + +func (c *ProjectItemRequestBuilder) Catalogs() *CatalogListRequestBuilder { + return NewCatalogListRequestBuilder(c.client, c.devCenter, c.id) +} + +func (c *ProjectItemRequestBuilder) CatalogByName(catalogName string) *CatalogItemRequestBuilder { + return NewCatalogItemRequestBuilder(c.client, c.devCenter, c.id, catalogName) +} + +func (c *ProjectItemRequestBuilder) EnvironmentTypes() *EnvironmentTypeListRequestBuilder { + return NewEnvironmentTypeListRequestBuilder(c.client, c.devCenter, c.id) +} + +func (c *ProjectItemRequestBuilder) EnvironmentDefinitions() *EnvironmentDefinitionListRequestBuilder { + return NewEnvironmentDefinitionListRequestBuilder(c.client, c.devCenter, c.id, "") +} + +func (c *ProjectItemRequestBuilder) Environments() *EnvironmentListRequestBuilder { + return NewEnvironmentListRequestBuilder(c.client, c.devCenter, c.id) +} + +func (c *ProjectItemRequestBuilder) EnvironmentsByUser(userId string) *EnvironmentListRequestBuilder { + builder := NewEnvironmentListRequestBuilder(c.client, c.devCenter, c.id) + builder.userId = userId + + return builder +} + +func (c *ProjectItemRequestBuilder) EnvironmentsByMe() *EnvironmentListRequestBuilder { + builder := NewEnvironmentListRequestBuilder(c.client, c.devCenter, c.id) + builder.userId = "me" + + return builder +} + +func (c ProjectItemRequestBuilder) Permissions() *PermissionListRequestBuilder { + return NewPermissionListRequestBuilder(c.client, c.devCenter, c.id) +} + +func (c *ProjectItemRequestBuilder) Get(ctx context.Context) (*Project, error) { + req, err := c.createRequest(ctx, http.MethodGet, fmt.Sprintf("projects/%s", c.id)) + if err != nil { + return nil, fmt.Errorf("failed creating request: %w", err) + } + + res, err := c.client.pipeline.Do(req) + if err != nil { + return nil, err + } + + if !runtime.HasStatusCode(res, http.StatusOK) { + return nil, runtime.NewResponseError(res) + } + + project, err := c.client.projectByDevCenter(ctx, c.devCenter, c.id) + if err != nil { + return nil, err + } + + return project, nil +} diff --git a/cli/azd/pkg/devcentersdk/util.go b/cli/azd/pkg/devcentersdk/util.go new file mode 100644 index 0000000000..305123aa1a --- /dev/null +++ b/cli/azd/pkg/devcentersdk/util.go @@ -0,0 +1,47 @@ +package devcentersdk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +// Creates a new Azure HTTP pipeline used for Graph SDK clients +func NewPipeline( + credential azcore.TokenCredential, + serviceConfig cloud.ServiceConfiguration, + clientOptions *azcore.ClientOptions, +) runtime.Pipeline { + scopes := []string{ + "https://devcenter.azure.com/.default", + } + + bearerOptions := &policy.BearerTokenOptions{} + authPolicy := runtime.NewBearerTokenPolicy(credential, scopes, bearerOptions) + pipelineOptions := runtime.PipelineOptions{ + PerRetry: []policy.Policy{authPolicy}, + } + + return runtime.NewPipeline("devcenter", "1.0.0", pipelineOptions, clientOptions) +} + +// Creates a JSON serialized HTTP request body +func SetHttpRequestBody(req *policy.Request, value any) error { + raw := req.Raw() + raw.Header.Set("Content-Type", "application/json") + + jsonBytes, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed serializing JSON: %w", err) + } + + raw.Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) + + return nil +} diff --git a/cli/azd/pkg/infra/azure_resource_types.go b/cli/azd/pkg/infra/azure_resource_types.go index 4365271868..903f830215 100644 --- a/cli/azd/pkg/infra/azure_resource_types.go +++ b/cli/azd/pkg/infra/azure_resource_types.go @@ -37,6 +37,8 @@ const ( AzureResourceTypeCognitiveServiceAccount AzureResourceType = "Microsoft.CognitiveServices/accounts" AzureResourceTypeSearchService AzureResourceType = "Microsoft.Search/searchServices" AzurePrivateEndpoint AzureResourceType = "Microsoft.Network/privateEndpoints" + AzureDevCenter AzureResourceType = "Microsoft.DevCenter/devcenters" + AzureDevCenterProject AzureResourceType = "Microsoft.DevCenter/projects" ) const resourceLevelSeparator = "/" @@ -102,6 +104,10 @@ func GetResourceTypeDisplayName(resourceType AzureResourceType) string { return "Azure Spring Apps" case AzurePrivateEndpoint: return "Private Endpoint" + case AzureDevCenter: + return "Dev Center" + case AzureDevCenterProject: + return "Dev Center Project" } return "" diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index da64e30394..f455de087f 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "dario.cat/mergo" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" @@ -45,7 +46,10 @@ import ( "golang.org/x/exp/maps" ) -const DefaultModule = "main" +var Defaults = Options{ + Module: "main", + Path: "infra", +} type deploymentDetails struct { CompiledBicep *compileBicepResult @@ -87,8 +91,8 @@ func (p *BicepProvider) RequiredExternalTools() []tools.ExternalTool { } func (p *BicepProvider) Initialize(ctx context.Context, projectPath string, options Options) error { - if strings.TrimSpace(options.Module) == "" { - options.Module = DefaultModule + if err := mergo.Merge(&options, Defaults); err != nil { + return fmt.Errorf("merging bicep defaults: %w", err) } p.projectPath = projectPath diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 839a9ed4b7..8d0371bca0 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -16,13 +16,15 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/prompt" ) +type DefaultProviderResolver func() (ProviderKind, error) + // Manages the orchestration of infrastructure provisioning type Manager struct { serviceLocator ioc.ServiceLocator + defaultProvider DefaultProviderResolver envManager environment.Manager env *environment.Environment console input.Console - prompter prompt.Prompter provider Provider alphaFeatureManager *alpha.FeatureManager projectPath string @@ -204,19 +206,19 @@ func EnsureSubscriptionAndLocation( // Creates a new instance of the Provisioning Manager func NewManager( serviceLocator ioc.ServiceLocator, + defaultProvider DefaultProviderResolver, envManager environment.Manager, env *environment.Environment, console input.Console, alphaFeatureManager *alpha.FeatureManager, - prompter prompt.Prompter, ) *Manager { return &Manager{ serviceLocator: serviceLocator, + defaultProvider: defaultProvider, envManager: envManager, env: env, console: console, alphaFeatureManager: alphaFeatureManager, - prompter: prompter, } } @@ -238,10 +240,20 @@ func (m *Manager) newProvider(ctx context.Context) (Provider, error) { m.console.WarnForFeature(ctx, alphaFeatureId) } + providerKey := m.options.Provider + if providerKey == NotSpecified { + defaultProvider, err := m.defaultProvider() + if err != nil { + return nil, err + } + + providerKey = defaultProvider + } + var provider Provider - err = m.serviceLocator.ResolveNamed(string(m.options.Provider), &provider) + err = m.serviceLocator.ResolveNamed(string(providerKey), &provider) if err != nil { - return nil, fmt.Errorf("failed resolving IaC provider '%s': %w", m.options.Provider, err) + return nil, fmt.Errorf("failed resolving IaC provider '%s': %w", providerKey, err) } return provider, nil diff --git a/cli/azd/pkg/infra/provisioning/manager_test.go b/cli/azd/pkg/infra/provisioning/manager_test.go index def6a42f2a..914bf0da25 100644 --- a/cli/azd/pkg/infra/provisioning/manager_test.go +++ b/cli/azd/pkg/infra/provisioning/manager_test.go @@ -44,7 +44,14 @@ func TestProvisionInitializesEnvironment(t *testing.T) { registerContainerDependencies(mockContext, env) envManager := &mockenv.MockEnvManager{} - mgr := NewManager(mockContext.Container, envManager, env, mockContext.Console, mockContext.AlphaFeaturesManager, nil) + mgr := NewManager( + mockContext.Container, + defaultProvider, + envManager, + env, + mockContext.Console, + mockContext.AlphaFeaturesManager, + ) err := mgr.Initialize(*mockContext.Context, "", Options{Provider: "test"}) require.NoError(t, err) @@ -62,7 +69,14 @@ func TestManagerPreview(t *testing.T) { registerContainerDependencies(mockContext, env) envManager := &mockenv.MockEnvManager{} - mgr := NewManager(mockContext.Container, envManager, env, mockContext.Console, mockContext.AlphaFeaturesManager, nil) + mgr := NewManager( + mockContext.Container, + defaultProvider, + envManager, + env, + mockContext.Console, + mockContext.AlphaFeaturesManager, + ) err := mgr.Initialize(*mockContext.Context, "", Options{Provider: "test"}) require.NoError(t, err) @@ -82,7 +96,14 @@ func TestManagerGetState(t *testing.T) { registerContainerDependencies(mockContext, env) envManager := &mockenv.MockEnvManager{} - mgr := NewManager(mockContext.Container, envManager, env, mockContext.Console, mockContext.AlphaFeaturesManager, nil) + mgr := NewManager( + mockContext.Container, + defaultProvider, + envManager, + env, + mockContext.Console, + mockContext.AlphaFeaturesManager, + ) err := mgr.Initialize(*mockContext.Context, "", Options{Provider: "test"}) require.NoError(t, err) @@ -102,7 +123,14 @@ func TestManagerDeploy(t *testing.T) { registerContainerDependencies(mockContext, env) envManager := &mockenv.MockEnvManager{} - mgr := NewManager(mockContext.Container, envManager, env, mockContext.Console, mockContext.AlphaFeaturesManager, nil) + mgr := NewManager( + mockContext.Container, + defaultProvider, + envManager, + env, + mockContext.Console, + mockContext.AlphaFeaturesManager, + ) err := mgr.Initialize(*mockContext.Context, "", Options{Provider: "test"}) require.NoError(t, err) @@ -128,7 +156,14 @@ func TestManagerDestroyWithPositiveConfirmation(t *testing.T) { envManager := &mockenv.MockEnvManager{} envManager.On("Save", *mockContext.Context, env).Return(nil) - mgr := NewManager(mockContext.Container, envManager, env, mockContext.Console, mockContext.AlphaFeaturesManager, nil) + mgr := NewManager( + mockContext.Container, + defaultProvider, + envManager, + env, + mockContext.Console, + mockContext.AlphaFeaturesManager, + ) err := mgr.Initialize(*mockContext.Context, "", Options{Provider: "test"}) require.NoError(t, err) @@ -155,7 +190,14 @@ func TestManagerDestroyWithNegativeConfirmation(t *testing.T) { registerContainerDependencies(mockContext, env) envManager := &mockenv.MockEnvManager{} - mgr := NewManager(mockContext.Container, envManager, env, mockContext.Console, mockContext.AlphaFeaturesManager, nil) + mgr := NewManager( + mockContext.Container, + defaultProvider, + envManager, + env, + mockContext.Console, + mockContext.AlphaFeaturesManager, + ) err := mgr.Initialize(*mockContext.Context, "", Options{Provider: "test"}) require.NoError(t, err) @@ -205,3 +247,7 @@ func registerContainerDependencies(mockContext *mocks.MockContext, env *environm return clock.NewMock() }) } + +func defaultProvider() (ProviderKind, error) { + return Bicep, nil +} diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index d7131abbef..d0825b1d2c 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -10,17 +10,18 @@ import ( type ProviderKind string const ( - Bicep ProviderKind = "bicep" - Arm ProviderKind = "arm" - Terraform ProviderKind = "terraform" - Pulumi ProviderKind = "pulumi" - Test ProviderKind = "test" + NotSpecified ProviderKind = "" + Bicep ProviderKind = "bicep" + Arm ProviderKind = "arm" + Terraform ProviderKind = "terraform" + Pulumi ProviderKind = "pulumi" + Test ProviderKind = "test" ) type Options struct { - Provider ProviderKind `yaml:"provider"` - Path string `yaml:"path"` - Module string `yaml:"module"` + Provider ProviderKind `yaml:"provider,omitempty"` + Path string `yaml:"path,omitempty"` + Module string `yaml:"module,omitempty"` // Not expected to be defined at azure.yaml IgnoreDeploymentState bool `yaml:"-"` } diff --git a/cli/azd/pkg/infra/provisioning/provisioning.go b/cli/azd/pkg/infra/provisioning/provisioning.go index 383e0479b4..5a8755cef0 100644 --- a/cli/azd/pkg/infra/provisioning/provisioning.go +++ b/cli/azd/pkg/infra/provisioning/provisioning.go @@ -54,11 +54,9 @@ func NewEnvRefreshResultFromState(state *State) contracts.EnvRefreshResult { // Defaults to `Bicep` if no provider is specified func ParseProvider(kind ProviderKind) (ProviderKind, error) { switch kind { - case "": - return Bicep, nil // For the time being we need to include `Test` here for the unit tests to work as expected // App builds will pass this test but fail resolving the provider since `Test` won't be registered in the container - case Bicep, Terraform, Test: + case NotSpecified, Bicep, Terraform, Test: return kind, nil } diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 6a7dda76d4..79e17dee68 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "dario.cat/mergo" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/environment" @@ -23,6 +24,11 @@ import ( "golang.org/x/exp/maps" ) +var Defaults = Options{ + Module: "main", + Path: "infra", +} + // TerraformProvider exposes infrastructure provisioning using Azure Terraform templates type TerraformProvider struct { envManager environment.Manager @@ -72,8 +78,8 @@ func NewTerraformProvider( } func (t *TerraformProvider) Initialize(ctx context.Context, projectPath string, options Options) error { - if strings.TrimSpace(options.Module) == "" { - options.Module = "main" + if err := mergo.Merge(&options, Defaults); err != nil { + return fmt.Errorf("merging terraform defaults: %w", err) } t.projectPath = projectPath diff --git a/cli/azd/pkg/platform/platform.go b/cli/azd/pkg/platform/platform.go new file mode 100644 index 0000000000..8f6fc4161c --- /dev/null +++ b/cli/azd/pkg/platform/platform.go @@ -0,0 +1,51 @@ +package platform + +import ( + "errors" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/ioc" +) + +var ErrPlatformNotSupported = fmt.Errorf("unsupported platform") + +type PlatformKind string + +type Config struct { + Type PlatformKind `yaml:"type"` + Config map[string]any `yaml:"config"` +} + +// Initialize configures the IoC container with the platform specific components +func Initialize(container *ioc.NestedContainer, defaultPlatform PlatformKind) (Provider, error) { + // Enable the platform provider if it is configured + var platformConfig *Config + platformType := defaultPlatform + + // Override platform type when specified + err := container.Resolve(&platformConfig) + if err != nil && errors.Is(err, ErrPlatformNotSupported) { + return nil, err + } + + if platformConfig != nil { + platformType = platformConfig.Type + } + + var provider Provider + platformKey := fmt.Sprintf("%s-platform", platformType) + + // Resolve the platform provider + if err := container.ResolveNamed(platformKey, &provider); err != nil { + return nil, fmt.Errorf("failed to resolve platform provider '%s': %w", platformType, err) + } + + if provider.IsEnabled() { + // Configure the container for the platform provider + if err := provider.ConfigureContainer(container); err != nil { + return nil, fmt.Errorf("failed to configure platform provider '%s': %w", platformType, err) + } + } + + return provider, nil +} diff --git a/cli/azd/pkg/platform/platform_test.go b/cli/azd/pkg/platform/platform_test.go new file mode 100644 index 0000000000..e048724879 --- /dev/null +++ b/cli/azd/pkg/platform/platform_test.go @@ -0,0 +1,90 @@ +package platform + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/stretchr/testify/require" +) + +func Test_Platform_Initialize(t *testing.T) { + t.Run("ExplicitdConfig", func(t *testing.T) { + container := ioc.NewNestedContainer(nil) + _ = container.RegisterNamedSingleton("default-platform", newDefaultProvider) + _ = container.RegisterNamedSingleton("test-platform", newTestProvider) + + config := &Config{ + Type: PlatformKind("test"), + } + ioc.RegisterInstance(container, config) + + provider, err := Initialize(container, PlatformKind("default")) + require.NoError(t, err) + require.NotNil(t, provider) + require.IsType(t, new(testProvider), provider) + }) + + t.Run("ImplicitConfig", func(t *testing.T) { + container := ioc.NewNestedContainer(nil) + _ = container.RegisterNamedSingleton("default-platform", newDefaultProvider) + _ = container.RegisterNamedSingleton("test-platform", newTestProvider) + + provider, err := Initialize(container, PlatformKind("default")) + require.NoError(t, err) + require.NotNil(t, provider) + require.IsType(t, new(defaultProvider), provider) + }) + + t.Run("Unsupported", func(t *testing.T) { + container := ioc.NewNestedContainer(nil) + _ = container.RegisterNamedSingleton("default-platform", newDefaultProvider) + _ = container.RegisterNamedSingleton("test-platform", newTestProvider) + + container.RegisterSingleton(func() (*Config, error) { + return nil, ErrPlatformNotSupported + }) + + provider, err := Initialize(container, PlatformKind("default")) + require.Error(t, err) + require.ErrorIs(t, err, ErrPlatformNotSupported) + require.Nil(t, provider) + }) +} + +type defaultProvider struct { +} + +func newDefaultProvider() Provider { + return &defaultProvider{} +} + +func (p *defaultProvider) Name() string { + return "default" +} + +func (p *defaultProvider) IsEnabled() bool { + return true +} + +func (p *defaultProvider) ConfigureContainer(container *ioc.NestedContainer) error { + return nil +} + +type testProvider struct { +} + +func newTestProvider() Provider { + return &testProvider{} +} + +func (p *testProvider) Name() string { + return "test" +} + +func (p *testProvider) IsEnabled() bool { + return true +} + +func (p *testProvider) ConfigureContainer(container *ioc.NestedContainer) error { + return nil +} diff --git a/cli/azd/pkg/platform/provider.go b/cli/azd/pkg/platform/provider.go new file mode 100644 index 0000000000..170d7ef8cd --- /dev/null +++ b/cli/azd/pkg/platform/provider.go @@ -0,0 +1,15 @@ +package platform + +import "github.com/azure/azure-dev/cli/azd/pkg/ioc" + +// Provider is an interface for a platform provider +type Provider interface { + // Name returns the name of the platform + Name() string + + // IsEnabled returns true if the platform is enabled + IsEnabled() bool + + // ConfigureContainer configures the IoC container for the platform + ConfigureContainer(container *ioc.NestedContainer) error +} diff --git a/cli/azd/pkg/project/project.go b/cli/azd/pkg/project/project.go index cd5eabdf65..b97bd0e95a 100644 --- a/cli/azd/pkg/project/project.go +++ b/cli/azd/pkg/project/project.go @@ -12,6 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/osutil" @@ -23,8 +24,6 @@ import ( const ( //nolint:lll projectSchemaAnnotation = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json" - - cInfraDirectory = "infra" ) func New(ctx context.Context, projectFilePath string, projectName string) (*ProjectConfig, error) { @@ -101,10 +100,6 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { } } - if projectConfig.Infra.Path == "" { - projectConfig.Infra.Path = cInfraDirectory - } - return &projectConfig, nil } @@ -161,6 +156,42 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { return projectConfig, nil } +func LoadConfig(ctx context.Context, projectFilePath string) (config.Config, error) { + log.Printf("Reading project from file '%s'\n", projectFilePath) + bytes, err := os.ReadFile(projectFilePath) + if err != nil { + return nil, fmt.Errorf("reading project file: %w", err) + } + + yamlContent := string(bytes) + + rawConfig := map[string]any{} + + if err := yaml.Unmarshal([]byte(yamlContent), &rawConfig); err != nil { + return nil, fmt.Errorf( + "unable to parse azure.yaml file. Check the format of the file, "+ + "and also verify you have the latest version of the CLI: %w", + err, + ) + } + + return config.NewConfig(rawConfig), nil +} + +func SaveConfig(ctx context.Context, config config.Config, projectFilePath string) error { + projectBytes, err := yaml.Marshal(config.Raw()) + if err != nil { + return fmt.Errorf("marshalling project yaml: %w", err) + } + + projectConfig, err := Parse(ctx, string(projectBytes)) + if err != nil { + return fmt.Errorf("parsing project yaml: %w", err) + } + + return Save(ctx, projectConfig, projectFilePath) +} + // Saves the current instance back to the azure.yaml file func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath string) error { projectBytes, err := yaml.Marshal(projectConfig) diff --git a/cli/azd/pkg/project/project_config.go b/cli/azd/pkg/project/project_config.go index b2b6c86ce7..5cad16feb4 100644 --- a/cli/azd/pkg/project/project_config.go +++ b/cli/azd/pkg/project/project_config.go @@ -6,6 +6,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/azure/azure-dev/cli/azd/pkg/state" ) @@ -16,15 +17,16 @@ type ProjectConfig struct { RequiredVersions *RequiredVersions `yaml:"requiredVersions,omitempty"` Name string `yaml:"name"` ResourceGroupName ExpandableString `yaml:"resourceGroup,omitempty"` - Path string `yaml:",omitempty"` + Path string `yaml:"-"` Metadata *ProjectMetadata `yaml:"metadata,omitempty"` - Services map[string]*ServiceConfig `yaml:",omitempty"` + Services map[string]*ServiceConfig `yaml:"services,omitempty"` Infra provisioning.Options `yaml:"infra,omitempty"` Pipeline PipelineOptions `yaml:"pipeline,omitempty"` Hooks map[string]*ext.HookConfig `yaml:"hooks,omitempty"` State *state.Config `yaml:"state,omitempty"` + Platform *platform.Config `yaml:"platform,omitempty"` - *ext.EventDispatcher[ProjectLifecycleEventArgs] `yaml:",omitempty"` + *ext.EventDispatcher[ProjectLifecycleEventArgs] `yaml:"-"` } // RequiredVersions contains information about what versions of tools this project requires. diff --git a/cli/azd/pkg/templates/json_source_test.go b/cli/azd/pkg/templates/json_source_test.go index 053cd6773d..88584db547 100644 --- a/cli/azd/pkg/templates/json_source_test.go +++ b/cli/azd/pkg/templates/json_source_test.go @@ -12,12 +12,12 @@ var testTemplates []*Template = []*Template{ { Name: "template1", Description: "Description of template 1", - RepositoryPath: "path/to/template1", + RepositoryPath: "owner/template1", }, { Name: "template2", Description: "Description of template 2", - RepositoryPath: "path/to/template2", + RepositoryPath: "owner/template2", }, } @@ -60,13 +60,13 @@ func Test_JsonTemplateSource_GetTemplate_MatchFound(t *testing.T) { source, err := NewJsonTemplateSource(name, jsonTemplates()) require.Nil(t, err) - template, err := source.GetTemplate(context.Background(), "path/to/template1") + template, err := source.GetTemplate(context.Background(), "owner/template1") require.Nil(t, err) expectedTemplate := &Template{ Name: "template1", Description: "Description of template 1", - RepositoryPath: "path/to/template1", + RepositoryPath: "owner/template1", Source: name, } require.Equal(t, expectedTemplate, template) @@ -77,7 +77,7 @@ func Test_JsonTemplateSource_GetTemplate_NoMatchFound(t *testing.T) { source, err := NewJsonTemplateSource(name, jsonTemplates()) require.Nil(t, err) - template, err := source.GetTemplate(context.Background(), "path/to/template3") + template, err := source.GetTemplate(context.Background(), "owner/notfound") require.Error(t, err) require.ErrorIs(t, err, ErrTemplateNotFound) diff --git a/cli/azd/pkg/templates/source.go b/cli/azd/pkg/templates/source.go index 6da546de88..3a39c9cf05 100644 --- a/cli/azd/pkg/templates/source.go +++ b/cli/azd/pkg/templates/source.go @@ -3,6 +3,9 @@ package templates import ( "context" "fmt" + "log" + + "golang.org/x/exp/slices" ) // Source is a source of AZD compatible templates. @@ -57,16 +60,29 @@ func (ts *templateSource) ListTemplates(ctx context.Context) ([]*Template, error } func (ts *templateSource) GetTemplate(ctx context.Context, path string) (*Template, error) { - templates, err := ts.ListTemplates(ctx) + absTemplatePath, err := Absolute(path) + if err != nil { + return nil, err + } + + allTemplates, err := ts.ListTemplates(ctx) if err != nil { - return nil, fmt.Errorf("unable to list templates: %w", err) + return nil, fmt.Errorf("failed listing templates: %w", err) } - for _, template := range templates { - if template.RepositoryPath == path { - return template, nil + matchingIndex := slices.IndexFunc(allTemplates, func(template *Template) bool { + absPath, err := Absolute(template.RepositoryPath) + if err != nil { + log.Printf("failed to get absolute path for template '%s': %s", template.RepositoryPath, err.Error()) + return false } + + return absPath == absTemplatePath + }) + + if matchingIndex == -1 { + return nil, fmt.Errorf("template with path '%s' was not found, %w", path, ErrTemplateNotFound) } - return nil, fmt.Errorf("template with name '%s' was not found, %w", path, ErrTemplateNotFound) + return allTemplates[matchingIndex], nil } diff --git a/cli/azd/pkg/templates/source_manager.go b/cli/azd/pkg/templates/source_manager.go index 0c749619e2..5dd782a027 100644 --- a/cli/azd/pkg/templates/source_manager.go +++ b/cli/azd/pkg/templates/source_manager.go @@ -9,6 +9,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/httputil" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/resources" ) @@ -38,6 +39,22 @@ var ( ErrSourceTypeInvalid = errors.New("invalid template source type") ) +// SourceOptions defines options for the SourceManager. +type SourceOptions struct { + // List of default template sources to use for listing templates + DefaultSources []*SourceConfig + // Whether to load template sources from azd configuration + LoadConfiguredSources bool +} + +// NewSourceOptions creates a new SourceOptions with default values +func NewSourceOptions() *SourceOptions { + return &SourceOptions{ + DefaultSources: []*SourceConfig{}, + LoadConfiguredSources: true, + } +} + // SourceManager manages template sources used in azd template list and azd init experiences. type SourceManager interface { // List returns a list of template sources. @@ -53,15 +70,28 @@ type SourceManager interface { } type sourceManager struct { - configManager config.UserConfigManager - httpClient httputil.HttpClient + options *SourceOptions + serviceLocator ioc.ServiceLocator + configManager config.UserConfigManager + httpClient httputil.HttpClient } // NewSourceManager creates a new SourceManager. -func NewSourceManager(configManager config.UserConfigManager, httpClient httputil.HttpClient) SourceManager { +func NewSourceManager( + options *SourceOptions, + serviceLocator ioc.ServiceLocator, + configManager config.UserConfigManager, + httpClient httputil.HttpClient, +) SourceManager { + if options == nil { + options = NewSourceOptions() + } + return &sourceManager{ - configManager: configManager, - httpClient: httpClient, + options: options, + serviceLocator: serviceLocator, + configManager: configManager, + httpClient: httpClient, } } @@ -72,7 +102,16 @@ func (sm *sourceManager) List(ctx context.Context) ([]*SourceConfig, error) { return nil, fmt.Errorf("unable to load user configuration: %w", err) } - sourceConfigs := []*SourceConfig{} + allSourceConfigs := []*SourceConfig{} + + if sm.options.DefaultSources != nil && len(sm.options.DefaultSources) > 0 { + allSourceConfigs = append(allSourceConfigs, sm.options.DefaultSources...) + } + + if !sm.options.LoadConfiguredSources { + return allSourceConfigs, nil + } + rawSources, ok := config.Get(baseConfigKey) if ok { sourceMap := rawSources.(map[string]interface{}) @@ -94,7 +133,7 @@ func (sm *sourceManager) List(ctx context.Context) ([]*SourceConfig, error) { } sourceConfig.Key = key - sourceConfigs = append(sourceConfigs, sourceConfig) + allSourceConfigs = append(allSourceConfigs, sourceConfig) } } else { // In the use case where template sources have never been configured, @@ -102,10 +141,10 @@ func (sm *sourceManager) List(ctx context.Context) ([]*SourceConfig, error) { if err := sm.addInternal(ctx, SourceAwesomeAzd.Key, SourceAwesomeAzd); err != nil { return nil, fmt.Errorf("unable to default template source '%s': %w", SourceAwesomeAzd.Key, err) } - sourceConfigs = append(sourceConfigs, SourceAwesomeAzd) + allSourceConfigs = append(allSourceConfigs, SourceAwesomeAzd) } - return sourceConfigs, nil + return allSourceConfigs, nil } // Get returns a template source by key. @@ -190,7 +229,10 @@ func (sm *sourceManager) CreateSource(ctx context.Context, config *SourceConfig) case SourceKindResource: source, err = NewJsonTemplateSource(SourceDefault.Name, string(resources.TemplatesJson)) default: - err = fmt.Errorf("%w, '%s'", ErrSourceTypeInvalid, config.Type) + err = sm.serviceLocator.ResolveNamed(string(config.Type), &source) + if err != nil { + err = fmt.Errorf("%w, '%s', %w", ErrSourceTypeInvalid, config.Type, err) + } } if err != nil { diff --git a/cli/azd/pkg/templates/source_manager_test.go b/cli/azd/pkg/templates/source_manager_test.go index d761dbc246..e487ebfc66 100644 --- a/cli/azd/pkg/templates/source_manager_test.go +++ b/cli/azd/pkg/templates/source_manager_test.go @@ -17,7 +17,7 @@ import ( func Test_sourceManager_List(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) config := config.NewConfig(nil) _ = config.Set("template.sources", map[string]interface{}{ @@ -39,7 +39,7 @@ func Test_sourceManager_List(t *testing.T) { func Test_sourceManager_List_EmptySources(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) config := config.NewConfig(nil) _ = config.Set("template.sources", map[string]interface{}{}) @@ -56,7 +56,7 @@ func Test_sourceManager_List_EmptySources(t *testing.T) { func Test_sourceManager_List_UndefinedSources(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) config := config.NewConfig(nil) configManager.On("Load").Return(config, nil) @@ -73,7 +73,7 @@ func Test_sourceManager_List_UndefinedSources(t *testing.T) { func Test_sourceManager_Get(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) config := config.NewConfig(nil) _ = config.Set("template.sources", map[string]interface{}{ @@ -94,7 +94,7 @@ func Test_sourceManager_Get(t *testing.T) { func Test_sourceManager_Add(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) config := config.NewConfig(defaultTemplateSourceData) configManager.On("Load").Return(config, nil) @@ -112,7 +112,7 @@ func Test_sourceManager_Add(t *testing.T) { func Test_sourceManager_Add_DuplicateKey(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) key := "test" config := config.NewConfig(nil) @@ -131,7 +131,7 @@ func Test_sourceManager_Add_DuplicateKey(t *testing.T) { func Test_sourceManager_Remove(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) key := "test" config := config.NewConfig(defaultTemplateSourceData) @@ -146,7 +146,7 @@ func Test_sourceManager_Remove(t *testing.T) { func Test_sourceManager_Remove_SourceNotFound(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) key := "invalid" config := config.NewConfig(defaultTemplateSourceData) @@ -162,7 +162,7 @@ func Test_sourceManager_CreateSource(t *testing.T) { mockAwesomeAzdTemplateSource(mockContext) configManager := &mockUserConfigManager{} - sm := NewSourceManager(configManager, mockContext.HttpClient) + sm := NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient) configDir, err := config.GetUserConfigDir() require.NoError(t, err) diff --git a/cli/azd/pkg/templates/template.go b/cli/azd/pkg/templates/template.go index 344410d66b..400c67cf3e 100644 --- a/cli/azd/pkg/templates/template.go +++ b/cli/azd/pkg/templates/template.go @@ -9,6 +9,8 @@ import ( ) type Template struct { + Id string `json:"id"` + // Name is the friendly short name of the template. Name string `json:"name"` @@ -22,6 +24,17 @@ type Template struct { // "{owner}/{repo}" for GitHub repositories, // or "{repo}" for GitHub repositories under Azure-Samples (default organization). RepositoryPath string `json:"repositoryPath"` + + // Additional metadata about the template + Metadata Metadata `json:"metadata,omitempty"` +} + +// Metadata contains additional metadata about the template +// This metadata is used to modify azd project, environment config and environment variables during azd init commands. +type Metadata struct { + Variables map[string]string `json:"variables,omitempty"` + Config map[string]string `json:"config,omitempty"` + Project map[string]string `json:"project,omitempty"` } // Display writes a string representation of the template suitable for display. diff --git a/cli/azd/pkg/templates/template_manager.go b/cli/azd/pkg/templates/template_manager.go index 4287a7bf20..f0308b4443 100644 --- a/cli/azd/pkg/templates/template_manager.go +++ b/cli/azd/pkg/templates/template_manager.go @@ -18,11 +18,13 @@ var ( type TemplateManager struct { sourceManager SourceManager sources []Source + console input.Console } -func NewTemplateManager(sourceManager SourceManager) (*TemplateManager, error) { +func NewTemplateManager(sourceManager SourceManager, console input.Console) (*TemplateManager, error) { return &TemplateManager{ sourceManager: sourceManager, + console: console, }, nil } @@ -34,6 +36,10 @@ type sourceFilterPredicate func(config *SourceConfig) bool // ListTemplates retrieves the list of templates in a deterministic order. func (tm *TemplateManager) ListTemplates(ctx context.Context, options *ListOptions) ([]*Template, error) { + msg := "Retrieving templates..." + tm.console.ShowSpinner(ctx, msg, input.Step) + defer tm.console.StopSpinner(ctx, "", input.StepDone) + allTemplates := []*Template{} var filterPredicate sourceFilterPredicate @@ -74,31 +80,33 @@ func (tm *TemplateManager) ListTemplates(ctx context.Context, options *ListOptio } func (tm *TemplateManager) GetTemplate(ctx context.Context, path string) (*Template, error) { - absTemplatePath, err := Absolute(path) + sources, err := tm.getSources(ctx, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed getting template sources: %w", err) } - allTemplates, err := tm.ListTemplates(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed listing templates: %w", err) - } + var match *Template + var sourceErr error - matchingIndex := slices.IndexFunc(allTemplates, func(template *Template) bool { - absPath, err := Absolute(template.RepositoryPath) + for _, source := range sources { + template, err := source.GetTemplate(ctx, path) if err != nil { - log.Printf("failed to get absolute path for template '%s': %s", template.RepositoryPath, err.Error()) - return false + sourceErr = err + } else if template != nil { + match = template + break } + } - return absPath == absTemplatePath - }) + if match != nil { + return match, nil + } - if matchingIndex == -1 { - return nil, fmt.Errorf("template with name '%s' was not found, %w", path, ErrTemplateNotFound) + if sourceErr != nil { + return nil, fmt.Errorf("failed getting template: %w", sourceErr) } - return allTemplates[matchingIndex], nil + return nil, ErrTemplateNotFound } func (tm *TemplateManager) getSources(ctx context.Context, filter sourceFilterPredicate) ([]Source, error) { @@ -162,28 +170,55 @@ func PromptTemplate( // If stdin is not interactive (non-tty), we ensure the options are not formatted. isInteractive := console.IsSpinnerInteractive() - choices := make([]string, 0, len(templates)+1) + templateChoices := []*Template{} + duplicateNames := []string{} + + // Check for duplicate template names + for _, template := range templates { + hasDuplicateName := slices.ContainsFunc(templateChoices, func(t *Template) bool { + return t.Name == template.Name + }) + + if hasDuplicateName { + duplicateNames = append(duplicateNames, template.Name) + } + + templateChoices = append(templateChoices, template) + } + + templateNames := make([]string, 0, len(templates)+1) - // prepend the minimal template option to guarantee first selection + // Prepend the minimal template option to guarantee first selection minimalChoice := "Minimal" if isInteractive { minimalChoice += "\n" } - choices = append(choices, minimalChoice) + templateNames = append(templateNames, minimalChoice) for _, template := range templates { templateChoice := template.Name + + // Disambiguate duplicate template names with source identifier + if slices.Contains(duplicateNames, template.Name) { + templateChoice += fmt.Sprintf(" (%s)", template.Source) + } + if isInteractive { repoPath := output.WithGrayFormat("(%s)", template.RepositoryPath) templateChoice += fmt.Sprintf("\n %s\n", repoPath) } - choices = append(choices, templateChoice) + + if slices.Contains(templateNames, templateChoice) { + duplicateNames = append(duplicateNames, templateChoice) + } + + templateNames = append(templateNames, templateChoice) } selected, err := console.Select(ctx, input.ConsoleOptions{ Message: message, - Options: choices, - DefaultValue: choices[0], + Options: templateNames, + DefaultValue: templateNames[0], }) // separate this prompt from the next log diff --git a/cli/azd/pkg/templates/template_manager_test.go b/cli/azd/pkg/templates/template_manager_test.go index 23fc423b96..142b94d5ea 100644 --- a/cli/azd/pkg/templates/template_manager_test.go +++ b/cli/azd/pkg/templates/template_manager_test.go @@ -24,9 +24,13 @@ var defaultTemplateSourceData = map[string]interface{}{ func Test_Templates_NewTemplateManager(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) templateManager, err := NewTemplateManager( - NewSourceManager(config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())), + NewSourceManager( + NewSourceOptions(), + mockContext.Container, + config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())), mockContext.HttpClient, ), + mockContext.Console, ) require.NoError(t, err) require.NotNil(t, templateManager) @@ -39,7 +43,10 @@ func Test_Templates_ListTemplates(t *testing.T) { configManager := &mockUserConfigManager{} configManager.On("Load").Return(config.NewConfig(defaultTemplateSourceData), nil) - templateManager, err := NewTemplateManager(NewSourceManager(configManager, mockContext.HttpClient)) + templateManager, err := NewTemplateManager( + NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient), + mockContext.Console, + ) require.NoError(t, err) templates, err := templateManager.ListTemplates(*mockContext.Context, nil) @@ -76,7 +83,10 @@ func Test_Templates_ListTemplates_SourceError(t *testing.T) { }) configManager.On("Load").Return(config, nil) - templateManager, err := NewTemplateManager(NewSourceManager(configManager, mockContext.HttpClient)) + templateManager, err := NewTemplateManager( + NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient), + mockContext.Console, + ) require.NoError(t, err) // An invalid source should not cause an unrecoverable error @@ -91,7 +101,10 @@ func Test_Templates_GetTemplate_WithValidPath(t *testing.T) { configManager := &mockUserConfigManager{} configManager.On("Load").Return(config.NewConfig(defaultTemplateSourceData), nil) - templateManager, err := NewTemplateManager(NewSourceManager(configManager, mockContext.HttpClient)) + templateManager, err := NewTemplateManager( + NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient), + mockContext.Console, + ) require.NoError(t, err) rel := "todo-nodejs-mongo" @@ -110,7 +123,10 @@ func Test_Templates_GetTemplate_WithInvalidPath(t *testing.T) { configManager := &mockUserConfigManager{} configManager.On("Load").Return(config.NewConfig(defaultTemplateSourceData), nil) - templateManager, err := NewTemplateManager(NewSourceManager(configManager, mockContext.HttpClient)) + templateManager, err := NewTemplateManager( + NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient), + mockContext.Console, + ) require.NoError(t, err) templateName := "not-a-valid-template-name" @@ -125,7 +141,10 @@ func Test_Templates_GetTemplate_WithNotFoundPath(t *testing.T) { configManager := &mockUserConfigManager{} configManager.On("Load").Return(config.NewConfig(defaultTemplateSourceData), nil) - templateManager, err := NewTemplateManager(NewSourceManager(configManager, mockContext.HttpClient)) + templateManager, err := NewTemplateManager( + NewSourceManager(NewSourceOptions(), mockContext.Container, configManager, mockContext.HttpClient), + mockContext.Console, + ) require.NoError(t, err) templateName := "not-a-valid-template-path" diff --git a/cli/azd/test/functional/initialize_test.go b/cli/azd/test/functional/initialize_test.go index 8d30ba3e08..ec7eec223a 100644 --- a/cli/azd/test/functional/initialize_test.go +++ b/cli/azd/test/functional/initialize_test.go @@ -63,7 +63,7 @@ func Test_CommandsAndActions_Initialize(t *testing.T) { // Creates the azd root command with a "Skip" middleware that will skip the invocation // of the underlying command / actions - rootCmd := cmd.NewRootCmd(true, chain) + rootCmd := cmd.NewRootCmd(ctx, true, chain) testCommand(t, rootCmd, ctx, chain, tempDir) } @@ -85,7 +85,7 @@ func testCommand( fullCmd := fmt.Sprintf("%s %s", testCmd.Parent().CommandPath(), use) args := strings.Split(fullCmd, " ")[1:] args = append(args, "--cwd", cwd) - childCmd := cmd.NewRootCmd(true, chain) + childCmd := cmd.NewRootCmd(ctx, true, chain) childCmd.SetArgs(args) err := childCmd.ExecuteContext(ctx) require.NoError(t, err) diff --git a/cli/azd/test/mocks/mockdevcentersdk/mocks.go b/cli/azd/test/mocks/mockdevcentersdk/mocks.go new file mode 100644 index 0000000000..1630c4c8df --- /dev/null +++ b/cli/azd/test/mocks/mockdevcentersdk/mocks.go @@ -0,0 +1,333 @@ +package mockdevcentersdk + +import ( + "fmt" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/azure/azure-dev/cli/azd/pkg/devcentersdk" + "github.com/azure/azure-dev/cli/azd/test/mocks" +) + +func MockDevCenterGraphQuery(mockContext *mocks.MockContext, devCenters []*devcentersdk.DevCenter) { + mockContext.HttpClient.When(func(request *http.Request) bool { + return strings.Contains(request.URL.Path, "providers/Microsoft.ResourceGraph/resources") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + resources := []*devcentersdk.GenericResource{} + for _, devCenter := range devCenters { + resources = append(resources, &devcentersdk.GenericResource{ + Id: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.DevCenter/projects/Project1", + devCenter.SubscriptionId, + devCenter.ResourceGroup, + ), + Location: "eastus2", + Name: "Project1", + Type: "microsoft.devcenter/projects", + TenantId: "TENANT_ID", + Properties: map[string]interface{}{ + "devCenterUri": devCenter.ServiceUri, + "devCenterId": devCenter.Id, + }, + }) + } + + body := armresourcegraph.ClientResourcesResponse{ + QueryResponse: armresourcegraph.QueryResponse{ + Data: resources, + }, + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, body) + }) +} + +func MockListEnvironmentsByProject( + mockContext *mocks.MockContext, + projectName string, + environments []*devcentersdk.Environment, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf("/projects/%s/environments", projectName) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := devcentersdk.EnvironmentListResponse{ + Value: environments, + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} + +func MockListEnvironmentsByUser( + mockContext *mocks.MockContext, + projectName string, + userId string, + environments []*devcentersdk.Environment, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf("/projects/%s/users/%s/environments", projectName, userId) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := devcentersdk.EnvironmentListResponse{ + Value: environments, + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} + +func MockGetEnvironment( + mockContext *mocks.MockContext, + projectName string, + userId string, + environmentName string, + environment *devcentersdk.Environment, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf( + "/projects/%s/users/%s/environments/%s", + projectName, + userId, + environmentName, + ) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := environment + + if environment == nil { + return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound) + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} + +func MockDeleteEnvironment( + mockContext *mocks.MockContext, + projectName string, + userId string, + environmentName string, + operationStatus *devcentersdk.OperationStatus, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodDelete && + request.URL.Path == fmt.Sprintf( + "/projects/%s/users/%s/environments/%s", + projectName, + userId, + environmentName, + ) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + if operationStatus == nil { + return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound) + } + + if operationStatus.Status == "Succeeded" { + response, err := mocks.CreateHttpResponseWithBody(request, http.StatusAccepted, operationStatus) + response.Header.Set( + "Location", + fmt.Sprintf("https://%s/projects/%s/operationstatuses/delete", request.Host, projectName), + ) + + return response, err + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusBadRequest, operationStatus) + }) + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, fmt.Sprintf( + "/projects/%s/operationstatuses/delete", + projectName, + )) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, operationStatus) + }) + + return mockRequest +} + +func MockGetEnvironmentDefinition( + mockContext *mocks.MockContext, + projectName string, + catalogName string, + environmentDefinitionName string, + environmentDefinition *devcentersdk.EnvironmentDefinition, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf( + "/projects/%s/catalogs/%s/environmentDefinitions/%s", + projectName, + catalogName, + environmentDefinitionName, + ) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := environmentDefinition + + if environmentDefinition == nil { + return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound) + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} + +func MockPutEnvironment( + mockContext *mocks.MockContext, + projectName string, + userId string, + environmentName string, + operationStatus *devcentersdk.OperationStatus, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodPut && + request.URL.Path == fmt.Sprintf( + "/projects/%s/users/%s/environments/%s", + projectName, + userId, + environmentName, + ) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + if operationStatus.Status == "Succeeded" { + response, err := mocks.CreateHttpResponseWithBody(request, http.StatusCreated, operationStatus) + response.Header.Set( + "Location", + fmt.Sprintf("https://%s/projects/%s/operationstatuses/put", request.Host, projectName), + ) + + return response, err + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusBadRequest, operationStatus) + }) + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + strings.Contains(request.URL.Path, fmt.Sprintf( + "/projects/%s/operationstatuses/put", + projectName, + )) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, operationStatus) + }) + + return mockRequest +} + +func MockListEnvironmentDefinitions( + mockContext *mocks.MockContext, + projectName string, + environmentDefinitions []*devcentersdk.EnvironmentDefinition, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf("/projects/%s/environmentDefinitions", projectName) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := devcentersdk.EnvironmentDefinitionListResponse{ + Value: environmentDefinitions, + } + + if environmentDefinitions == nil { + return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound) + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} + +func MockListCatalogs( + mockContext *mocks.MockContext, + projectName string, + catalogs []*devcentersdk.Catalog, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf("/projects/%s/catalogs", projectName) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := devcentersdk.CatalogListResponse{ + Value: catalogs, + } + + if catalogs == nil { + return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound) + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} + +func MockListEnvironmentTypes( + mockContext *mocks.MockContext, + projectName string, + environmentTypes []*devcentersdk.EnvironmentType, +) *http.Request { + mockRequest := &http.Request{} + + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && + request.URL.Path == fmt.Sprintf("/projects/%s/environmentTypes", projectName) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + *mockRequest = *request + + response := devcentersdk.EnvironmentTypeListResponse{ + Value: environmentTypes, + } + + if environmentTypes == nil { + return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound) + } + + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response) + }) + + return mockRequest +} diff --git a/go.mod b/go.mod index 3e777f36b1..5c33111ab7 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,9 @@ require github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/arma require github.com/bmatcuk/doublestar/v4 v4.6.0 require ( + dario.cat/mergo v1.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.1.1 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.7.1 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b ) diff --git a/go.sum b/go.sum index 955a6a82bd..351413d20a 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8= github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= @@ -69,6 +71,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice v github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice v1.0.0/go.mod h1:avvc5/7qR4taCvAhOM7KFXuEHhAU0Wek9YX7sh9H3EM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization v1.0.0 h1:qtRcg5Y7jNJ4jEzPq4GpWLfTspHdNe2ZK6LjwGcjgmU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization v1.0.0/go.mod h1:lPneRe3TwsoDRKY4O6YDLXHhEWrD+TIRa8XrV/3/fqw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.1.1 h1:6A4M8smF+y8nM/DYsLNQz9n7n2ZGaEVqfz8ZWQirQkI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.1.1/go.mod h1:WqyxV5S0VtXD2+2d6oPqOvyhGubCvzLCKSAKgQ004Uk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.4.1 h1:ynIxbR7wH5nBEJzprbeBFVBtoYTYcQbN39vM7eNS3Xc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.4.1/go.mod h1:nUhnLNlOtAVpn/PRwJKIf3ulXLvdMiWlGk8nufEUaKc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v0.6.0 h1:Z5/bDxQL2Zc9t6ZDwdRU60bpLHZvoKOeuaM7XVbf2z0= @@ -79,6 +83,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0 h1:lMW1lD/ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0/go.mod h1:ceIuwmxDWptoW3eCqSXlnPsZFKh4X+R38dWPv7GS9Vs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.0.0 h1:Jc2KcpCDMu7wJfkrzn7fs/53QMDXH78GuqnH4HOd7zs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.0.0/go.mod h1:PFVgFsclKzPqYRT/BiwpfUN22cab0C7FlgXR3iWpwMo= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.7.1 h1:eoQrCw9DMThzbJ32fHXZtISnURk6r0TozXiWuTsay5s= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.7.1/go.mod h1:21rlzm+SuYrS9ARS92XEGxcHQeLVDcaY2YV30rHjSd4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.0.0 h1:xXmHA6JxGDHOY2anNQhpgIibZOiEaOvPLZOiAs07/4k= diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index c6278908c9..d9e8b4a8e5 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -396,6 +396,47 @@ ] } } + }, + "platform": { + "type": "object", + "title": "The platform configuration used for the project.", + "description": "Optional. Provides additional configuration for platform specific features such as Azure Dev Center.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "title": "The platform type.", + "description": "Required. The platform type. (Example: devcenter)", + "enum": [ + "devcenter" + ] + }, + "config": { + "type": "object", + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "devcenter" + } + } + }, + "then": { + "properties": { + "config": { + "$ref": "#/definitions/azureDevCenterConfig" + } + } + } + } + ] } }, "definitions": { @@ -586,6 +627,39 @@ "description": "Optional. The Azure Storage endpoint. (Default: blob.core.windows.net)" } } + }, + "azureDevCenterConfig": { + "type": "object", + "title": "The dev center configuration used for the project.", + "description": "Optional. Provides additional project configuration for Azure Dev Center integration.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "The name of the Azure Dev Center", + "description": "Optional. Used as the default dev center for this project." + }, + "project": { + "type": "string", + "title": "The name of the Azure Dev Center project.", + "description": "Optional. Used as the default dev center project for this project." + }, + "catalog": { + "type": "string", + "title": "The name of the Azure Dev Center catalog.", + "description": "Optional. Used as the default dev center catalog for this project." + }, + "environmentDefinition": { + "type": "string", + "title": "The name of the Dev Center catalog environment definition.", + "description": "Optional. Used as the default dev center environment definition for this project." + }, + "environmentType": { + "type": "string", + "title": "The Dev Center project environment type used for the deployment environment.", + "description": "Optional. Used as the default environment type for this project." + } + } } } } \ No newline at end of file