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
8 changes: 8 additions & 0 deletions cli/azd/internal/grpcserver/project_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR
return nil, err
}

// Reload the project config from disk before mutating so we never clobber
// properties (e.g. hooks, or any other top-level keys) that were written to
// azure.yaml after the lazy cache was first resolved. Other mutating handlers
// (SetConfig*, SetServiceConfig*, UnsetConfig*) reload for the same reason.
if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil {
return nil, err
}

projectConfig, err := s.lazyProjectConfig.GetValue()
if err != nil {
return nil, err
Expand Down
87 changes: 87 additions & 0 deletions cli/azd/internal/grpcserver/project_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package grpcserver

import (
"context"
"os"
"path/filepath"
"strings"
"sync/atomic"
Expand Down Expand Up @@ -186,6 +187,92 @@ func Test_ProjectService_AddService(t *testing.T) {
require.Equal(t, project.ContainerAppTarget, serviceConfig.Host)
}

// 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
// absent from a stale in-memory lazyProjectConfig cache. It reproduces the init
// flow where azure.yaml is materialized/updated after the lazy cache was first
// resolved.
func Test_ProjectService_AddService_PreservesExistingProperties(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)

// The azure.yaml on disk has hooks, a custom top-level key, and an existing
// service — none of which are in the stale cache seeded below.
onDiskYaml := "" +
"name: test\n" +
"metadata:\n" +
" template: foo@1.0\n" +
"hooks:\n" +
" preprovision:\n" +
" shell: sh\n" +
" run: ./scripts/pre.sh\n" +
"customTopLevel:\n" +
" foo: bar\n" +
"services:\n" +
" existing:\n" +
" project: ./src/existing\n" +
" host: containerapp\n" +
" 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
// was written). Without the reload fix, AddService would persist this stale
// config and wipe hooks/custom keys/existing services.
staleConfig := &project.ProjectConfig{Name: "test"}
lazyProjectConfig := lazy.From(staleConfig)

ghCli := github.NewGitHubCli(mockContext.Console, mockContext.CommandRunner)
importManager := project.NewImportManager(&project.DotNetImporter{})
service := NewProjectService(lazyAzdContext, lazyEnvManager, nil, nil, lazyProjectConfig, importManager, ghCli)

serviceRequest := &azdext.AddServiceRequest{
Service: &azdext.ServiceConfig{
Name: "service1",
RelativePath: filepath.Join("src", "service1"),
Language: "python",
Host: "containerapp",
},
}

_, err = service.AddService(*mockContext.Context, serviceRequest)
require.NoError(t, err)

// The raw file must still contain the pre-existing top-level properties.
saved, err := os.ReadFile(azdContext.ProjectPath())
require.NoError(t, err)
require.Contains(t, string(saved), "hooks", "hooks must be preserved")
require.Contains(t, string(saved), "customTopLevel", "unknown top-level properties must be preserved")

// And a structured reload must show both the existing and the new service
// plus the hooks.
updatedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath())
require.NoError(t, err)
require.NotEmpty(t, updatedConfig.Hooks, "hooks must be preserved")
require.Contains(t, updatedConfig.Services, "existing", "existing service must be preserved")
require.Contains(t, updatedConfig.Services, "service1", "new service must be added")
require.Contains(t, updatedConfig.AdditionalProperties, "customTopLevel",
"unknown top-level properties must be preserved")
}

func Test_ProjectService_ConfigSection(t *testing.T) {
// Setup mock context and temporary project directory
mockContext := mocks.NewMockContext(t.Context())
Expand Down
Loading