diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 6cb390088c1..14df670cb60 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1534,6 +1534,23 @@ To re-generate gRPC clients: The following are a list of available gRPC services for extension developer to integrate with `azd` core. +Service-level `env` entries from `azure.yaml` are expanded by `azd` against the environment for +the current session and forwarded to extensions as `ServiceConfig.environment`. The forwarded +values are the expanded results, not the raw `${VAR}` templates. When a service config is written +back through `ProjectService.AddService`, entries whose values are unchanged keep their original +`${VAR}` references in `azure.yaml`, while new or changed values are persisted as literals (any +`$` or `\` in a stored literal is escaped in `azure.yaml` so the value round-trips unchanged). + +Because `ServiceConfig.environment` carries expanded values, `AddService` cannot author `${VAR}` +references: a new service or a new env key is always persisted as a literal. To create or edit +raw `${VAR}` templates in `azure.yaml`, use the service config RPCs instead — +`GetServiceConfigSection`/`SetServiceConfigSection` (or the `Value` variants) with the `env` +path — which read and write the raw document exactly as written. Two caveats when using them: +values written through the config RPCs are treated as templates, so escape `$` as `$$` when a +literal dollar is intended; and when editing templates, read them through the config RPCs too — +writing values taken from `ServiceConfig.environment` back through a config RPC would persist +the expanded results in place of the original `${VAR}` references. + ### Table of Contents - [Project Service](#project-service) diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/proto/models.proto b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/proto/models.proto index 4bd49e4e10f..4a9745a5e7a 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/proto/models.proto +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/proto/models.proto @@ -91,6 +91,7 @@ message ServiceConfig { string output_path = 8; string image = 9; repeated string uses = 13; + map environment = 14; } // InfraOptions message definition diff --git a/cli/azd/grpc/proto/models.proto b/cli/azd/grpc/proto/models.proto index ce43288e821..e492b2d586a 100644 --- a/cli/azd/grpc/proto/models.proto +++ b/cli/azd/grpc/proto/models.proto @@ -98,6 +98,7 @@ message ServiceConfig { google.protobuf.Struct config = 11; google.protobuf.Struct additional_properties = 12; repeated string uses = 13; + map environment = 14; } // InfraOptions message definition diff --git a/cli/azd/internal/grpcserver/framework_service.go b/cli/azd/internal/grpcserver/framework_service.go index 19e16e5172a..f55cbc040e1 100644 --- a/cli/azd/internal/grpcserver/framework_service.go +++ b/cli/azd/internal/grpcserver/framework_service.go @@ -11,10 +11,12 @@ import ( "sync" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" "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/project" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -25,6 +27,7 @@ type FrameworkService struct { azdext.UnimplementedFrameworkServiceServer container *ioc.NestedContainer extensionManager *extensions.Manager + lazyEnv *lazy.Lazy[*environment.Environment] providerMap map[string]*grpcbroker.MessageBroker[azdext.FrameworkServiceMessage] providerMapMu sync.Mutex } @@ -33,10 +36,12 @@ type FrameworkService struct { func NewFrameworkService( container *ioc.NestedContainer, extensionManager *extensions.Manager, + lazyEnv *lazy.Lazy[*environment.Environment], ) azdext.FrameworkServiceServer { return &FrameworkService{ container: container, extensionManager: extensionManager, + lazyEnv: lazyEnv, providerMap: make(map[string]*grpcbroker.MessageBroker[azdext.FrameworkServiceMessage]), } } @@ -120,6 +125,7 @@ func (s *FrameworkService) onRegisterRequest( extension, broker, console, + s.lazyEnv, ) }) diff --git a/cli/azd/internal/grpcserver/framework_service_test.go b/cli/azd/internal/grpcserver/framework_service_test.go index db7f0c269a9..b10bd6c9ec4 100644 --- a/cli/azd/internal/grpcserver/framework_service_test.go +++ b/cli/azd/internal/grpcserver/framework_service_test.go @@ -4,19 +4,81 @@ package grpcserver import ( + "errors" "testing" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "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/project" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" "github.com/stretchr/testify/require" ) func TestNewFrameworkService(t *testing.T) { t.Parallel() container := ioc.NewNestedContainer(nil) - svc := NewFrameworkService(container, nil) + svc := NewFrameworkService(container, nil, nil) require.NotNil(t, svc) } +// Registration and resolution of an external framework service must succeed regardless of +// whether the environment can be loaded — the environment is resolved lazily per operation. +// Expansion behavior with and without an environment is covered by the +// Test_ExternalFrameworkService_toProtoServiceConfig* tests in pkg/project. +func TestFrameworkService_onRegisterRequest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + lazyEnv *lazy.Lazy[*environment.Environment] + }{ + { + name: "no lazy env", + lazyEnv: nil, + }, + { + name: "env load error", + lazyEnv: lazy.NewLazy(func() (*environment.Environment, error) { + return nil, errors.New("no environment") + }), + }, + { + name: "env available", + lazyEnv: lazy.From(environment.NewWithValues("test", nil)), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + container := ioc.NewNestedContainer(nil) + ioc.RegisterInstance[input.Console](container, mockinput.NewMockConsole()) + + svc := NewFrameworkService(container, nil, tc.lazyEnv).(*FrameworkService) + + var language string + _, err := svc.onRegisterRequest( + t.Context(), + &azdext.RegisterFrameworkServiceRequest{Language: "rust"}, + &extensions.Extension{Id: "test.framework"}, + nil, + &language, + ) + require.NoError(t, err) + + var frameworkService project.FrameworkService + err = container.ResolveNamed("rust", &frameworkService) + require.NoError(t, err) + require.NotNil(t, frameworkService) + }) + } +} + func TestNewServiceTargetService(t *testing.T) { t.Parallel() container := ioc.NewNestedContainer(nil) diff --git a/cli/azd/internal/grpcserver/project_service.go b/cli/azd/internal/grpcserver/project_service.go index 3dac60c2202..58f1fc704d0 100644 --- a/cli/azd/internal/grpcserver/project_service.go +++ b/cli/azd/internal/grpcserver/project_service.go @@ -6,6 +6,7 @@ package grpcserver import ( "context" "fmt" + "log" "github.com/azure/azure-dev/cli/azd/internal/mapper" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -25,7 +26,6 @@ type projectService struct { azdext.UnimplementedProjectServiceServer lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext] - lazyEnvManager *lazy.Lazy[environment.Manager] lazyResourceManager *lazy.Lazy[project.ResourceManager] lazyEnv *lazy.Lazy[*environment.Environment] importManager *project.ImportManager @@ -39,7 +39,6 @@ type projectService struct { // // Parameters: // - lazyAzdContext: Lazy-loaded Azure Developer CLI context for project directory operations -// - lazyEnvManager: Lazy-loaded environment manager for handling Azure environments // - lazyResourceManager: Lazy-loaded resource manager for resolving target resources // - lazyEnv: Lazy-loaded environment for accessing environment variables and subscription info // - lazyProjectConfig: Lazy-loaded project configuration for accessing project settings @@ -47,7 +46,6 @@ type projectService struct { // Returns an implementation of azdext.ProjectServiceServer. func NewProjectService( lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext], - lazyEnvManager *lazy.Lazy[environment.Manager], lazyResourceManager *lazy.Lazy[project.ResourceManager], lazyEnv *lazy.Lazy[*environment.Environment], lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], @@ -56,7 +54,6 @@ func NewProjectService( ) azdext.ProjectServiceServer { return &projectService{ lazyAzdContext: lazyAzdContext, - lazyEnvManager: lazyEnvManager, lazyResourceManager: lazyResourceManager, lazyEnv: lazyEnv, lazyProjectConfig: lazyProjectConfig, @@ -117,8 +114,9 @@ func (s *projectService) validateServiceExists(ctx context.Context, serviceName } // Get retrieves the complete project configuration including all services and metadata. -// This method resolves environment variables in configuration values using the default environment -// and converts the internal project configuration to the protobuf format for gRPC communication. +// This method resolves environment variables in configuration values using the environment +// for the current session and converts the internal project configuration to the protobuf +// format for gRPC communication. // // The returned project includes: // - Basic project metadata (name, resource group, path) @@ -126,41 +124,16 @@ func (s *projectService) validateServiceExists(ctx context.Context, serviceName // - All configured services with their settings // - Template metadata if available // -// Environment variable substitution is performed using the default environment's variables. +// Environment variable substitution is performed using the session environment's variables; +// when no environment is available, values expand to empty strings. func (s *projectService) Get(ctx context.Context, req *azdext.EmptyRequest) (*azdext.GetProjectResponse, error) { - azdContext, err := s.lazyAzdContext.GetValue() - if err != nil { - return nil, err - } - projectConfig, err := s.lazyProjectConfig.GetValue() if err != nil { return nil, err } - envKeyMapper := func(env string) string { - return "" - } - - defaultEnvironment, err := azdContext.GetDefaultEnvironmentName() - if err != nil { - return nil, err - } - - envManager, err := s.lazyEnvManager.GetValue() - if err != nil { - return nil, err - } - - if defaultEnvironment != "" { - env, err := envManager.Get(ctx, defaultEnvironment) - if err == nil && env != nil { - envKeyMapper = env.Getenv - } - } - var project *azdext.ProjectConfig - if err := mapper.WithResolver(envKeyMapper).Convert(projectConfig, &project); err != nil { + if err := mapper.WithResolver(s.envResolver()).Convert(projectConfig, &project); err != nil { return nil, fmt.Errorf("converting project config to proto: %w", err) } @@ -169,6 +142,22 @@ func (s *projectService) Get(ctx context.Context, req *azdext.EmptyRequest) (*az }, nil } +// envResolver returns a resolver backed by the environment for the current session (honoring +// the -e/--environment flag, like the framework, service target and event services), falling +// back to empty values when no environment is available. +func (s *projectService) envResolver() mapper.Resolver { + if s.lazyEnv != nil { + env, err := s.lazyEnv.GetValue() + if err != nil { + log.Printf("project service: environment unavailable, expanding with empty values: %v", err) + } else if env != nil { + return env.Getenv + } + } + + return noEnvResolver +} + // AddService adds a new service to the project configuration and persists the changes. // The service configuration is converted from the protobuf format to the internal representation // and added to the project's services map. The updated project configuration is then saved to disk. @@ -219,6 +208,13 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR serviceConfig.EventDispatcher = ext.NewEventDispatcher[project.ServiceLifecycleEventArgs]() } + // Incoming env values are expanded literals. For values the caller did not change, + // keep the original ${VAR} templates from azure.yaml so a read-modify-write round + // trip does not bake expanded values into the persisted file. + if existingService, exists := projectConfig.Services[req.Service.Name]; exists { + preserveUnchangedEnvTemplates(existingService, serviceConfig, s.envResolver()) + } + // Set the Project reference and Name (required fields not set by mapper) serviceConfig.Project = projectConfig serviceConfig.Name = req.Service.Name @@ -231,6 +227,32 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR return &azdext.EmptyResponse{}, nil } +// preserveUnchangedEnvTemplates restores the original env value templates from existing for +// every incoming env entry whose expanded value is unchanged, so unmodified entries keep +// their ${VAR} references when the service config is persisted back to azure.yaml. +func preserveUnchangedEnvTemplates(existing, incoming *project.ServiceConfig, resolver mapper.Resolver) { + for key, incomingValue := range incoming.Environment { + existingValue, has := existing.Environment[key] + if !has { + continue + } + + existingExpanded, err := existingValue.Envsubst(resolver) + if err != nil { + continue + } + + incomingExpanded, err := incomingValue.Envsubst(resolver) + if err != nil { + continue + } + + if existingExpanded == incomingExpanded { + incoming.Environment[key] = existingValue + } + } +} + // GetConfigSection retrieves a configuration section from the project configuration. // This method provides access to both core struct fields (e.g., "infra", "services") // and extension-specific configuration data stored in AdditionalProperties using @@ -801,26 +823,7 @@ func (s *projectService) GetResolvedServices( return nil, err } - envKeyMapper := func(env string) string { - return "" - } - - defaultEnvironment, err := azdContext.GetDefaultEnvironmentName() - if err != nil { - return nil, err - } - - envManager, err := s.lazyEnvManager.GetValue() - if err != nil { - return nil, err - } - - if defaultEnvironment != "" { - env, err := envManager.Get(ctx, defaultEnvironment) - if err == nil && env != nil { - envKeyMapper = env.Getenv - } - } + envKeyMapper := s.envResolver() // Get resolved services using ImportManager servicesStable, err := s.importManager.ServiceStable(ctx, projectConfig) diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index 2c172cb6867..1e9e500b2f7 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -48,9 +48,6 @@ func Test_ProjectService_NoProject(t *testing.T) { lazyAzdContext := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, azdcontext.ErrNoProject }) - lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { - return nil, azdcontext.ErrNoProject - }) lazyProjectConfig := lazy.NewLazy(func() (*project.ProjectConfig, error) { return nil, azdcontext.ErrNoProject }) @@ -60,7 +57,7 @@ func Test_ProjectService_NoProject(t *testing.T) { // Create the service with ImportManager. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, ghCli) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, ghCli) _, err := service.Get(*mockContext.Context, &azdext.EmptyRequest{}) require.Error(t, err) } @@ -98,7 +95,6 @@ func Test_ProjectService_Flow(t *testing.T) { // Create lazy-loaded instances. lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(&projectConfig) // Create an environment and set an environment variable. @@ -116,7 +112,7 @@ func Test_ProjectService_Flow(t *testing.T) { // Create the service with ImportManager. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, ghCli) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, ghCli) // Test: Retrieve project details. getResponse, err := service.Get(*mockContext.Context, &azdext.EmptyRequest{}) @@ -147,16 +143,8 @@ func Test_ProjectService_AddService(t *testing.T) { err := project.Save(*mockContext.Context, &projectConfig, azdContext.ProjectPath()) require.NoError(t, err) - // Configure and initialize environment manager. - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - require.NotNil(t, envManager) - // Create lazy-loaded instances. lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(&projectConfig) // Create mock GitHub CLI. @@ -164,7 +152,7 @@ func Test_ProjectService_AddService(t *testing.T) { // Create the project service with ImportManager. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, ghCli) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, ghCli) // Prepare a new service addition request. serviceRequest := &azdext.AddServiceRequest{ @@ -193,6 +181,128 @@ func Test_ProjectService_AddService(t *testing.T) { require.Equal(t, project.ContainerAppTarget, serviceConfig.Host) } +// Test_ProjectService_Get_ResolvesServiceEnvironment verifies that service-level env values +// in Get responses are expanded against the session environment (the same lazy environment +// used by the framework, service target and event services). +func Test_ProjectService_Get_ResolvesServiceEnvironment(t *testing.T) { + projectConfig := &project.ProjectConfig{ + Name: "test", + Services: map[string]*project.ServiceConfig{ + "api": { + Name: "api", + RelativePath: "./src/api", + Language: project.ServiceLanguagePython, + Host: project.ContainerAppTarget, + Environment: osutil.ExpandableMap{ + "FROM_ENV": osutil.NewExpandableString("${SERVICE_VALUE}"), + "STATIC": osutil.NewExpandableString("static-value"), + }, + }, + }, + } + + env := environment.NewWithValues("test", map[string]string{ + "SERVICE_VALUE": "resolved", + }) + + service := NewProjectService( + nil, nil, lazy.From(env), lazy.From(projectConfig), project.NewImportManager(nil), nil) + + getResponse, err := service.Get(t.Context(), &azdext.EmptyRequest{}) + require.NoError(t, err) + require.NotNil(t, getResponse) + require.Equal(t, map[string]string{ + "FROM_ENV": "resolved", + "STATIC": "static-value", + }, getResponse.Project.Services["api"].Environment) +} + +// Test_ProjectService_AddService_PreservesEnvTemplates verifies that a read-modify-write +// round trip through AddService keeps the original ${VAR} env templates in azure.yaml for +// values the caller did not change, while changed or added values are persisted as literals +// (with any '$' escaped so the stored value round-trips unchanged). +func Test_ProjectService_AddService_PreservesEnvTemplates(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + temp := t.TempDir() + + // Mock GitHub CLI version check. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, string(filepath.Separator)+"gh") && args.Args[0] == "--version" + }).Respond(exec.RunResult{ + Stdout: github.Version.String(), + }) + + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + onDiskYaml := "" + + "name: test\n" + + "services:\n" + + " api:\n" + + " project: ./src/api\n" + + " host: containerapp\n" + + " language: python\n" + + " env:\n" + + " FROM_ENV: ${SERVICE_VALUE}\n" + + " CHANGED: ${OTHER_VALUE}\n" + require.NoError(t, os.WriteFile(azdContext.ProjectPath(), []byte(onDiskYaml), 0600)) + + projectConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + env := environment.NewWithValues("test", map[string]string{ + "SERVICE_VALUE": "resolved", + "OTHER_VALUE": "other-old", + }) + + service := NewProjectService( + lazy.From(azdContext), + nil, + lazy.From(env), + lazy.From(projectConfig), + project.NewImportManager(nil), + github.NewGitHubCli(mockContext.Console, mockContext.CommandRunner), + ) + + // Simulates an extension echoing back the expanded config it received, with one + // value changed and one added. + serviceRequest := &azdext.AddServiceRequest{ + Service: &azdext.ServiceConfig{ + Name: "api", + RelativePath: "./src/api", + Language: "python", + Host: "containerapp", + Environment: map[string]string{ + "FROM_ENV": "resolved", // unchanged: template must be preserved + "CHANGED": "brand-new", // changed: persisted as literal + "ADDED": "pa$$word", // added: persisted as escaped literal + }, + }, + } + + _, err = service.AddService(*mockContext.Context, serviceRequest) + require.NoError(t, err) + + rawYaml, err := os.ReadFile(azdContext.ProjectPath()) + require.NoError(t, err) + require.Contains(t, string(rawYaml), "${SERVICE_VALUE}") + require.NotContains(t, string(rawYaml), "${OTHER_VALUE}") + + updatedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + updatedEnv, err := updatedConfig.Services["api"].Environment.Expand(func(key string) string { + if key == "SERVICE_VALUE" { + return "resolved-later" + } + return "" + }) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "FROM_ENV": "resolved-later", // still a template resolving against the environment + "CHANGED": "brand-new", + "ADDED": "pa$$word", + }, updatedEnv) +} + // Test_ProjectService_AddService_PreservesExistingProperties is a regression // test for issue #8678: AddService must not drop top-level azure.yaml // properties (e.g. hooks) or pre-existing services that are present on disk but @@ -231,13 +341,7 @@ func Test_ProjectService_AddService_PreservesExistingProperties(t *testing.T) { " language: python\n" require.NoError(t, os.WriteFile(azdContext.ProjectPath(), []byte(onDiskYaml), 0600)) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) // Seed the cache with a STALE minimal config that does not reflect what is // on disk (mirrors the lazy being resolved before the template azure.yaml @@ -248,7 +352,7 @@ func Test_ProjectService_AddService_PreservesExistingProperties(t *testing.T) { ghCli := github.NewGitHubCli(mockContext.Console, mockContext.CommandRunner) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, ghCli) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, ghCli) serviceRequest := &azdext.AddServiceRequest{ Service: &azdext.ServiceConfig{ @@ -259,7 +363,7 @@ func Test_ProjectService_AddService_PreservesExistingProperties(t *testing.T) { }, } - _, err = service.AddService(*mockContext.Context, serviceRequest) + _, err := service.AddService(*mockContext.Context, serviceRequest) require.NoError(t, err) // The raw file must still contain the pre-existing top-level properties. @@ -307,15 +411,10 @@ func Test_ProjectService_ConfigSection(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("GetConfigSection_Success", func(t *testing.T) { resp, err := service.GetConfigSection(*mockContext.Context, &azdext.GetProjectConfigSectionRequest{ @@ -376,15 +475,10 @@ func Test_ProjectService_ConfigValue(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("GetConfigValue_String", func(t *testing.T) { resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ @@ -450,15 +544,10 @@ func Test_ProjectService_SetConfigSection(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("SetConfigSection_NewSection", func(t *testing.T) { // Create section data @@ -533,15 +622,10 @@ func Test_ProjectService_SetConfigValue(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("SetConfigValue_String", func(t *testing.T) { value, err := structpb.NewValue("test-string") @@ -630,15 +714,10 @@ func Test_ProjectService_UnsetConfig(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("UnsetConfig_NestedValue", func(t *testing.T) { _, err := service.UnsetConfig(*mockContext.Context, &azdext.UnsetProjectConfigRequest{ @@ -700,15 +779,10 @@ func Test_ProjectService_ConfigNilAdditionalProperties(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("GetConfigValue_NilAdditionalProperties", func(t *testing.T) { resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ @@ -783,21 +857,13 @@ func Test_ProjectService_ServiceConfiguration(t *testing.T) { err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) require.NoError(t, err) - // Configure and initialize environment manager. - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - require.NotNil(t, envManager) - // Create lazy loaders. lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) // Create the service. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("GetServiceConfigSection_Found", func(t *testing.T) { resp, err := service.GetServiceConfigSection(*mockContext.Context, &azdext.GetServiceConfigSectionRequest{ @@ -1059,21 +1125,13 @@ func Test_ProjectService_ServiceConfiguration_NilAdditionalProperties(t *testing err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) require.NoError(t, err) - // Configure and initialize environment manager. - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - require.NotNil(t, envManager) - // Create lazy loaders. lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) // Create the service. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("GetServiceConfigSection_NilAdditionalProperties", func(t *testing.T) { resp, err := service.GetServiceConfigSection(*mockContext.Context, &azdext.GetServiceConfigSectionRequest{ @@ -1139,15 +1197,10 @@ func Test_ProjectService_ChangeServiceHost(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(projectConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) // Test 1: Get the current host value getResp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ @@ -1216,21 +1269,10 @@ func Test_ProjectService_TypeValidation_InvalidChangesNotPersisted(t *testing.T) // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager( - mockContext.Container, - azdContext, - mockContext.Console, - localDataStore, - nil, - ) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(loadedConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("Project_SetInfraToInt_ShouldFailAndNotPersist", func(t *testing.T) { // Try to set "infra" (which should be an object) to an integer @@ -1409,21 +1451,10 @@ func Test_ProjectService_TypeValidation_CoercedValues(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager( - mockContext.Container, - azdContext, - mockContext.Console, - localDataStore, - nil, - ) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(loadedConfig) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("SetNameToInt_GetsCoercedToString", func(t *testing.T) { // Try to set "name" (which should be a string) to an integer @@ -1503,17 +1534,6 @@ func Test_ProjectService_EventDispatcherPreservation(t *testing.T) { // Setup lazy dependencies lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager( - mockContext.Container, - azdContext, - mockContext.Console, - localDataStore, - nil, - ) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(loadedConfig) // Step 2: Register event handlers for project and services @@ -1556,7 +1576,7 @@ func Test_ProjectService_EventDispatcherPreservation(t *testing.T) { // Create project service importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) // Step 3: Modify project configuration customValue, err := structpb.NewValue("project-custom-value") @@ -1706,17 +1726,6 @@ func Test_ProjectService_EventDispatcherPreservation_MultipleUpdates(t *testing. require.NoError(t, err) lazyAzdContext := lazy.From(azdContext) - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager( - mockContext.Container, - azdContext, - mockContext.Console, - localDataStore, - nil, - ) - require.NoError(t, err) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(loadedConfig) // Register event handler (EventDispatcher already initialized by project.Load()) @@ -1732,7 +1741,7 @@ func Test_ProjectService_EventDispatcherPreservation_MultipleUpdates(t *testing. require.NoError(t, err) importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + service := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) // Perform multiple configuration updates for i := 1; i <= 3; i++ { @@ -1789,20 +1798,13 @@ func Test_ProjectService_ServiceConfigValue_EmptyPath(t *testing.T) { err := project.Save(*mockContext.Context, &projectConfig, azdContext.ProjectPath()) require.NoError(t, err) - // Configure and initialize environment manager - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - // Create lazy-loaded instances lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(&projectConfig) // Create the service importManager := project.NewImportManager(&project.DotNetImporter{}) - projectService := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + projectService := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) t.Run("GetServiceConfigValue_EmptyPath", func(t *testing.T) { _, err := projectService.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ @@ -1854,20 +1856,13 @@ func Test_ProjectService_EmptyStringValidation(t *testing.T) { err := project.Save(*mockContext.Context, &projectConfig, azdContext.ProjectPath()) require.NoError(t, err) - // Configure and initialize environment manager - fileConfigManager := config.NewFileConfigManager(config.NewManager()) - localDataStore := environment.NewLocalFileDataStore(azdContext, fileConfigManager) - envManager, err := environment.NewManager(mockContext.Container, azdContext, mockContext.Console, localDataStore, nil) - require.NoError(t, err) - // Create lazy-loaded instances lazyAzdContext := lazy.From(azdContext) - lazyEnvManager := lazy.From(envManager) lazyProjectConfig := lazy.From(&projectConfig) // Create the service importManager := project.NewImportManager(&project.DotNetImporter{}) - projectService := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, nil) + projectService := NewProjectService(lazyAzdContext, nil, nil, lazyProjectConfig, importManager, nil) // Project-level config method validations t.Run("GetConfigValue_EmptyPath", func(t *testing.T) { @@ -1975,13 +1970,13 @@ func Test_ProjectService_EmptyStringValidation(t *testing.T) { func TestNewProjectService(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) require.NotNil(t, svc) } func TestProjectService_GetServiceTargetResource_EmptyServiceName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "", }) @@ -1996,7 +1991,7 @@ func TestProjectService_GetServiceTargetResource_ProjectConfigError(t *testing.T lazyProject := lazy.NewLazy(func() (*project.ProjectConfig, error) { return nil, errors.New("config error") }) - svc := NewProjectService(nil, nil, nil, nil, lazyProject, nil, nil) + svc := NewProjectService(nil, nil, nil, lazyProject, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "web", @@ -2014,7 +2009,7 @@ func TestProjectService_GetServiceTargetResource_ServiceNotFound(t *testing.T) { Services: map[string]*project.ServiceConfig{}, }, nil }) - svc := NewProjectService(nil, nil, nil, nil, lazyProject, nil, nil) + svc := NewProjectService(nil, nil, nil, lazyProject, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "nonexistent", @@ -2037,7 +2032,7 @@ func TestProjectService_GetServiceTargetResource_EnvError(t *testing.T) { lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { return nil, errors.New("env not found") }) - svc := NewProjectService(nil, nil, nil, lazyEnv, lazyProject, nil, nil) + svc := NewProjectService(nil, nil, lazyEnv, lazyProject, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "web", @@ -2061,7 +2056,7 @@ func TestProjectService_GetServiceTargetResource_SubscriptionEmpty(t *testing.T) lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { return environment.New("test"), nil }) - svc := NewProjectService(nil, nil, nil, lazyEnv, lazyProject, nil, nil) + svc := NewProjectService(nil, nil, lazyEnv, lazyProject, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "web", @@ -2090,7 +2085,7 @@ func TestProjectService_GetServiceTargetResource_ResourceManagerError(t *testing lazyRM := lazy.NewLazy(func() (project.ResourceManager, error) { return nil, errors.New("resource manager unavailable") }) - svc := NewProjectService(nil, nil, lazyRM, lazyEnv, lazyProject, nil, nil) + svc := NewProjectService(nil, lazyRM, lazyEnv, lazyProject, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "web", @@ -2160,7 +2155,7 @@ func TestProjectService_GetServiceTargetResource_GetTargetResourceError(t *testi lazyRM := lazy.NewLazy(func() (project.ResourceManager, error) { return rm, nil }) - svc := NewProjectService(nil, nil, lazyRM, lazyEnv, lazyProject, nil, nil) + svc := NewProjectService(nil, lazyRM, lazyEnv, lazyProject, nil, nil) _, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "web", @@ -2196,7 +2191,7 @@ func TestProjectService_GetServiceTargetResource_Success(t *testing.T) { lazyRM := lazy.NewLazy(func() (project.ResourceManager, error) { return rm, nil }) - svc := NewProjectService(nil, nil, lazyRM, lazyEnv, lazyProject, nil, nil) + svc := NewProjectService(nil, lazyRM, lazyEnv, lazyProject, nil, nil) resp, err := svc.GetServiceTargetResource(t.Context(), &azdext.GetServiceTargetResourceRequest{ ServiceName: "web", @@ -2214,7 +2209,7 @@ func TestProjectService_GetResolvedServices_AzdContextError(t *testing.T) { lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, errors.New("no azd context") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil) _, err := svc.GetResolvedServices(t.Context(), &azdext.EmptyRequest{}) require.Error(t, err) @@ -2223,7 +2218,7 @@ func TestProjectService_GetResolvedServices_AzdContextError(t *testing.T) { func TestProjectService_ParseGitHubUrl_Empty(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.ParseGitHubUrl(t.Context(), &azdext.ParseGitHubUrlRequest{ Url: "", }) @@ -2249,7 +2244,7 @@ func newProjectServiceWithYaml(t *testing.T, yamlContent string) azdext.ProjectS return environment.NewWithValues("dev", nil), nil }) - return NewProjectService(lazyCtx, nil, nil, lazyEnv, lazyPC, nil, nil) + return NewProjectService(lazyCtx, nil, lazyEnv, lazyPC, nil, nil) } func TestProjectService_GetConfigValue_EmptyPath(t *testing.T) { @@ -2284,7 +2279,7 @@ func TestProjectService_GetConfigSection_AzdContextError(t *testing.T) { lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, errors.New("no azd context") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil) _, err := svc.GetConfigSection(t.Context(), &azdext.GetProjectConfigSectionRequest{Path: "infra"}) require.Error(t, err) } @@ -2316,7 +2311,7 @@ func TestProjectService_SetConfigSection_AzdContextError(t *testing.T) { lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, errors.New("no ctx") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil) section, _ := structpb.NewStruct(map[string]any{"key": "val"}) _, err := svc.SetConfigSection(t.Context(), &azdext.SetProjectConfigSectionRequest{ Path: "custom", @@ -2340,7 +2335,7 @@ func TestProjectService_SetConfigValue_AzdContextError(t *testing.T) { lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, errors.New("no ctx") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil) val, _ := structpb.NewValue("test") _, err := svc.SetConfigValue(t.Context(), &azdext.SetProjectConfigValueRequest{ Path: "custom.key", @@ -2364,14 +2359,14 @@ func TestProjectService_UnsetConfig_AzdContextError(t *testing.T) { lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, errors.New("no ctx") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil) _, err := svc.UnsetConfig(t.Context(), &azdext.UnsetProjectConfigRequest{Path: "custom"}) require.Error(t, err) } func TestProjectService_AddService_EmptyName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.AddService(t.Context(), &azdext.AddServiceRequest{ Service: &azdext.ServiceConfig{Name: ""}, }) @@ -2383,7 +2378,7 @@ func TestProjectService_AddService_EmptyName(t *testing.T) { func TestProjectService_AddService_NilService(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.AddService(t.Context(), &azdext.AddServiceRequest{Service: nil}) require.Error(t, err) } @@ -2393,7 +2388,7 @@ func TestProjectService_AddService_AzdContextError(t *testing.T) { lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return nil, errors.New("no ctx") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil) _, err := svc.AddService(t.Context(), &azdext.AddServiceRequest{ Service: &azdext.ServiceConfig{Name: "web"}, }) @@ -2408,7 +2403,7 @@ func TestProjectService_AddService_ProjectConfigError(t *testing.T) { lazyPC := lazy.NewLazy(func() (*project.ProjectConfig, error) { return nil, errors.New("config error") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, lazyPC, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, lazyPC, nil, nil) _, err := svc.AddService(t.Context(), &azdext.AddServiceRequest{ Service: &azdext.ServiceConfig{Name: "web"}, }) @@ -2462,16 +2457,6 @@ func TestProjectService_ValidateServiceExists_Found(t *testing.T) { require.NoError(t, err) } -func TestProjectService_Get_AzdContextError(t *testing.T) { - t.Parallel() - lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { - return nil, errors.New("no ctx") - }) - svc := NewProjectService(lazyCtx, nil, nil, nil, nil, nil, nil) - _, err := svc.Get(t.Context(), &azdext.EmptyRequest{}) - require.Error(t, err) -} - func TestProjectService_Get_ProjectConfigError(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -2480,7 +2465,7 @@ func TestProjectService_Get_ProjectConfigError(t *testing.T) { lazyPC := lazy.NewLazy(func() (*project.ProjectConfig, error) { return nil, errors.New("config error") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, lazyPC, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, lazyPC, nil, nil) _, err := svc.Get(t.Context(), &azdext.EmptyRequest{}) require.Error(t, err) } @@ -2493,7 +2478,7 @@ func TestProjectService_GetResolvedServices_ProjectConfigError(t *testing.T) { lazyPC := lazy.NewLazy(func() (*project.ProjectConfig, error) { return nil, errors.New("config error") }) - svc := NewProjectService(lazyCtx, nil, nil, nil, lazyPC, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, lazyPC, nil, nil) _, err := svc.GetResolvedServices(t.Context(), &azdext.EmptyRequest{}) require.Error(t, err) } @@ -2502,7 +2487,7 @@ func TestProjectService_GetResolvedServices_ProjectConfigError(t *testing.T) { func TestProjectService_GetServiceConfigSection_EmptyServiceName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.GetServiceConfigSection(t.Context(), &azdext.GetServiceConfigSectionRequest{ ServiceName: "", }) @@ -2514,7 +2499,7 @@ func TestProjectService_GetServiceConfigSection_EmptyServiceName(t *testing.T) { func TestProjectService_GetServiceConfigValue_EmptyServiceName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.GetServiceConfigValue(t.Context(), &azdext.GetServiceConfigValueRequest{ ServiceName: "", }) @@ -2523,7 +2508,7 @@ func TestProjectService_GetServiceConfigValue_EmptyServiceName(t *testing.T) { func TestProjectService_SetServiceConfigSection_EmptyServiceName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.SetServiceConfigSection(t.Context(), &azdext.SetServiceConfigSectionRequest{ ServiceName: "", }) @@ -2532,7 +2517,7 @@ func TestProjectService_SetServiceConfigSection_EmptyServiceName(t *testing.T) { func TestProjectService_SetServiceConfigValue_EmptyServiceName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{ ServiceName: "", }) @@ -2541,7 +2526,7 @@ func TestProjectService_SetServiceConfigValue_EmptyServiceName(t *testing.T) { func TestProjectService_UnsetServiceConfig_EmptyServiceName(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.UnsetServiceConfig(t.Context(), &azdext.UnsetServiceConfigRequest{ ServiceName: "", }) @@ -2598,18 +2583,17 @@ func TestProjectService_Get_HappyPath(t *testing.T) { pc, err := project.Load(t.Context(), filepath.Join(dir, "azure.yaml")) require.NoError(t, err) lazyPC := lazy.NewLazy(func() (*project.ProjectConfig, error) { return pc, nil }) - lazyEnvMgr := lazy.NewLazy(func() (environment.Manager, error) { - return &mockEnvManager{}, nil - }) - svc := NewProjectService(lazyCtx, lazyEnvMgr, nil, nil, lazyPC, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, lazyPC, nil, nil) resp, err := svc.Get(t.Context(), &azdext.EmptyRequest{}) require.NoError(t, err) require.NotNil(t, resp.Project) require.Equal(t, "test-project", resp.Project.Name) } -func TestProjectService_Get_WithDefaultEnv(t *testing.T) { +// Get must succeed even when no session environment is available; expandable values +// then resolve to empty strings. +func TestProjectService_Get_NoSessionEnv(t *testing.T) { t.Parallel() dir := t.TempDir() yamlContent := "name: test-project\n" @@ -2617,19 +2601,12 @@ func TestProjectService_Get_WithDefaultEnv(t *testing.T) { require.NoError(t, err) ctx := azdcontext.NewAzdContextWithDirectory(dir) - require.NoError(t, ctx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: "dev"})) lazyCtx := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { return ctx, nil }) pc, err := project.Load(t.Context(), filepath.Join(dir, "azure.yaml")) require.NoError(t, err) lazyPC := lazy.NewLazy(func() (*project.ProjectConfig, error) { return pc, nil }) - mockMgr := &mockEnvManager{ - getFunc: func(_ context.Context, name string) (*environment.Environment, error) { - return environment.NewWithValues("dev", map[string]string{"MY_VAR": "hello"}), nil - }, - } - lazyEnvMgr := lazy.NewLazy(func() (environment.Manager, error) { return mockMgr, nil }) - svc := NewProjectService(lazyCtx, lazyEnvMgr, nil, nil, lazyPC, nil, nil) + svc := NewProjectService(lazyCtx, nil, nil, lazyPC, nil, nil) resp, err := svc.Get(t.Context(), &azdext.EmptyRequest{}) require.NoError(t, err) require.NotNil(t, resp.Project) @@ -2741,7 +2718,7 @@ func TestProjectService_ParseGitHubUrl_Valid(t *testing.T) { t.Parallel() // ParseGitHubUrl requires ghCli for HTTPS urls, so just test that it's called correctly // with an API URL that doesn't need authentication - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.ParseGitHubUrl(t.Context(), &azdext.ParseGitHubUrlRequest{ Url: "https://api.github.com/repos/Azure/azure-dev/contents/README.md?ref=main", }) @@ -2751,7 +2728,7 @@ func TestProjectService_ParseGitHubUrl_Valid(t *testing.T) { func TestProjectService_ParseGitHubUrl_Invalid(t *testing.T) { t.Parallel() - svc := NewProjectService(nil, nil, nil, nil, nil, nil, nil) + svc := NewProjectService(nil, nil, nil, nil, nil, nil) _, err := svc.ParseGitHubUrl(t.Context(), &azdext.ParseGitHubUrlRequest{ Url: "not-a-url", }) diff --git a/cli/azd/internal/grpcserver/service_target_service.go b/cli/azd/internal/grpcserver/service_target_service.go index bddd1d4c4dd..8a5457e89ab 100644 --- a/cli/azd/internal/grpcserver/service_target_service.go +++ b/cli/azd/internal/grpcserver/service_target_service.go @@ -122,7 +122,6 @@ func (s *ServiceTargetService) onRegisterRequest( console input.Console, prompter prompt.Prompter, ) project.ServiceTarget { - env, _ := s.lazyEnv.GetValue() return project.NewExternalServiceTarget( hostType, project.ServiceTargetKind(hostType), @@ -130,7 +129,7 @@ func (s *ServiceTargetService) onRegisterRequest( broker, console, prompter, - env, + s.lazyEnv, ) }) diff --git a/cli/azd/pkg/azdext/models.pb.go b/cli/azd/pkg/azdext/models.pb.go index 0385fd65242..9b527ef5629 100644 --- a/cli/azd/pkg/azdext/models.pb.go +++ b/cli/azd/pkg/azdext/models.pb.go @@ -874,6 +874,7 @@ type ServiceConfig struct { Config *structpb.Struct `protobuf:"bytes,11,opt,name=config,proto3" json:"config,omitempty"` AdditionalProperties *structpb.Struct `protobuf:"bytes,12,opt,name=additional_properties,json=additionalProperties,proto3" json:"additional_properties,omitempty"` Uses []string `protobuf:"bytes,13,rep,name=uses,proto3" json:"uses,omitempty"` + Environment map[string]string `protobuf:"bytes,14,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -999,6 +1000,13 @@ func (x *ServiceConfig) GetUses() []string { return nil } +func (x *ServiceConfig) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + // InfraOptions message definition type InfraOptions struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1424,7 +1432,7 @@ const file_models_proto_rawDesc = "" + "\x10RequiredVersions\x12\x10\n" + "\x03azd\x18\x01 \x01(\tR\x03azd\"-\n" + "\x0fProjectMetadata\x12\x1a\n" + - "\btemplate\x18\x01 \x01(\tR\btemplate\"\xee\x03\n" + + "\btemplate\x18\x01 \x01(\tR\btemplate\"\xf8\x04\n" + "\rServiceConfig\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12.\n" + "\x13resource_group_name\x18\x02 \x01(\tR\x11resourceGroupName\x12#\n" + @@ -1441,7 +1449,11 @@ const file_models_proto_rawDesc = "" + " \x01(\v2\x1c.azdext.DockerProjectOptionsR\x06docker\x12/\n" + "\x06config\x18\v \x01(\v2\x17.google.protobuf.StructR\x06config\x12L\n" + "\x15additional_properties\x18\f \x01(\v2\x17.google.protobuf.StructR\x14additionalProperties\x12\x12\n" + - "\x04uses\x18\r \x03(\tR\x04uses\"V\n" + + "\x04uses\x18\r \x03(\tR\x04uses\x12H\n" + + "\venvironment\x18\x0e \x03(\v2&.azdext.ServiceConfig.EnvironmentEntryR\venvironment\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"V\n" + "\fInfraOptions\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x16\n" + @@ -1502,7 +1514,7 @@ func file_models_proto_rawDescGZIP() []byte { } var file_models_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_models_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_models_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_models_proto_goTypes = []any{ (ArtifactKind)(0), // 0: azdext.ArtifactKind (LocationKind)(0), // 1: azdext.LocationKind @@ -1525,33 +1537,35 @@ var file_models_proto_goTypes = []any{ (*ArtifactList)(nil), // 18: azdext.ArtifactList (*Artifact)(nil), // 19: azdext.Artifact nil, // 20: azdext.ProjectConfig.ServicesEntry - nil, // 21: azdext.Artifact.MetadataEntry - (*structpb.Struct)(nil), // 22: google.protobuf.Struct + nil, // 21: azdext.ServiceConfig.EnvironmentEntry + nil, // 22: azdext.Artifact.MetadataEntry + (*structpb.Struct)(nil), // 23: google.protobuf.Struct } var file_models_proto_depIdxs = []int32{ 7, // 0: azdext.AzureContext.scope:type_name -> azdext.AzureScope 13, // 1: azdext.ProjectConfig.metadata:type_name -> azdext.ProjectMetadata 20, // 2: azdext.ProjectConfig.services:type_name -> azdext.ProjectConfig.ServicesEntry 15, // 3: azdext.ProjectConfig.infra:type_name -> azdext.InfraOptions - 22, // 4: azdext.ProjectConfig.additional_properties:type_name -> google.protobuf.Struct + 23, // 4: azdext.ProjectConfig.additional_properties:type_name -> google.protobuf.Struct 16, // 5: azdext.ServiceConfig.docker:type_name -> azdext.DockerProjectOptions - 22, // 6: azdext.ServiceConfig.config:type_name -> google.protobuf.Struct - 22, // 7: azdext.ServiceConfig.additional_properties:type_name -> google.protobuf.Struct - 19, // 8: azdext.ServiceContext.restore:type_name -> azdext.Artifact - 19, // 9: azdext.ServiceContext.build:type_name -> azdext.Artifact - 19, // 10: azdext.ServiceContext.package:type_name -> azdext.Artifact - 19, // 11: azdext.ServiceContext.publish:type_name -> azdext.Artifact - 19, // 12: azdext.ServiceContext.deploy:type_name -> azdext.Artifact - 19, // 13: azdext.ArtifactList.artifacts:type_name -> azdext.Artifact - 0, // 14: azdext.Artifact.kind:type_name -> azdext.ArtifactKind - 1, // 15: azdext.Artifact.location_kind:type_name -> azdext.LocationKind - 21, // 16: azdext.Artifact.metadata:type_name -> azdext.Artifact.MetadataEntry - 14, // 17: azdext.ProjectConfig.ServicesEntry.value:type_name -> azdext.ServiceConfig - 18, // [18:18] is the sub-list for method output_type - 18, // [18:18] is the sub-list for method input_type - 18, // [18:18] is the sub-list for extension type_name - 18, // [18:18] is the sub-list for extension extendee - 0, // [0:18] is the sub-list for field type_name + 23, // 6: azdext.ServiceConfig.config:type_name -> google.protobuf.Struct + 23, // 7: azdext.ServiceConfig.additional_properties:type_name -> google.protobuf.Struct + 21, // 8: azdext.ServiceConfig.environment:type_name -> azdext.ServiceConfig.EnvironmentEntry + 19, // 9: azdext.ServiceContext.restore:type_name -> azdext.Artifact + 19, // 10: azdext.ServiceContext.build:type_name -> azdext.Artifact + 19, // 11: azdext.ServiceContext.package:type_name -> azdext.Artifact + 19, // 12: azdext.ServiceContext.publish:type_name -> azdext.Artifact + 19, // 13: azdext.ServiceContext.deploy:type_name -> azdext.Artifact + 19, // 14: azdext.ArtifactList.artifacts:type_name -> azdext.Artifact + 0, // 15: azdext.Artifact.kind:type_name -> azdext.ArtifactKind + 1, // 16: azdext.Artifact.location_kind:type_name -> azdext.LocationKind + 22, // 17: azdext.Artifact.metadata:type_name -> azdext.Artifact.MetadataEntry + 14, // 18: azdext.ProjectConfig.ServicesEntry.value:type_name -> azdext.ServiceConfig + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_models_proto_init() } @@ -1565,7 +1579,7 @@ func file_models_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_models_proto_rawDesc), len(file_models_proto_rawDesc)), NumEnums: 2, - NumMessages: 20, + NumMessages: 21, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/azd/pkg/osutil/expandable_string.go b/cli/azd/pkg/osutil/expandable_string.go index 5c171210b00..4fdcfbbf1ee 100644 --- a/cli/azd/pkg/osutil/expandable_string.go +++ b/cli/azd/pkg/osutil/expandable_string.go @@ -5,6 +5,7 @@ package osutil import ( "fmt" + "strings" "github.com/drone/envsubst" ) @@ -15,6 +16,19 @@ func NewExpandableString(template string) ExpandableString { } } +// NewLiteralExpandableString creates an ExpandableString whose expansion always yields the +// given literal value. Any `$` or `\` in the value is escaped so envsubst does not interpret +// it as a substitution reference or an escape sequence. Use this when wrapping values that +// are data rather than templates. +func NewLiteralExpandableString(value string) ExpandableString { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, "$", "$$") + + return ExpandableString{ + template: escaped, + } +} + // ExpandableString is a string that has ${foo} style references inside which can be evaluated. type ExpandableString struct { template string diff --git a/cli/azd/pkg/osutil/expandable_string_test.go b/cli/azd/pkg/osutil/expandable_string_test.go index a0f3ac2c53b..769bfbcfd3b 100644 --- a/cli/azd/pkg/osutil/expandable_string_test.go +++ b/cli/azd/pkg/osutil/expandable_string_test.go @@ -35,3 +35,33 @@ func TestExpandableString_Empty(t *testing.T) { assert.False(t, e.Empty()) }) } + +func TestNewLiteralExpandableString(t *testing.T) { + resolver := func(string) string { return "resolved" } + + testCases := []struct { + name string + value string + }{ + {name: "plain", value: "plain-value"}, + {name: "empty", value: ""}, + {name: "template syntax", value: "${ENV_VAR}"}, + {name: "double dollar", value: "pa$$word"}, + {name: "bare dollar", value: "pa$word"}, + {name: "trailing dollar", value: "value$"}, + {name: "unc path", value: `\\server\share`}, + {name: "escaped slash", value: `a\/b`}, + {name: "trailing backslash", value: `value\`}, + {name: "mixed dollar and backslash", value: `c:\dir\$$file$`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + e := NewLiteralExpandableString(tc.value) + + expanded, err := e.Envsubst(resolver) + assert.NoError(t, err) + assert.Equal(t, tc.value, expanded) + }) + } +} diff --git a/cli/azd/pkg/project/framework_service_external.go b/cli/azd/pkg/project/framework_service_external.go index c21ea44b008..f9ae90c85a9 100644 --- a/cli/azd/pkg/project/framework_service_external.go +++ b/cli/azd/pkg/project/framework_service_external.go @@ -7,13 +7,16 @@ import ( "context" "errors" "fmt" + "log" "github.com/azure/azure-dev/cli/azd/internal/mapper" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/google/uuid" ) @@ -42,6 +45,7 @@ type ExternalFrameworkService struct { languageName string languageKind ServiceLanguageKind console input.Console + lazyEnv *lazy.Lazy[*environment.Environment] broker *grpcbroker.MessageBroker[azdext.FrameworkServiceMessage] } @@ -53,12 +57,14 @@ func NewExternalFrameworkService( extension *extensions.Extension, broker *grpcbroker.MessageBroker[azdext.FrameworkServiceMessage], console input.Console, + lazyEnv *lazy.Lazy[*environment.Environment], ) FrameworkService { service := &ExternalFrameworkService{ extension: extension, languageName: name, languageKind: kind, console: console, + lazyEnv: lazyEnv, broker: broker, } @@ -73,6 +79,9 @@ func (efs *ExternalFrameworkService) RequiredExternalTools( // Convert serviceConfig to gRPC proto protoServiceConfig, err := efs.toProtoServiceConfig(serviceConfig) if err != nil { + // The FrameworkService interface does not allow returning an error here, so log + // the failure instead of silently skipping the extension's required tools. + log.Printf("failed to convert service config for required external tools: %v", err) return nil } @@ -225,8 +234,8 @@ func (efs *ExternalFrameworkService) Build( serviceContext *ServiceContext, progress *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) { - protoServiceConfig := &azdext.ServiceConfig{} - if err := mapper.Convert(serviceConfig, &protoServiceConfig); err != nil { + protoServiceConfig, err := efs.toProtoServiceConfig(serviceConfig) + if err != nil { return nil, err } @@ -271,8 +280,8 @@ func (efs *ExternalFrameworkService) Package( serviceContext *ServiceContext, progress *async.Progress[ServiceProgress], ) (*ServicePackageResult, error) { - protoServiceConfig := &azdext.ServiceConfig{} - if err := mapper.Convert(serviceConfig, &protoServiceConfig); err != nil { + protoServiceConfig, err := efs.toProtoServiceConfig(serviceConfig) + if err != nil { return nil, err } @@ -312,21 +321,5 @@ func (efs *ExternalFrameworkService) Package( // Convert ServiceConfig to proto message func (efs *ExternalFrameworkService) toProtoServiceConfig(serviceConfig *ServiceConfig) (*azdext.ServiceConfig, error) { - if serviceConfig == nil { - return nil, nil - } - - // Use an empty resolver since ExternalFrameworkService doesn't have access to environment - // The extension is responsible for handling environment variable substitution - emptyResolver := func(key string) string { - return "" - } - - var protoConfig *azdext.ServiceConfig - err := mapper.WithResolver(emptyResolver).Convert(serviceConfig, &protoConfig) - if err != nil { - return nil, fmt.Errorf("converting service config: %w", err) - } - - return protoConfig, nil + return serviceConfigToProto(efs.lazyEnv, serviceConfig) } diff --git a/cli/azd/pkg/project/framework_service_external_test.go b/cli/azd/pkg/project/framework_service_external_test.go index 7d6ccff0e49..aa6043a30e2 100644 --- a/cli/azd/pkg/project/framework_service_external_test.go +++ b/cli/azd/pkg/project/framework_service_external_test.go @@ -6,6 +6,7 @@ package project import ( "archive/zip" "context" + "errors" "os" "path/filepath" "testing" @@ -14,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/osutil" ) @@ -527,6 +529,81 @@ func Test_ExternalFrameworkService_toProtoNil(t *testing.T) { assert.NoError(t, err) } +func Test_ExternalFrameworkService_toProtoServiceConfigExpandsEnvironment(t *testing.T) { + env := environment.NewWithValues("test", map[string]string{ + "SERVICE_VALUE": "resolved", + }) + efs := &ExternalFrameworkService{lazyEnv: lazy.From(env)} + serviceConfig := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + "FROM_ENV": osutil.NewExpandableString("${SERVICE_VALUE}"), + "STATIC": osutil.NewExpandableString("static"), + }, + } + + cfg, err := efs.toProtoServiceConfig(serviceConfig) + + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, map[string]string{ + "FROM_ENV": "resolved", + "STATIC": "static", + }, cfg.Environment) +} + +func Test_ExternalFrameworkService_toProtoServiceConfigEnvLoadError(t *testing.T) { + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + return nil, errors.New("no environment") + }) + efs := &ExternalFrameworkService{lazyEnv: lazyEnv} + serviceConfig := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + "FROM_ENV": osutil.NewExpandableString("${SERVICE_VALUE}"), + "STATIC": osutil.NewExpandableString("static"), + }, + } + + cfg, err := efs.toProtoServiceConfig(serviceConfig) + + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, map[string]string{ + "FROM_ENV": "", + "STATIC": "static", + }, cfg.Environment) +} + +func Test_ExternalFrameworkService_toProtoServiceConfigEnvResolvedPerCall(t *testing.T) { + // The environment is resolved from the lazy on every conversion, so an environment + // that becomes available after the service is constructed is still picked up. + var env *environment.Environment + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + if env == nil { + return nil, errors.New("no environment yet") + } + return env, nil + }) + efs := &ExternalFrameworkService{lazyEnv: lazyEnv} + serviceConfig := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + "FROM_ENV": osutil.NewExpandableString("${SERVICE_VALUE}"), + }, + } + + cfg, err := efs.toProtoServiceConfig(serviceConfig) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FROM_ENV": ""}, cfg.Environment) + + env = environment.NewWithValues("test", map[string]string{"SERVICE_VALUE": "resolved"}) + + cfg, err = efs.toProtoServiceConfig(serviceConfig) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FROM_ENV": "resolved"}, cfg.Environment) +} + func Test_mergeDefaultEnvVars(t *testing.T) { // Test that user env overrides defaults defaults := map[string]string{ diff --git a/cli/azd/pkg/project/framework_service_test.go b/cli/azd/pkg/project/framework_service_test.go index 61405023b84..4e4de1efd65 100644 --- a/cli/azd/pkg/project/framework_service_test.go +++ b/cli/azd/pkg/project/framework_service_test.go @@ -18,7 +18,7 @@ import ( ) func Test_NewExternalFrameworkService(t *testing.T) { - svc := NewExternalFrameworkService("test-lang", ServiceLanguageCustom, nil, nil, nil) + svc := NewExternalFrameworkService("test-lang", ServiceLanguageCustom, nil, nil, nil, nil) require.NotNil(t, svc) } diff --git a/cli/azd/pkg/project/mapper_registry.go b/cli/azd/pkg/project/mapper_registry.go index 02e97967d15..c9fd947c223 100644 --- a/cli/azd/pkg/project/mapper_registry.go +++ b/cli/azd/pkg/project/mapper_registry.go @@ -118,6 +118,14 @@ func registerProjectMappings() { return nil, fmt.Errorf("envsubst image: %w", err) } + var serviceEnv map[string]string + if len(src.Environment) > 0 { + serviceEnv, err = src.Environment.Expand(envResolver) + if err != nil { + return nil, fmt.Errorf("envsubst service environment: %w", err) + } + } + // Convert Docker options var docker *azdext.DockerProjectOptions err = mapper.WithResolver(resolver).Convert(src.Docker, &docker) @@ -159,6 +167,7 @@ func registerProjectMappings() { Config: protoConfig, AdditionalProperties: protoAdditionalProperties, Uses: src.Uses, + Environment: serviceEnv, }, nil }) @@ -398,6 +407,16 @@ func registerProjectMappings() { result.AdditionalProperties = src.AdditionalProperties.AsMap() } + if len(src.Environment) > 0 { + result.Environment = make(osutil.ExpandableMap, len(src.Environment)) + for key, value := range src.Environment { + // Incoming values are expanded literals, not templates: escape them so a + // later expansion (or a round trip back into azure.yaml) cannot reinterpret + // or corrupt values containing `$`. + result.Environment[key] = osutil.NewLiteralExpandableString(value) + } + } + return result, nil }) @@ -700,8 +719,8 @@ func registerProjectMappings() { services := make(map[string]*azdext.ServiceConfig, len(src.Services)) for i, svc := range src.Services { var serviceConfig *azdext.ServiceConfig - if err := mapper.Convert(svc, &serviceConfig); err != nil { - return nil, err + if err := mapper.WithResolver(resolver).Convert(svc, &serviceConfig); err != nil { + return nil, fmt.Errorf("converting service %q: %w", i, err) } services[i] = serviceConfig diff --git a/cli/azd/pkg/project/mapper_registry_test.go b/cli/azd/pkg/project/mapper_registry_test.go index 2fe997b5ad1..cff1321b2c4 100644 --- a/cli/azd/pkg/project/mapper_registry_test.go +++ b/cli/azd/pkg/project/mapper_registry_test.go @@ -80,6 +80,8 @@ func TestServiceConfigMappingWithResolver(t *testing.T) { return "resolved-service" case "REGISTRY": return "myregistry.azurecr.io" + case "API_ENDPOINT": + return "https://api.contoso.test" default: return "" } @@ -90,6 +92,10 @@ func TestServiceConfigMappingWithResolver(t *testing.T) { Host: ContainerAppTarget, Language: ServiceLanguageDotNet, RelativePath: "./src/api", + Environment: osutil.ExpandableMap{ + "API_ENDPOINT": osutil.NewExpandableString("${API_ENDPOINT}"), + "STATIC_ENV": osutil.NewExpandableString("static-value"), + }, } var protoConfig *azdext.ServiceConfig @@ -98,6 +104,10 @@ func TestServiceConfigMappingWithResolver(t *testing.T) { require.NotNil(t, protoConfig) require.Equal(t, "test-service", protoConfig.Name) require.Equal(t, string(ContainerAppTarget), protoConfig.Host) + require.Equal(t, map[string]string{ + "API_ENDPOINT": "https://api.contoso.test", + "STATIC_ENV": "static-value", + }, protoConfig.Environment) } func TestServiceConfigMappingWithConfig(t *testing.T) { @@ -218,6 +228,12 @@ func TestServiceConfigReverseMapping(t *testing.T) { Language: string(ServiceLanguageDotNet), RelativePath: "./src/api", Config: nil, + Environment: map[string]string{ + "FROM_EXTENSION": "extension-value", + // Values are literals: '$' must survive later expansions unchanged. + "WITH_DOLLARS": "pa$$word", + "WITH_TEMPLATE": "${NOT_A_TEMPLATE}", + }, } }, validateFn: func(t *testing.T, result *ServiceConfig) { @@ -227,6 +243,13 @@ func TestServiceConfigReverseMapping(t *testing.T) { require.Equal(t, "./src/api", result.RelativePath) require.Nil(t, result.Config) require.Nil(t, result.AdditionalProperties) + expanded, err := result.Environment.Expand(func(string) string { return "unexpected" }) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "FROM_EXTENSION": "extension-value", + "WITH_DOLLARS": "pa$$word", + "WITH_TEMPLATE": "${NOT_A_TEMPLATE}", + }, expanded) }, }, { @@ -351,15 +374,29 @@ func TestServiceConfigRoundTripMapping(t *testing.T) { RelativePath: "./src/api", Config: originalConfig, Uses: []string{"db", "cache"}, + Environment: osutil.ExpandableMap{ + "FROM_ENV": osutil.NewExpandableString("${SERVICE_VALUE}"), + "STATIC": osutil.NewExpandableString("static"), + }, AdditionalProperties: map[string]any{ "roundTripField": "roundTripValue", "nestedData": map[string]any{"key": "value"}, }, } // Convert to proto var protoConfig *azdext.ServiceConfig - err := mapper.Convert(originalServiceConfig, &protoConfig) + err := mapper.WithResolver(func(key string) string { + if key == "SERVICE_VALUE" { + // Expanded values containing '$' must survive the reverse mapping intact. + return "re$olved$$value" + } + return "" + }).Convert(originalServiceConfig, &protoConfig) require.NoError(t, err) require.NotNil(t, protoConfig) + require.Equal(t, map[string]string{ + "FROM_ENV": "re$olved$$value", + "STATIC": "static", + }, protoConfig.Environment) // Convert back to ServiceConfig var roundTripServiceConfig *ServiceConfig @@ -373,6 +410,12 @@ func TestServiceConfigRoundTripMapping(t *testing.T) { require.Equal(t, originalServiceConfig.Language, roundTripServiceConfig.Language) require.Equal(t, originalServiceConfig.RelativePath, roundTripServiceConfig.RelativePath) require.Equal(t, originalServiceConfig.Uses, roundTripServiceConfig.Uses) + roundTripEnv, err := roundTripServiceConfig.Environment.Expand(func(string) string { return "unexpected" }) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "FROM_ENV": "re$olved$$value", + "STATIC": "static", + }, roundTripEnv) // Verify config data (note: some type conversions are expected due to JSON/protobuf handling) require.NotNil(t, roundTripServiceConfig.Config) @@ -628,6 +671,10 @@ func TestFromProtoServiceConfigMapping(t *testing.T) { Language: "csharp", OutputPath: "./dist", Image: "nginx:latest", + Environment: map[string]string{ + "APP_SETTING": "setting-value", + "WITH_DOLLARS": "pa$$word", + }, Docker: &azdext.DockerProjectOptions{ Path: "./Dockerfile", Context: ".", @@ -654,6 +701,12 @@ func TestFromProtoServiceConfigMapping(t *testing.T) { require.Equal(t, ServiceLanguageCsharp, serviceConfig.Language) require.Equal(t, "./dist", serviceConfig.OutputPath) require.Equal(t, "nginx:latest", serviceConfig.Image.MustEnvsubst(func(string) string { return "" })) + envValues, err := serviceConfig.Environment.Expand(func(string) string { return "unexpected" }) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "APP_SETTING": "setting-value", + "WITH_DOLLARS": "pa$$word", + }, envValues) // Verify docker options conversion require.Equal(t, "./Dockerfile", serviceConfig.Docker.Path) @@ -986,6 +1039,10 @@ func TestProjectConfigMapping(t *testing.T) { Host: ContainerAppTarget, Language: ServiceLanguagePython, RelativePath: "./src", + Environment: osutil.ExpandableMap{ + "ENV_NAME": osutil.NewExpandableString("${ENVIRONMENT_NAME}"), + "STATIC": osutil.NewExpandableString("static-value"), + }, }, "api": { Name: "api", @@ -1021,6 +1078,12 @@ func TestProjectConfigMapping(t *testing.T) { require.Contains(t, protoConfig.Services, "api") require.Equal(t, "containerapp", protoConfig.Services["web"].Host) require.Equal(t, "appservice", protoConfig.Services["api"].Host) + // The resolver must propagate into nested service conversions so service-level + // env values resolve the same way as in direct ServiceConfig conversions. + require.Equal(t, map[string]string{ + "ENV_NAME": "dev", + "STATIC": "static-value", + }, protoConfig.Services["web"].Environment) }) t.Run("proto ProjectConfig -> ProjectConfig", func(t *testing.T) { diff --git a/cli/azd/pkg/project/service_target_external.go b/cli/azd/pkg/project/service_target_external.go index 4a33b50f3ec..5f70ead81a9 100644 --- a/cli/azd/pkg/project/service_target_external.go +++ b/cli/azd/pkg/project/service_target_external.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "github.com/azure/azure-dev/cli/azd/internal/mapper" "github.com/azure/azure-dev/cli/azd/pkg/async" @@ -15,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/prompt" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/google/uuid" @@ -26,7 +28,7 @@ type ExternalServiceTarget struct { targetKind ServiceTargetKind console input.Console prompters prompt.Prompter - env *environment.Environment + lazyEnv *lazy.Lazy[*environment.Environment] broker *grpcbroker.MessageBroker[azdext.ServiceTargetMessage] } @@ -48,7 +50,7 @@ func NewExternalServiceTarget( broker *grpcbroker.MessageBroker[azdext.ServiceTargetMessage], console input.Console, prompters prompt.Prompter, - env *environment.Environment, + lazyEnv *lazy.Lazy[*environment.Environment], ) ServiceTarget { target := &ExternalServiceTarget{ extension: extension, @@ -56,13 +58,19 @@ func NewExternalServiceTarget( targetKind: kind, console: console, prompters: prompters, - env: env, + lazyEnv: lazyEnv, broker: broker, } return target } +// toProtoServiceConfig converts a ServiceConfig to its proto representation, expanding +// expandable values against the environment for the current session. +func (est *ExternalServiceTarget) toProtoServiceConfig(serviceConfig *ServiceConfig) (*azdext.ServiceConfig, error) { + return serviceConfigToProto(est.lazyEnv, serviceConfig) +} + // Publish implements ServiceTarget. func (est *ExternalServiceTarget) Publish( ctx context.Context, @@ -72,8 +80,7 @@ func (est *ExternalServiceTarget) Publish( progress *async.Progress[ServiceProgress], publishOptions *PublishOptions, ) (*ServicePublishResult, error) { - var protoServiceConfig *azdext.ServiceConfig - err := mapper.WithResolver(envResolver(est.env)).Convert(serviceConfig, &protoServiceConfig) + protoServiceConfig, err := est.toProtoServiceConfig(serviceConfig) if err != nil { return nil, err } @@ -128,8 +135,7 @@ func (est *ExternalServiceTarget) Initialize(ctx context.Context, serviceConfig return errors.New("service configuration is required") } - var protoServiceConfig *azdext.ServiceConfig - err := mapper.WithResolver(envResolver(est.env)).Convert(serviceConfig, &protoServiceConfig) + protoServiceConfig, err := est.toProtoServiceConfig(serviceConfig) if err != nil { return err } @@ -162,8 +168,7 @@ func (est *ExternalServiceTarget) Package( serviceContext *ServiceContext, progress *async.Progress[ServiceProgress], ) (*ServicePackageResult, error) { - var protoServiceConfig *azdext.ServiceConfig - err := mapper.WithResolver(envResolver(est.env)).Convert(serviceConfig, &protoServiceConfig) + protoServiceConfig, err := est.toProtoServiceConfig(serviceConfig) if err != nil { return nil, err } @@ -211,8 +216,7 @@ func (est *ExternalServiceTarget) Deploy( progress *async.Progress[ServiceProgress], ) (*ServiceDeployResult, error) { // Convert project types to protobuf types - var protoServiceConfig *azdext.ServiceConfig - err := mapper.WithResolver(envResolver(est.env)).Convert(serviceConfig, &protoServiceConfig) + protoServiceConfig, err := est.toProtoServiceConfig(serviceConfig) if err != nil { return nil, err } @@ -265,8 +269,7 @@ func (est *ExternalServiceTarget) Endpoints( serviceConfig *ServiceConfig, targetResource *environment.TargetResource, ) ([]string, error) { - var protoServiceConfig *azdext.ServiceConfig - err := mapper.WithResolver(envResolver(est.env)).Convert(serviceConfig, &protoServiceConfig) + protoServiceConfig, err := est.toProtoServiceConfig(serviceConfig) if err != nil { return nil, err } @@ -306,8 +309,7 @@ func (est *ExternalServiceTarget) ResolveTargetResource( serviceConfig *ServiceConfig, defaultResolver func() (*environment.TargetResource, error), ) (*environment.TargetResource, error) { - var protoServiceConfig *azdext.ServiceConfig - err := mapper.WithResolver(envResolver(est.env)).Convert(serviceConfig, &protoServiceConfig) + protoServiceConfig, err := est.toProtoServiceConfig(serviceConfig) if err != nil { return nil, err } @@ -370,6 +372,39 @@ func envResolver(env *environment.Environment) mapper.Resolver { } } +// serviceConfigToProto converts a ServiceConfig to its proto representation for an external +// provider, resolving the environment from lazyEnv at call time so values reflect the current +// session environment. When the environment cannot be loaded, the failure is logged and +// expandable values are expanded with empty strings. +func serviceConfigToProto( + lazyEnv *lazy.Lazy[*environment.Environment], + serviceConfig *ServiceConfig, +) (*azdext.ServiceConfig, error) { + if serviceConfig == nil { + return nil, nil + } + + var env *environment.Environment + if lazyEnv != nil { + var err error + env, err = lazyEnv.GetValue() + if err != nil { + log.Printf( + "converting service config %q: environment unavailable, expanding with empty values: %v", + serviceConfig.Name, + err, + ) + } + } + + var protoConfig *azdext.ServiceConfig + if err := mapper.WithResolver(envResolver(env)).Convert(serviceConfig, &protoConfig); err != nil { + return nil, fmt.Errorf("converting service config: %w", err) + } + + return protoConfig, nil +} + func createProgressFunc(progress *async.Progress[ServiceProgress]) func(string) { return func(message string) { if progress != nil {