Skip to content
Open
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
2 changes: 1 addition & 1 deletion cli/azd/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) {
}

featureCustomLanguage := alpha.MustFeatureKey("language.custom")
for sName, sConfig := range projectConfig.Services {
for sName, sConfig := range projectConfig.ServiceConfigs() {
if sConfig.Language == project.ServiceLanguageCustom &&
!alphaManager.IsEnabled(featureCustomLanguage) {
return nil, fmt.Errorf(
Expand Down
7 changes: 4 additions & 3 deletions cli/azd/cmd/project_extension_auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -661,16 +661,17 @@ func missingProjectExtensions(
return nil
}

for _, serviceName := range slices.Sorted(maps.Keys(projectConfig.Services)) {
services := projectConfig.ServiceConfigs()
for _, serviceName := range slices.Sorted(maps.Keys(services)) {
if err := addProvider(
extensions.ServiceTargetProviderCapability,
string(projectConfig.Services[serviceName].Host),
string(services[serviceName].Host),
); err != nil {
return nil, err
}
}

for _, infra := range projectConfig.Infra.GetLayers() {
for _, infra := range projectConfig.InfrastructureConfigs() {
if err := addProvider(extensions.ProvisioningProviderCapability, string(infra.Provider)); err != nil {
return nil, err
}
Expand Down
6 changes: 3 additions & 3 deletions cli/azd/internal/grpcserver/container_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func (c *containerService) Build(
return nil, err
}

serviceConfig, has := projectConfig.Services[req.ServiceName]
serviceConfig, has := projectConfig.ServiceConfigs()[req.ServiceName]
if !has {
return nil, status.Errorf(codes.NotFound,
"service %q not found in project configuration", req.ServiceName)
Expand Down Expand Up @@ -216,7 +216,7 @@ func (c *containerService) Package(
return nil, err
}

serviceConfig, has := projectConfig.Services[req.ServiceName]
serviceConfig, has := projectConfig.ServiceConfigs()[req.ServiceName]
if !has {
return nil, status.Errorf(codes.NotFound,
"service %q not found in project configuration", req.ServiceName)
Expand Down Expand Up @@ -271,7 +271,7 @@ func (c *containerService) Publish(
return nil, err
}

serviceConfig, has := projectConfig.Services[req.ServiceName]
serviceConfig, has := projectConfig.ServiceConfigs()[req.ServiceName]
if !has {
return nil, status.Errorf(codes.NotFound,
"service %q not found in project configuration", req.ServiceName)
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/internal/grpcserver/event_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func (s *eventService) onSubscribeServiceEvent(
}

evt := ext.Event(eventName)
for _, serviceConfig := range projectConfig.Services {
for _, serviceConfig := range projectConfig.ServiceConfigs() {
if subscribeMsg.Language != "" && string(serviceConfig.Language) != subscribeMsg.Language {
continue
}
Expand Down
115 changes: 89 additions & 26 deletions cli/azd/internal/grpcserver/project_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ package grpcserver
import (
"context"
"fmt"
"iter"
"log"
"maps"
"sync"

"github.com/azure/azure-dev/cli/azd/internal/mapper"
Expand Down Expand Up @@ -109,13 +111,67 @@ func (s *projectService) validateServiceExists(ctx context.Context, serviceName
return err
}

if projectConfig.Services == nil || projectConfig.Services[serviceName] == nil {
if projectConfig.ServiceConfigs()[serviceName] == nil {
return fmt.Errorf("service '%s' not found", serviceName)
}

return nil
}

func allLayerServiceConfigs(layers []any) iter.Seq2[string, config.Config] {
return func(yield func(string, config.Config) bool) {
for _, rawLayer := range layers {
layer, ok := rawLayer.(map[string]any)
if !ok {
continue
}
services, ok := layer["services"].(map[string]any)
if !ok {
continue
}
for name, rawService := range services {
service, ok := rawService.(map[string]any)
if ok && !yield(name, config.NewConfig(service)) {
return
}
}
}
}
}

func (s *projectService) serviceConfig(cfg config.Config, serviceName string) (config.Config, error) {
projectConfig, err := s.lazyProjectConfig.GetValue()
if err != nil {
return nil, err
}

if projectConfig.Format() != project.ProjectFormatLayersV2 {
services, ok := cfg.Raw()["services"].(map[string]any)
if !ok {
return nil, fmt.Errorf("services configuration not found")
}
service, ok := services[serviceName].(map[string]any)
if !ok {
return nil, fmt.Errorf("service configuration for '%s' not found", serviceName)
}
return config.NewConfig(service), nil
}

layers, ok := cfg.Raw()["layers"].([]any)
if !ok {
return nil, fmt.Errorf("layers configuration not found")
}

// NOTE: this is temporary. We'll make these services layer-scoped later instead of flattening them into one map.
for name, service := range allLayerServiceConfigs(layers) {
if name == serviceName {
return service, nil
}
}

return nil, fmt.Errorf("service '%s' not found", serviceName)
}

// Get retrieves the complete project configuration including all services and metadata.
// This method resolves environment variables in configuration values using the environment
// for the current session and converts the internal project configuration to the protobuf
Expand Down Expand Up @@ -195,6 +251,9 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR
if err != nil {
return nil, err
}
if projectConfig.Format() == project.ProjectFormatLayersV2 {
return nil, status.Error(codes.Unimplemented, "adding services to layered projects is not supported")
}

serviceConfig := &project.ServiceConfig{}
if err := mapper.Convert(req.Service, &serviceConfig); err != nil {
Expand Down Expand Up @@ -552,13 +611,15 @@ func (s *projectService) GetServiceConfigSection(
return nil, err
}

// Construct path to service config section: "services.<serviceName>.<path>"
servicePath := fmt.Sprintf("services.%s", req.ServiceName)
if req.Path != "" {
servicePath = fmt.Sprintf("%s.%s", servicePath, req.Path)
serviceConfig, err := s.serviceConfig(cfg, req.ServiceName)
if err != nil {
return nil, err
}

section, found := cfg.GetMap(servicePath)
section, found := serviceConfig.GetMap(req.Path)
if req.Path == "" {
section, found = serviceConfig.Raw(), true
}

if !found {
return &azdext.GetServiceConfigSectionResponse{
Expand Down Expand Up @@ -619,10 +680,12 @@ func (s *projectService) GetServiceConfigValue(
return nil, err
}

// Construct path to service config value: "services.<serviceName>.<path>"
servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path)
serviceConfig, err := s.serviceConfig(cfg, req.ServiceName)
if err != nil {
return nil, err
}

value, ok := cfg.Get(servicePath)
value, ok := serviceConfig.Get(req.Path)

if !ok {
return &azdext.GetServiceConfigValueResponse{
Expand Down Expand Up @@ -680,15 +743,17 @@ func (s *projectService) SetServiceConfigSection(
return nil, err
}

// Construct path to service config section: "services.<serviceName>.<path>"
servicePath := fmt.Sprintf("services.%s", req.ServiceName)
if req.Path != "" {
servicePath = fmt.Sprintf("%s.%s", servicePath, req.Path)
serviceConfig, err := s.serviceConfig(cfg, req.ServiceName)
if err != nil {
return nil, err
}

// Convert protobuf Struct to map
sectionMap := req.Section.AsMap()
if err := cfg.Set(servicePath, sectionMap); err != nil {
if req.Path == "" {
clear(serviceConfig.Raw())
maps.Copy(serviceConfig.Raw(), sectionMap)
} else if err := serviceConfig.Set(req.Path, sectionMap); err != nil {
return nil, fmt.Errorf("failed to set service config section: %w", err)
}

Expand Down Expand Up @@ -747,18 +812,14 @@ func (s *projectService) SetServiceConfigValue(
return nil, err
}

services, ok := cfg.Raw()["services"].(map[string]any)
if !ok {
return nil, fmt.Errorf("services configuration not found")
}
serviceConfig, ok := services[req.ServiceName].(map[string]any)
if !ok {
return nil, fmt.Errorf("service configuration for '%s' not found", req.ServiceName)
serviceConfig, err := s.serviceConfig(cfg, req.ServiceName)
if err != nil {
return nil, err
}

// Convert protobuf Value to interface{}
value := req.Value.AsInterface()
if err := config.NewConfig(serviceConfig).Set(req.Path, value); err != nil {
if err := serviceConfig.Set(req.Path, value); err != nil {
return nil, fmt.Errorf("failed to set service config value: %w", err)
}

Expand Down Expand Up @@ -817,10 +878,12 @@ func (s *projectService) UnsetServiceConfig(
return nil, err
}

// Construct path to service config: "services.<serviceName>.<path>"
servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path)
serviceConfig, err := s.serviceConfig(cfg, req.ServiceName)
if err != nil {
return nil, err
}

if err := cfg.Unset(servicePath); err != nil {
if err := serviceConfig.Unset(req.Path); err != nil {
return nil, fmt.Errorf("failed to unset service config: %w", err)
}

Expand Down Expand Up @@ -920,7 +983,7 @@ func (s *projectService) GetServiceTargetResource(
}

// Validate the service exists
serviceConfig, exists := projectConfig.Services[req.ServiceName]
serviceConfig, exists := projectConfig.ServiceConfigs()[req.ServiceName]
if !exists {
return nil, status.Errorf(codes.NotFound, "service '%s' not found in project", req.ServiceName)
}
Expand Down
118 changes: 118 additions & 0 deletions cli/azd/internal/grpcserver/project_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2623,6 +2623,124 @@ services:
project: ./src/api
`

func TestProjectService_LayeredServiceConfig(t *testing.T) {
t.Parallel()

const yamlWithLayeredService = `name: test-project
layers:
- name: app
services:
my.agent:
host: appservice
language: python
project: ./src/api
custom:
setting: original
removable: value
`

svc := newProjectServiceWithYaml(t, yamlWithLayeredService)

// Service names (currently) are still globally unique, even when in layers, so the layer is not
// needed for this lookup. When services become layer-scoped, this unqualified lookup should become
// ambiguous and the test should change with the API.
section, err := svc.GetServiceConfigSection(t.Context(), &azdext.GetServiceConfigSectionRequest{
ServiceName: "my.agent",
Path: "custom",
})
require.NoError(t, err)
require.True(t, section.Found)
require.Equal(t, "original", section.Section.AsMap()["setting"])

_, err = svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{
ServiceName: "my.agent",
Path: "custom.setting",
Value: structpb.NewStringValue("updated"),
})
require.NoError(t, err)

_, err = svc.UnsetServiceConfig(t.Context(), &azdext.UnsetServiceConfigRequest{
ServiceName: "my.agent",
Path: "custom.removable",
})
require.NoError(t, err)

value, err := svc.GetServiceConfigValue(t.Context(), &azdext.GetServiceConfigValueRequest{
ServiceName: "my.agent",
Path: "custom.setting",
})
require.NoError(t, err)
require.True(t, value.Found)
require.Equal(t, "updated", value.Value.AsInterface())

_, err = svc.AddService(t.Context(), &azdext.AddServiceRequest{Service: &azdext.ServiceConfig{Name: "web"}})
require.Error(t, err)
require.Equal(t, codes.Unimplemented, status.Code(err))

projectService := svc.(*projectService)
azdContext, err := projectService.lazyAzdContext.GetValue()
require.NoError(t, err)
saved, err := project.LoadConfig(t.Context(), azdContext.ProjectPath())
require.NoError(t, err)
_, hasFlatServices := saved.Raw()["services"]
require.False(t, hasFlatServices)
layers, ok := saved.Raw()["layers"].([]any)
require.True(t, ok)
layer, ok := layers[0].(map[string]any)
require.True(t, ok)
services, ok := layer["services"].(map[string]any)
require.True(t, ok)
serviceConfig, ok := services["my.agent"].(map[string]any)
require.True(t, ok)
custom, ok := serviceConfig["custom"].(map[string]any)
require.True(t, ok)
require.Equal(t, "updated", custom["setting"])
require.NotContains(t, custom, "removable")
}

func TestProjectService_SetServiceConfigValue_PreservesInfraV1(t *testing.T) {
t.Parallel()

svc := newProjectServiceWithYaml(t, `name: test-project
infra:
provider: bicep
layers:
- name: network
path: infra/network
- name: application
provider: terraform
path: infra/application
services:
api:
host: appservice
language: python
project: ./src/api
`)

_, err := svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{
ServiceName: "api",
Path: "custom.setting",
Value: structpb.NewStringValue("updated"),
})
require.NoError(t, err)

projectService := svc.(*projectService)
azdContext, err := projectService.lazyAzdContext.GetValue()
require.NoError(t, err)

reloaded, err := project.Load(t.Context(), azdContext.ProjectPath())
require.NoError(t, err)

require.Equal(t, project.ProjectFormatInfraV1, reloaded.Format())
require.Equal(t, provisioning.Bicep, reloaded.Infra.Provider)
require.Len(t, reloaded.Infra.Layers, 2)
require.Equal(t, "network", reloaded.Infra.Layers[0].Name)
require.Equal(t, provisioning.Terraform, reloaded.Infra.Layers[1].Provider)
custom, ok := reloaded.Services["api"].AdditionalProperties["custom"].(map[string]any)
require.True(t, ok)
require.Equal(t, "updated", custom["setting"])
}

func TestProjectService_SetServiceConfigSection_HappyPath(t *testing.T) {
t.Parallel()
svc := newProjectServiceWithYaml(t, yamlWithService)
Expand Down
Loading
Loading