Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cli/azd/docs/extensions/extension-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ message ServiceConfig {
string output_path = 8;
string image = 9;
repeated string uses = 13;
map<string, string> environment = 14;
}

// InfraOptions message definition
Expand Down
1 change: 1 addition & 0 deletions cli/azd/grpc/proto/models.proto
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ message ServiceConfig {
google.protobuf.Struct config = 11;
google.protobuf.Struct additional_properties = 12;
repeated string uses = 13;
map<string, string> environment = 14;
}

// InfraOptions message definition
Expand Down
6 changes: 6 additions & 0 deletions cli/azd/internal/grpcserver/framework_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Expand All @@ -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]),
}
}
Expand Down Expand Up @@ -120,6 +125,7 @@ func (s *FrameworkService) onRegisterRequest(
extension,
broker,
console,
s.lazyEnv,
)
})

Expand Down
64 changes: 63 additions & 1 deletion cli/azd/internal/grpcserver/framework_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
111 changes: 57 additions & 54 deletions cli/azd/internal/grpcserver/project_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -39,15 +39,13 @@ 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
//
// 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],
Expand All @@ -56,7 +54,6 @@ func NewProjectService(
) azdext.ProjectServiceServer {
return &projectService{
lazyAzdContext: lazyAzdContext,
lazyEnvManager: lazyEnvManager,
lazyResourceManager: lazyResourceManager,
lazyEnv: lazyEnv,
lazyProjectConfig: lazyProjectConfig,
Expand Down Expand Up @@ -117,50 +114,26 @@ 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)
// - Infrastructure configuration (provider, path, module)
// - 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)
}

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading