diff --git a/cli/azd/.github/copilot-instructions.md b/cli/azd/.github/copilot-instructions.md index 6d204c38632..712486c59ec 100644 --- a/cli/azd/.github/copilot-instructions.md +++ b/cli/azd/.github/copilot-instructions.md @@ -96,6 +96,19 @@ All Go files must include Microsoft copyright header: // Licensed under the MIT License. ``` +## Extensions Development + +### Building Extensions +Extensions are located in `extensions/` directory and use the extension framework: +```bash +# Build and install extension (example using demo extension) +cd extensions/microsoft.azd.demo +azd x build + +# Test extension (using extension's namespace from extension.yaml) +azd demo +``` + ## MCP Tools Development ### Tool Pattern diff --git a/cli/azd/docs/extension-framework.md b/cli/azd/docs/extension-framework.md index e20ef891ce4..9521f3fa143 100644 --- a/cli/azd/docs/extension-framework.md +++ b/cli/azd/docs/extension-framework.md @@ -1158,7 +1158,7 @@ The following are a list of available gRPC services for extension developer to i ### Project Service -This service manages project configuration retrieval and related operations. +This service manages project configuration retrieval and related operations, including project and service-level configuration management. > See [project.proto](../grpc/proto/project.proto) for more details. @@ -1185,6 +1185,107 @@ Adds a new service to the project. - `service`: _ServiceConfig_ - **Response:** _EmptyResponse_ +#### GetConfigSection + +Retrieves a project configuration section by path from AdditionalProperties. + +- **Request:** _GetProjectConfigSectionRequest_ + - `path` (string): Dot-notation path to the config section +- **Response:** _GetProjectConfigSectionResponse_ + - Contains: + - `section` (google.protobuf.Struct): The configuration section + - `found` (bool): Whether the section exists + +#### SetConfigSection + +Sets a project configuration section at the specified path in AdditionalProperties. + +- **Request:** _SetProjectConfigSectionRequest_ + - `path` (string): Dot-notation path to the config section + - `section` (google.protobuf.Struct): The configuration section to set +- **Response:** _EmptyResponse_ + +#### GetConfigValue + +Retrieves a single project configuration value by path from AdditionalProperties. + +- **Request:** _GetProjectConfigValueRequest_ + - `path` (string): Dot-notation path to the config value +- **Response:** _GetProjectConfigValueResponse_ + - Contains: + - `value` (google.protobuf.Value): The configuration value + - `found` (bool): Whether the value exists + +#### SetConfigValue + +Sets a single project configuration value at the specified path in AdditionalProperties. + +- **Request:** _SetProjectConfigValueRequest_ + - `path` (string): Dot-notation path to the config value + - `value` (google.protobuf.Value): The configuration value to set +- **Response:** _EmptyResponse_ + +#### UnsetConfig + +Removes a project configuration value or section at the specified path from AdditionalProperties. + +- **Request:** _UnsetProjectConfigRequest_ + - `path` (string): Dot-notation path to the config to remove +- **Response:** _EmptyResponse_ + +#### GetServiceConfigSection + +Retrieves a service configuration section by path from service AdditionalProperties. + +- **Request:** _GetServiceConfigSectionRequest_ + - `service_name` (string): Name of the service + - `path` (string): Dot-notation path to the config section +- **Response:** _GetServiceConfigSectionResponse_ + - Contains: + - `section` (google.protobuf.Struct): The configuration section + - `found` (bool): Whether the section exists + +#### SetServiceConfigSection + +Sets a service configuration section at the specified path in service AdditionalProperties. + +- **Request:** _SetServiceConfigSectionRequest_ + - `service_name` (string): Name of the service + - `path` (string): Dot-notation path to the config section + - `section` (google.protobuf.Struct): The configuration section to set +- **Response:** _EmptyResponse_ + +#### GetServiceConfigValue + +Retrieves a single service configuration value by path from service AdditionalProperties. + +- **Request:** _GetServiceConfigValueRequest_ + - `service_name` (string): Name of the service + - `path` (string): Dot-notation path to the config value +- **Response:** _GetServiceConfigValueResponse_ + - Contains: + - `value` (google.protobuf.Value): The configuration value + - `found` (bool): Whether the value exists + +#### SetServiceConfigValue + +Sets a single service configuration value at the specified path in service AdditionalProperties. + +- **Request:** _SetServiceConfigValueRequest_ + - `service_name` (string): Name of the service + - `path` (string): Dot-notation path to the config value + - `value` (google.protobuf.Value): The configuration value to set +- **Response:** _EmptyResponse_ + +#### UnsetServiceConfig + +Removes a service configuration value or section at the specified path from service AdditionalProperties. + +- **Request:** _UnsetServiceConfigRequest_ + - `service_name` (string): Name of the service + - `path` (string): Dot-notation path to the config to remove +- **Response:** _EmptyResponse_ + --- ### Environment Service diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/config.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/config.go new file mode 100644 index 00000000000..51d6240cdcd --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/config.go @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/structpb" +) + +// MonitoringConfig represents the project-level monitoring configuration +type MonitoringConfig struct { + Enabled bool `json:"enabled"` + Environment string `json:"environment"` + RetentionDays int `json:"retentionDays"` + AlertEmail string `json:"alertEmail"` +} + +// ServiceMonitoringConfig represents service-level monitoring configuration +type ServiceMonitoringConfig struct { + Enabled bool `json:"enabled"` + HealthCheckPath string `json:"healthCheckPath"` + MetricsPort int `json:"metricsPort"` + LogLevel string `json:"logLevel"` + AlertThresholds struct { + ErrorRate float64 `json:"errorRate"` + ResponseTimeMs int `json:"responseTimeMs"` + CPUPercent int `json:"cpuPercent"` + } `json:"alertThresholds"` + Tags []string `json:"tags"` +} + +func newConfigCommand() *cobra.Command { + return &cobra.Command{ + Use: "config", + Short: "Setup monitoring configuration for the project and services", + Long: `This command demonstrates the new configuration management capabilities by setting up +a realistic monitoring configuration scenario. It will: + +1. Check if project-level monitoring config exists, create it if missing +2. Find the first service in the project +3. Check if service-level monitoring config exists, create it if missing +4. Display the final configuration state + +This showcases how extensions can manage both project and service-level configuration +using the new AdditionalProperties gRPC API with strongly-typed Go structs.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + return setupMonitoringConfig(ctx, azdClient) + }, + } +} + +func setupMonitoringConfig(ctx context.Context, azdClient *azdext.AzdClient) error { + color.HiCyan("🔧 Setting up monitoring configuration...") + fmt.Println() + + // Step 1: Check and setup project-level monitoring config + projectConfigCreated, err := setupProjectMonitoringConfig(ctx, azdClient) + if err != nil { + return err + } + + // Step 2: Get project to find services + projectResp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + if len(projectResp.Project.Services) == 0 { + color.Yellow("⚠️ No services found in project - skipping service configuration") + return displayConfigurationSummary(ctx, azdClient, "", projectConfigCreated, false) + } + + // Step 3: Setup monitoring for the first service + var firstServiceName string + for serviceName := range projectResp.Project.Services { + firstServiceName = serviceName + break + } + + color.HiWhite("📦 Found service: %s", firstServiceName) + serviceConfigCreated, err := setupServiceMonitoringConfig(ctx, azdClient, firstServiceName) + if err != nil { + return err + } + + // Step 4: Display final configuration state + return displayConfigurationSummary(ctx, azdClient, firstServiceName, projectConfigCreated, serviceConfigCreated) +} + +// Helper functions to convert between type-safe structs and protobuf structs +func structToProtobuf(v interface{}) (*structpb.Struct, error) { + // Convert struct to JSON bytes + jsonBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal struct to JSON: %w", err) + } + + // Convert JSON bytes to map + var m map[string]interface{} + if err := json.Unmarshal(jsonBytes, &m); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON to map: %w", err) + } + + // Convert map to protobuf struct + return structpb.NewStruct(m) +} + +func protobufToStruct(pbStruct *structpb.Struct, target interface{}) error { + // Convert protobuf struct to JSON bytes + jsonBytes, err := json.Marshal(pbStruct.AsMap()) + if err != nil { + return fmt.Errorf("failed to marshal protobuf struct to JSON: %w", err) + } + + // Unmarshal JSON into target struct + if err := json.Unmarshal(jsonBytes, target); err != nil { + return fmt.Errorf("failed to unmarshal JSON to target struct: %w", err) + } + + return nil +} + +func setupProjectMonitoringConfig(ctx context.Context, azdClient *azdext.AzdClient) (bool, error) { + color.HiWhite("🏢 Checking project-level monitoring configuration...") + + // Check if monitoring config already exists + configResp, err := azdClient.Project().GetConfigSection(ctx, &azdext.GetProjectConfigSectionRequest{ + Path: "monitoring", + }) + if err != nil { + return false, fmt.Errorf("failed to check project monitoring config: %w", err) + } + + if configResp.Found { + color.Green(" ✓ Project monitoring configuration already exists") + + // Demonstrate reading back the configuration into our type-safe struct + var existingConfig MonitoringConfig + if err := protobufToStruct(configResp.Section, &existingConfig); err != nil { + return false, fmt.Errorf("failed to convert existing config: %w", err) + } + color.Cyan(" Current config: Environment=%s, Retention=%d days", + existingConfig.Environment, existingConfig.RetentionDays) + return false, nil // false means it already existed (not created) + } + + // Create default monitoring configuration using type-safe struct + color.Yellow(" ⚙️ Creating project monitoring configuration...") + + monitoringConfig := MonitoringConfig{ + Enabled: true, + Environment: "development", + RetentionDays: 30, + AlertEmail: "admin@company.com", + } + + // Convert type-safe struct to protobuf struct + configStruct, err := structToProtobuf(monitoringConfig) + if err != nil { + return false, fmt.Errorf("failed to convert config struct: %w", err) + } + + _, err = azdClient.Project().SetConfigSection(ctx, &azdext.SetProjectConfigSectionRequest{ + Path: "monitoring", + Section: configStruct, + }) + if err != nil { + return false, fmt.Errorf("failed to set project monitoring config: %w", err) + } + + color.Green(" ✓ Project monitoring configuration created successfully") + return true, nil // true means it was created +} + +func setupServiceMonitoringConfig(ctx context.Context, azdClient *azdext.AzdClient, serviceName string) (bool, error) { + color.HiWhite("🔍 Checking service-level monitoring configuration for '%s'...", serviceName) + + // Check if service monitoring config already exists + configResp, err := azdClient.Project().GetServiceConfigSection(ctx, &azdext.GetServiceConfigSectionRequest{ + ServiceName: serviceName, + Path: "monitoring", + }) + if err != nil { + return false, fmt.Errorf("failed to check service monitoring config: %w", err) + } + + if configResp.Found { + color.Green(" ✓ Service monitoring configuration already exists") + + // Demonstrate reading back the configuration into our type-safe struct + var existingConfig ServiceMonitoringConfig + if err := protobufToStruct(configResp.Section, &existingConfig); err != nil { + return false, fmt.Errorf("failed to convert existing service config: %w", err) + } + color.Cyan(" Current config: Port=%d, LogLevel=%s, Tags=%v", + existingConfig.MetricsPort, existingConfig.LogLevel, existingConfig.Tags) + return false, nil // false means it already existed (not created) + } + + // Create default service monitoring configuration using type-safe struct + color.Yellow(" ⚙️ Creating service monitoring configuration...") + + serviceConfig := ServiceMonitoringConfig{ + Enabled: true, + HealthCheckPath: "/health", + MetricsPort: 9090, + LogLevel: "info", + Tags: []string{"web", "api", "production"}, + } + + // Set alert thresholds + serviceConfig.AlertThresholds.ErrorRate = 5.0 + serviceConfig.AlertThresholds.ResponseTimeMs = 2000 + serviceConfig.AlertThresholds.CPUPercent = 80 + + // Convert type-safe struct to protobuf struct + configStruct, err := structToProtobuf(serviceConfig) + if err != nil { + return false, fmt.Errorf("failed to create service config struct: %w", err) + } + + _, err = azdClient.Project().SetServiceConfigSection(ctx, &azdext.SetServiceConfigSectionRequest{ + ServiceName: serviceName, + Path: "monitoring", + Section: configStruct, + }) + if err != nil { + return false, fmt.Errorf("failed to set service monitoring config: %w", err) + } + + color.Green(" ✓ Service monitoring configuration created successfully") + return true, nil // true means it was created +} + +func displayConfigurationSummary( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + projectConfigCreated, serviceConfigCreated bool, +) error { + fmt.Println() + color.HiCyan("📊 Configuration Summary") + color.HiCyan("========================") + fmt.Println() + + // Display project monitoring config with status + projectStatus := "📋 Already existed" + if projectConfigCreated { + projectStatus = "✨ Newly created" + } + color.HiWhite("🏢 Project Monitoring Configuration (%s):", projectStatus) + projectConfigResp, err := azdClient.Project().GetConfigSection(ctx, &azdext.GetProjectConfigSectionRequest{ + Path: "monitoring", + }) + if err != nil { + return fmt.Errorf("failed to get project monitoring config: %w", err) + } + + if projectConfigResp.Found { + if err := printConfigSection(projectConfigResp.Section.AsMap()); err != nil { + return err + } + } + + fmt.Println() + + // Display service monitoring config with status (only if we have a service) + if serviceName != "" { + serviceStatus := "📋 Already existed" + if serviceConfigCreated { + serviceStatus = "✨ Newly created" + } + color.HiWhite("📦 Service '%s' Monitoring Configuration (%s):", serviceName, serviceStatus) + serviceConfigResp, err := azdClient.Project().GetServiceConfigSection(ctx, &azdext.GetServiceConfigSectionRequest{ + ServiceName: serviceName, + Path: "monitoring", + }) + if err != nil { + return fmt.Errorf("failed to get service monitoring config: %w", err) + } + + if serviceConfigResp.Found { + if err := printConfigSection(serviceConfigResp.Section.AsMap()); err != nil { + return err + } + } + fmt.Println() + } + + color.HiGreen("✅ Monitoring configuration setup complete!") + fmt.Println() + color.HiBlue("💡 This demonstrates how extensions can manage both project and service-level") + color.HiBlue(" configuration using the new AdditionalProperties gRPC API with type-safe") + color.HiBlue(" Go structs for complex configuration scenarios.") + + return nil +} + +func printConfigSection(section map[string]interface{}) error { + jsonBytes, err := json.MarshalIndent(section, " ", " ") + if err != nil { + return fmt.Errorf("failed to format section: %w", err) + } + fmt.Printf(" %s\n", string(jsonBytes)) + return nil +} diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go index 6913ef26017..b76d0a9466d 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go @@ -27,6 +27,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newColorsCommand()) rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(newMcpCommand()) + rootCmd.AddCommand(newConfigCommand()) rootCmd.AddCommand(newGhUrlParseCommand()) return rootCmd diff --git a/cli/azd/grpc/proto/models.proto b/cli/azd/grpc/proto/models.proto index 1598c7ba3d5..f3c292ad983 100644 --- a/cli/azd/grpc/proto/models.proto +++ b/cli/azd/grpc/proto/models.proto @@ -70,6 +70,7 @@ message ProjectConfig { ProjectMetadata metadata = 4; map services = 5; InfraOptions infra = 6; + google.protobuf.Struct additional_properties = 7; } // RequiredVersions message definition @@ -95,6 +96,7 @@ message ServiceConfig { string image = 9; DockerProjectOptions docker = 10; google.protobuf.Struct config = 11; + google.protobuf.Struct additional_properties = 12; } // InfraOptions message definition diff --git a/cli/azd/grpc/proto/project.proto b/cli/azd/grpc/proto/project.proto index 44f39376274..75a7fe4f2fe 100644 --- a/cli/azd/grpc/proto/project.proto +++ b/cli/azd/grpc/proto/project.proto @@ -7,6 +7,7 @@ package azdext; option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; import "models.proto"; +import "include/google/protobuf/struct.proto"; // ProjectService defines methods for managing projects and their configurations. service ProjectService { @@ -22,6 +23,36 @@ service ProjectService { // ParseGitHubUrl parses a GitHub URL and extracts repository information. rpc ParseGitHubUrl(ParseGitHubUrlRequest) returns (ParseGitHubUrlResponse); + + // Gets a configuration section by path. + rpc GetConfigSection(GetProjectConfigSectionRequest) returns (GetProjectConfigSectionResponse); + + // Gets a configuration value by path. + rpc GetConfigValue(GetProjectConfigValueRequest) returns (GetProjectConfigValueResponse); + + // Sets a configuration section by path. + rpc SetConfigSection(SetProjectConfigSectionRequest) returns (EmptyResponse); + + // Sets a configuration value by path. + rpc SetConfigValue(SetProjectConfigValueRequest) returns (EmptyResponse); + + // Removes configuration by path. + rpc UnsetConfig(UnsetProjectConfigRequest) returns (EmptyResponse); + + // Gets a service configuration section by path. + rpc GetServiceConfigSection(GetServiceConfigSectionRequest) returns (GetServiceConfigSectionResponse); + + // Gets a service configuration value by path. + rpc GetServiceConfigValue(GetServiceConfigValueRequest) returns (GetServiceConfigValueResponse); + + // Sets a service configuration section by path. + rpc SetServiceConfigSection(SetServiceConfigSectionRequest) returns (EmptyResponse); + + // Sets a service configuration value by path. + rpc SetServiceConfigValue(SetServiceConfigValueRequest) returns (EmptyResponse); + + // Removes service configuration by path. + rpc UnsetServiceConfig(UnsetServiceConfigRequest) returns (EmptyResponse); } // GetProjectResponse message definition @@ -45,6 +76,17 @@ message ParseGitHubUrlRequest { string url = 1; } +// Request message for GetConfigSection +message GetProjectConfigSectionRequest { + string path = 1; +} + +// Response message for GetConfigSection +message GetProjectConfigSectionResponse { + google.protobuf.Struct section = 1; + bool found = 2; +} + // ParseGitHubUrlResponse message definition message ParseGitHubUrlResponse { string hostname = 1; @@ -52,3 +94,75 @@ message ParseGitHubUrlResponse { string branch = 3; string file_path = 4; } + +// Request message for GetConfigValue +message GetProjectConfigValueRequest { + string path = 1; +} + +// Response message for GetConfigValue +message GetProjectConfigValueResponse { + google.protobuf.Value value = 1; + bool found = 2; +} + +// Request message for SetConfigSection +message SetProjectConfigSectionRequest { + string path = 1; + google.protobuf.Struct section = 2; +} + +// Request message for SetConfigValue +message SetProjectConfigValueRequest { + string path = 1; + google.protobuf.Value value = 2; +} + +// Request message for UnsetConfig +message UnsetProjectConfigRequest { + string path = 1; +} + +// Request message for GetServiceConfigSection +message GetServiceConfigSectionRequest { + string service_name = 1; + string path = 2; +} + +// Response message for GetServiceConfigSection +message GetServiceConfigSectionResponse { + google.protobuf.Struct section = 1; + bool found = 2; +} + +// Request message for GetServiceConfigValue +message GetServiceConfigValueRequest { + string service_name = 1; + string path = 2; +} + +// Response message for GetServiceConfigValue +message GetServiceConfigValueResponse { + google.protobuf.Value value = 1; + bool found = 2; +} + +// Request message for SetServiceConfigSection +message SetServiceConfigSectionRequest { + string service_name = 1; + string path = 2; + google.protobuf.Struct section = 3; +} + +// Request message for SetServiceConfigValue +message SetServiceConfigValueRequest { + string service_name = 1; + string path = 2; + google.protobuf.Value value = 3; +} + +// Request message for UnsetServiceConfig +message UnsetServiceConfigRequest { + string service_name = 1; + string path = 2; +} diff --git a/cli/azd/internal/grpcserver/project_service.go b/cli/azd/internal/grpcserver/project_service.go index 4798f87acec..8b8411f1901 100644 --- a/cli/azd/internal/grpcserver/project_service.go +++ b/cli/azd/internal/grpcserver/project_service.go @@ -15,38 +15,126 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/templates" "github.com/azure/azure-dev/cli/azd/pkg/tools/github" + "google.golang.org/protobuf/types/known/structpb" ) type projectService struct { azdext.UnimplementedProjectServiceServer - lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext] - lazyEnvManager *lazy.Lazy[environment.Manager] - importManager *project.ImportManager - ghCli *github.Cli + lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext] + lazyEnvManager *lazy.Lazy[environment.Manager] + importManager *project.ImportManager + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig] + ghCli *github.Cli } +// NewProjectService creates a new project service instance with lazy-loaded dependencies. +// The service provides gRPC methods for managing Azure Developer CLI projects, including +// project configuration, service management, and extension configuration through AdditionalProperties. +// +// Parameters: +// - lazyAzdContext: Lazy-loaded Azure Developer CLI context for project directory operations +// - lazyEnvManager: Lazy-loaded environment manager for handling Azure environments +// - 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], + lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], importManager *project.ImportManager, ghCli *github.Cli, ) azdext.ProjectServiceServer { return &projectService{ - lazyAzdContext: lazyAzdContext, - lazyEnvManager: lazyEnvManager, - importManager: importManager, - ghCli: ghCli, + lazyAzdContext: lazyAzdContext, + lazyEnvManager: lazyEnvManager, + lazyProjectConfig: lazyProjectConfig, + importManager: importManager, + ghCli: ghCli, } } +// reloadAndCacheProjectConfig reloads the project configuration from disk and updates the lazy cache. +// It preserves the EventDispatcher from the previous instance to maintain event handler continuity +// for both the project and all services. +// +// Event dispatchers must be preserved because they contain event handlers that were registered by: +// - azure.yaml hooks (prepackage, postdeploy, etc.) +// - azd extensions that registered custom event handlers +// +// Without preserving these dispatchers, any event handlers registered before the reload would be lost, +// causing hooks and extension-registered handlers to stop working after configuration updates. +func (s *projectService) reloadAndCacheProjectConfig(ctx context.Context, projectPath string) error { + // Get the current config to preserve the EventDispatchers + oldConfig, err := s.lazyProjectConfig.GetValue() + if err != nil { + // If we can't get old config, just reload without preserving dispatchers + reloadedConfig, err := project.Load(ctx, projectPath) + if err != nil { + return err + } + s.lazyProjectConfig.SetValue(reloadedConfig) + return nil + } + + // Reload the config from disk + reloadedConfig, err := project.Load(ctx, projectPath) + if err != nil { + return err + } + + // Preserve the EventDispatcher from the old project config + if oldConfig.EventDispatcher != nil { + reloadedConfig.EventDispatcher = oldConfig.EventDispatcher + } + + // Preserve the EventDispatcher for each service + if oldConfig.Services != nil && reloadedConfig.Services != nil { + for serviceName, oldService := range oldConfig.Services { + if reloadedService, exists := reloadedConfig.Services[serviceName]; exists && oldService.EventDispatcher != nil { + reloadedService.EventDispatcher = oldService.EventDispatcher + } + } + } + + // Update the lazy cache + s.lazyProjectConfig.SetValue(reloadedConfig) + return nil +} + +// validateServiceExists checks if a service exists in the project configuration. +// Returns an error if the service doesn't exist. +func (s *projectService) validateServiceExists(ctx context.Context, serviceName string) error { + projectConfig, err := s.lazyProjectConfig.GetValue() + if err != nil { + return err + } + + if projectConfig.Services == nil || projectConfig.Services[serviceName] == nil { + return fmt.Errorf("service '%s' not found", serviceName) + } + + return nil +} + +// 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. +// +// 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. 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 := project.Load(ctx, azdContext.ProjectPath()) + projectConfig, err := s.lazyProjectConfig.GetValue() if err != nil { return nil, err } @@ -72,33 +160,9 @@ func (s *projectService) Get(ctx context.Context, req *azdext.EmptyRequest) (*az } } - project := &azdext.ProjectConfig{ - Name: projectConfig.Name, - ResourceGroupName: projectConfig.ResourceGroupName.MustEnvsubst(envKeyMapper), - Path: projectConfig.Path, - Infra: &azdext.InfraOptions{ - Provider: string(projectConfig.Infra.Provider), - Path: projectConfig.Infra.Path, - Module: projectConfig.Infra.Module, - }, - Services: map[string]*azdext.ServiceConfig{}, - } - - if projectConfig.Metadata != nil { - project.Metadata = &azdext.ProjectMetadata{ - Template: projectConfig.Metadata.Template, - } - } - - for name, service := range projectConfig.Services { - var protoService *azdext.ServiceConfig - - // Use mapper with environment variable resolver - if err := mapper.WithResolver(envKeyMapper).Convert(service, &protoService); err != nil { - return nil, fmt.Errorf("converting service config to proto: %w", err) - } - - project.Services[name] = protoService + var project *azdext.ProjectConfig + if err := mapper.WithResolver(envKeyMapper).Convert(projectConfig, &project); err != nil { + return nil, fmt.Errorf("converting project config to proto: %w", err) } return &azdext.GetProjectResponse{ @@ -106,13 +170,22 @@ func (s *projectService) Get(ctx context.Context, req *azdext.EmptyRequest) (*az }, nil } +// 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. +// +// Parameters: +// - req.Service: The service configuration to add, including name, host, language, and other settings +// +// The service name from req.Service.Name is used as the key in the services map. +// If the services map doesn't exist, it will be initialized. func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceRequest) (*azdext.EmptyResponse, error) { azdContext, err := s.lazyAzdContext.GetValue() if err != nil { return nil, err } - projectConfig, err := project.Load(ctx, azdContext.ProjectPath()) + projectConfig, err := s.lazyProjectConfig.GetValue() if err != nil { return nil, err } @@ -134,6 +207,515 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR return &azdext.EmptyResponse{}, nil } +// 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 +// dot-notation paths (e.g., "extension.database.connection", "infra"). +// +// Parameters: +// - req.Path: Dot-notation path to the configuration section (e.g., "custom.settings", "infra") +// +// Returns: +// - Section: The configuration section as a protobuf Struct if found +// - Found: Boolean indicating whether the section exists +// +// Examples: +// - "infra" - retrieves the infrastructure configuration section +// - "custom.database" - retrieves custom extension configuration +func (s *projectService) GetConfigSection( + ctx context.Context, req *azdext.GetProjectConfigSectionRequest, +) (*azdext.GetProjectConfigSectionResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Load entire project config as a map (includes core fields and AdditionalProperties) + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + section, found := cfg.GetMap(req.Path) + + if !found { + return &azdext.GetProjectConfigSectionResponse{ + Found: false, + }, nil + } + + // Convert section to protobuf Struct + protoStruct, err := structpb.NewStruct(section) + if err != nil { + return nil, fmt.Errorf("failed to convert section to protobuf struct: %w", err) + } + + return &azdext.GetProjectConfigSectionResponse{ + Section: protoStruct, + Found: true, + }, nil +} + +// GetConfigValue retrieves a specific configuration value from the project configuration. +// This method provides access to both core struct fields (e.g., "name", "infra.provider") +// and extension-specific configuration values stored in AdditionalProperties using +// dot-notation paths (e.g., "extension.database.port", "infra.path"). +// +// Parameters: +// - req.Path: Dot-notation path to the configuration value (e.g., "custom.settings.timeout", "infra.provider") +// +// Returns: +// - Value: The configuration value as a protobuf Value if found +// - Found: Boolean indicating whether the value exists +// +// Supports all JSON types: strings, numbers, booleans, objects, and arrays. +// Examples: +// - "name" - retrieves the project name +// - "infra.provider" - retrieves the infrastructure provider +// - "custom.timeout" - retrieves custom extension configuration +func (s *projectService) GetConfigValue( + ctx context.Context, + req *azdext.GetProjectConfigValueRequest, +) (*azdext.GetProjectConfigValueResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Load entire project config as a map (includes core fields and AdditionalProperties) + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + value, ok := cfg.Get(req.Path) + + if !ok { + return &azdext.GetProjectConfigValueResponse{ + Found: false, + }, nil + } + + // Convert value to protobuf Value + protoValue, err := structpb.NewValue(value) + if err != nil { + return nil, fmt.Errorf("failed to convert value to protobuf value: %w", err) + } + + return &azdext.GetProjectConfigValueResponse{ + Value: protoValue, + Found: true, + }, nil +} + +// SetConfigSection sets or updates a configuration section in the project configuration. +// This method allows extensions to store complex configuration data as nested objects +// (both core fields and AdditionalProperties) using dot-notation paths. +// The changes are immediately persisted to the project file. +// +// Parameters: +// - req.Path: Dot-notation path where to store the section (e.g., "custom.database", "infra.module") +// - req.Section: The configuration section as a protobuf Struct containing the data +// +// If the path doesn't exist, it will be created. Existing data at the path will be replaced. +func (s *projectService) SetConfigSection( + ctx context.Context, + req *azdext.SetProjectConfigSectionRequest, +) (*azdext.EmptyResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Load entire project config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Convert protobuf Struct to map + sectionMap := req.Section.AsMap() + if err := cfg.Set(req.Path, sectionMap); err != nil { + return nil, fmt.Errorf("failed to set config section: %w", err) + } + + // Save the updated configuration (validates structure) + if err := project.SaveConfig(ctx, cfg, azdContext.ProjectPath()); err != nil { + return nil, err + } + + // Reload and update the lazy cache, preserving the EventDispatcher + if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil { + return nil, err + } + + return &azdext.EmptyResponse{}, nil +} + +// SetConfigValue sets or updates a specific configuration value in the project configuration. +// This method allows extensions to store individual configuration values (both core fields and +// AdditionalProperties) using dot-notation paths. The changes are immediately persisted to the project file. +// +// Parameters: +// - req.Path: Dot-notation path where to store the value (e.g., "custom.settings.timeout", "name", "infra.provider") +// - req.Value: The configuration value as a protobuf Value (string, number, boolean, etc.) +// +// If the path doesn't exist, intermediate objects will be created automatically. +// Existing data at the path will be replaced. +func (s *projectService) SetConfigValue( + ctx context.Context, + req *azdext.SetProjectConfigValueRequest, +) (*azdext.EmptyResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Load entire project config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Convert protobuf Value to interface{} + value := req.Value.AsInterface() + if err := cfg.Set(req.Path, value); err != nil { + return nil, fmt.Errorf("failed to set config value: %w", err) + } + + // Save the updated configuration (validates structure) + if err := project.SaveConfig(ctx, cfg, azdContext.ProjectPath()); err != nil { + return nil, err + } + + // Reload and update the lazy cache, preserving the EventDispatcher + if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil { + return nil, err + } + + return &azdext.EmptyResponse{}, nil +} + +// UnsetConfig removes a configuration value or section from the project configuration. +// This method allows extensions to clean up configuration data (both core fields and AdditionalProperties) +// using dot-notation paths. The changes are immediately persisted to the project file. +// +// Parameters: +// - req.Path: Dot-notation path to the configuration to remove (e.g., "custom.settings.timeout", "infra.module") +// +// If the path points to a value, only that value is removed. +// If the path points to a section, the entire section and all its contents are removed. +// If the path doesn't exist, the operation succeeds without error. +func (s *projectService) UnsetConfig( + ctx context.Context, + req *azdext.UnsetProjectConfigRequest, +) (*azdext.EmptyResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Load entire project config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + if err := cfg.Unset(req.Path); err != nil { + return nil, fmt.Errorf("failed to unset config: %w", err) + } + + // Save the updated configuration (validates structure) + if err := project.SaveConfig(ctx, cfg, azdContext.ProjectPath()); err != nil { + return nil, err + } + + // Reload and update the lazy cache, preserving the EventDispatcher + if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil { + return nil, err + } + + return &azdext.EmptyResponse{}, nil +} + +// GetServiceConfigSection retrieves a configuration section from a specific service's configuration. +// This method provides access to service-specific configuration data (both core fields like "host", "project" +// and AdditionalProperties) using dot-notation paths. +// +// Parameters: +// - req.ServiceName: Name of the service to retrieve configuration from +// - req.Path: Dot-notation path to the configuration section (e.g., "custom.database", or empty for entire service config) +// +// Returns: +// - Section: The configuration section as a protobuf Struct if found +// - Found: Boolean indicating whether the section exists +// +// Returns an error if the specified service doesn't exist in the project. +func (s *projectService) GetServiceConfigSection( + ctx context.Context, + req *azdext.GetServiceConfigSectionRequest, +) (*azdext.GetServiceConfigSectionResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Validate service exists + if err := s.validateServiceExists(ctx, req.ServiceName); err != nil { + return nil, err + } + + // Load entire project config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Construct path to service config section: "services.." + servicePath := fmt.Sprintf("services.%s", req.ServiceName) + if req.Path != "" { + servicePath = fmt.Sprintf("%s.%s", servicePath, req.Path) + } + + section, found := cfg.GetMap(servicePath) + + if !found { + return &azdext.GetServiceConfigSectionResponse{ + Found: false, + }, nil + } + + // Convert section to protobuf Struct + protoStruct, err := structpb.NewStruct(section) + if err != nil { + return nil, fmt.Errorf("failed to convert section to protobuf struct: %w", err) + } + + return &azdext.GetServiceConfigSectionResponse{ + Section: protoStruct, + Found: true, + }, nil +} + +// GetServiceConfigValue retrieves a specific configuration value from a service's configuration. +// This method provides access to individual service-specific configuration values (both core fields +// like "host", "project" and AdditionalProperties) using dot-notation paths. +// +// Parameters: +// - req.ServiceName: Name of the service to retrieve configuration from +// - req.Path: Dot-notation path to the configuration value (e.g., "custom.database.port", "host", "project") +// +// Returns: +// - Value: The configuration value as a protobuf Value if found +// - Found: Boolean indicating whether the value exists +// +// Supports all JSON types: strings, numbers, booleans, objects, and arrays. +// Returns an error if the specified service doesn't exist in the project. +func (s *projectService) GetServiceConfigValue( + ctx context.Context, + req *azdext.GetServiceConfigValueRequest, +) (*azdext.GetServiceConfigValueResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Validate service exists + if err := s.validateServiceExists(ctx, req.ServiceName); err != nil { + return nil, err + } + + // Load entire project config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Construct path to service config value: "services.." + servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + + value, ok := cfg.Get(servicePath) + + if !ok { + return &azdext.GetServiceConfigValueResponse{ + Found: false, + }, nil + } + + // Convert value to protobuf Value + protoValue, err := structpb.NewValue(value) + if err != nil { + return nil, fmt.Errorf("failed to convert value to protobuf value: %w", err) + } + + return &azdext.GetServiceConfigValueResponse{ + Value: protoValue, + Found: true, + }, nil +} + +// SetServiceConfigSection sets or updates a configuration section in a service's configuration. +// This method allows extensions to store complex service-specific configuration data (both core fields +// and AdditionalProperties) as nested objects. The changes are immediately persisted to the project file. +// +// Parameters: +// - req.ServiceName: Name of the service to update configuration for +// - req.Path: Dot-notation path where to store the section +// - req.Section: The configuration section as a protobuf Struct containing the data +// +// Returns an error if the specified service doesn't exist in the project. +// If the path doesn't exist, it will be created. Existing data at the path will be replaced. +func (s *projectService) SetServiceConfigSection( + ctx context.Context, + req *azdext.SetServiceConfigSectionRequest, +) (*azdext.EmptyResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Validate service exists + if err := s.validateServiceExists(ctx, req.ServiceName); err != nil { + return nil, err + } + + // Load the full config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Construct path to service config section: "services.." + servicePath := fmt.Sprintf("services.%s", req.ServiceName) + if req.Path != "" { + servicePath = fmt.Sprintf("%s.%s", servicePath, req.Path) + } + + // Convert protobuf Struct to map + sectionMap := req.Section.AsMap() + if err := cfg.Set(servicePath, sectionMap); err != nil { + return nil, fmt.Errorf("failed to set service config section: %w", err) + } + + // Save the updated configuration (validates structure) + if err := project.SaveConfig(ctx, cfg, azdContext.ProjectPath()); err != nil { + return nil, err + } + + // Reload and update the lazy cache, preserving the EventDispatcher + if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil { + return nil, err + } + + return &azdext.EmptyResponse{}, nil +} + +// SetServiceConfigValue sets or updates a specific configuration value in a service's configuration. +// This method allows extensions to store individual service-specific configuration values (both core +// fields like "host", "project" and AdditionalProperties). The changes are immediately persisted to the project file. +// +// Parameters: +// - req.ServiceName: Name of the service to update configuration for +// - req.Path: Dot-notation path where to store the value (e.g., "custom.database.port", "host", "project") +// - req.Value: The configuration value as a protobuf Value (string, number, boolean, etc.) +// +// Returns an error if the specified service doesn't exist in the project. +// If the path doesn't exist, intermediate objects will be created automatically. +// Existing data at the path will be replaced. +func (s *projectService) SetServiceConfigValue( + ctx context.Context, + req *azdext.SetServiceConfigValueRequest, +) (*azdext.EmptyResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Validate service exists + if err := s.validateServiceExists(ctx, req.ServiceName); err != nil { + return nil, err + } + + // Load the full config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Construct path to service config value: "services.." + servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + + // Convert protobuf Value to interface{} + value := req.Value.AsInterface() + if err := cfg.Set(servicePath, value); err != nil { + return nil, fmt.Errorf("failed to set service config value: %w", err) + } + + // Save the updated configuration (validates structure) + if err := project.SaveConfig(ctx, cfg, azdContext.ProjectPath()); err != nil { + return nil, err + } + + // Reload and update the lazy cache, preserving the EventDispatcher + if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil { + return nil, err + } + + return &azdext.EmptyResponse{}, nil +} + +// UnsetServiceConfig removes a configuration value or section from a service's configuration. +// This method allows extensions to clean up service-specific configuration data (both core fields +// and AdditionalProperties). The changes are immediately persisted to the project file. +// +// Parameters: +// - req.ServiceName: Name of the service to remove configuration from +// - req.Path: Dot-notation path to the configuration to remove (e.g., "custom.database.port", "module") +// +// Returns an error if the specified service doesn't exist in the project. +// If the path points to a value, only that value is removed. +// If the path points to a section, the entire section and all its contents are removed. +// If the path doesn't exist, the operation succeeds without error. +func (s *projectService) UnsetServiceConfig( + ctx context.Context, + req *azdext.UnsetServiceConfigRequest, +) (*azdext.EmptyResponse, error) { + azdContext, err := s.lazyAzdContext.GetValue() + if err != nil { + return nil, err + } + + // Validate service exists + if err := s.validateServiceExists(ctx, req.ServiceName); err != nil { + return nil, err + } + + // Load the full config as a map + cfg, err := project.LoadConfig(ctx, azdContext.ProjectPath()) + if err != nil { + return nil, err + } + + // Construct path to service config: "services.." + servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + + if err := cfg.Unset(servicePath); err != nil { + return nil, fmt.Errorf("failed to unset service config: %w", err) + } + + // Save the updated configuration (validates structure) + if err := project.SaveConfig(ctx, cfg, azdContext.ProjectPath()); err != nil { + return nil, err + } + + // Reload and update the lazy cache, preserving the EventDispatcher + if err := s.reloadAndCacheProjectConfig(ctx, azdContext.ProjectPath()); err != nil { + return nil, err + } + + return &azdext.EmptyResponse{}, nil +} + // GetResolvedServices returns the resolved list of services after processing any importers (e.g., Aspire projects). // This includes services generated by importers like Aspire AppHost projects. func (s *projectService) GetResolvedServices( diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index e4432093e5c..aad1718f188 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -7,6 +7,7 @@ import ( "context" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -19,6 +20,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/github" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // Test_ProjectService_NoProject ensures that when no project exists, @@ -41,6 +43,9 @@ func Test_ProjectService_NoProject(t *testing.T) { lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { return nil, azdcontext.ErrNoProject }) + lazyProjectConfig := lazy.NewLazy(func() (*project.ProjectConfig, error) { + return nil, azdcontext.ErrNoProject + }) // Create mock GitHub CLI. ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) @@ -48,7 +53,7 @@ func Test_ProjectService_NoProject(t *testing.T) { // Create the service with ImportManager. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, importManager, ghCli) + service := NewProjectService(lazyAzdContext, lazyEnvManager, lazyProjectConfig, importManager, ghCli) _, err = service.Get(*mockContext.Context, &azdext.EmptyRequest{}) require.Error(t, err) } @@ -87,6 +92,7 @@ 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. testEnv1, err := envManager.Create(*mockContext.Context, environment.Spec{ @@ -104,7 +110,7 @@ func Test_ProjectService_Flow(t *testing.T) { // Create the service with ImportManager. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, importManager, ghCli) + service := NewProjectService(lazyAzdContext, lazyEnvManager, lazyProjectConfig, importManager, ghCli) // Test: Retrieve project details. getResponse, err := service.Get(*mockContext.Context, &azdext.EmptyRequest{}) @@ -145,6 +151,7 @@ func Test_ProjectService_AddService(t *testing.T) { // Create lazy-loaded instances. lazyAzdContext := lazy.From(azdContext) lazyEnvManager := lazy.From(envManager) + lazyProjectConfig := lazy.From(&projectConfig) // Create mock GitHub CLI. ghCli, err := github.NewGitHubCli(*mockContext.Context, mockContext.Console, mockContext.CommandRunner) @@ -152,7 +159,7 @@ func Test_ProjectService_AddService(t *testing.T) { // Create the project service with ImportManager. importManager := project.NewImportManager(&project.DotNetImporter{}) - service := NewProjectService(lazyAzdContext, lazyEnvManager, importManager, ghCli) + service := NewProjectService(lazyAzdContext, lazyEnvManager, lazyProjectConfig, importManager, ghCli) // Prepare a new service addition request. serviceRequest := &azdext.AddServiceRequest{ @@ -180,3 +187,1493 @@ func Test_ProjectService_AddService(t *testing.T) { require.Equal(t, project.ServiceLanguagePython, serviceConfig.Language) require.Equal(t, project.ContainerAppTarget, serviceConfig.Host) } + +func Test_ProjectService_ConfigSection(t *testing.T) { + // Setup mock context and temporary project directory + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create project config with additional properties + projectConfig := &project.ProjectConfig{ + Name: "test", + AdditionalProperties: map[string]any{ + "database": map[string]any{ + "host": "localhost", + "port": 5432, + "credentials": map[string]any{ + "username": "admin", + "password": "secret", + }, + }, + "feature": map[string]any{ + "enabled": true, + }, + }, + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("GetConfigSection_Success", func(t *testing.T) { + resp, err := service.GetConfigSection(*mockContext.Context, &azdext.GetProjectConfigSectionRequest{ + Path: "database", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.NotNil(t, resp.Section) + + sectionMap := resp.Section.AsMap() + require.Equal(t, "localhost", sectionMap["host"]) + require.Equal(t, float64(5432), sectionMap["port"]) // JSON numbers are float64 + }) + + t.Run("GetConfigSection_NestedSection", func(t *testing.T) { + resp, err := service.GetConfigSection(*mockContext.Context, &azdext.GetProjectConfigSectionRequest{ + Path: "database.credentials", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.NotNil(t, resp.Section) + + sectionMap := resp.Section.AsMap() + require.Equal(t, "admin", sectionMap["username"]) + require.Equal(t, "secret", sectionMap["password"]) + }) + + t.Run("GetConfigSection_NotFound", func(t *testing.T) { + resp, err := service.GetConfigSection(*mockContext.Context, &azdext.GetProjectConfigSectionRequest{ + Path: "nonexistent", + }) + require.NoError(t, err) + require.False(t, resp.Found) + require.Nil(t, resp.Section) + }) +} + +func Test_ProjectService_ConfigValue(t *testing.T) { + // Setup mock context and temporary project directory + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create project config with additional properties + projectConfig := &project.ProjectConfig{ + Name: "test", + AdditionalProperties: map[string]any{ + "database": map[string]any{ + "host": "localhost", + "port": 5432, + "enabled": true, + }, + "version": "1.0.0", + }, + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("GetConfigValue_String", func(t *testing.T) { + resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "version", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.Equal(t, "1.0.0", resp.Value.AsInterface()) + }) + + t.Run("GetConfigValue_NestedString", func(t *testing.T) { + resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "database.host", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.Equal(t, "localhost", resp.Value.AsInterface()) + }) + + t.Run("GetConfigValue_Number", func(t *testing.T) { + resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "database.port", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.Equal(t, float64(5432), resp.Value.AsInterface()) + }) + + t.Run("GetConfigValue_Boolean", func(t *testing.T) { + resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "database.enabled", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.Equal(t, true, resp.Value.AsInterface()) + }) + + t.Run("GetConfigValue_NotFound", func(t *testing.T) { + resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "nonexistent", + }) + require.NoError(t, err) + require.False(t, resp.Found) + require.Nil(t, resp.Value) + }) +} + +func Test_ProjectService_SetConfigSection(t *testing.T) { + // Setup mock context and temporary project directory + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create initial project config + projectConfig := &project.ProjectConfig{ + Name: "test", + AdditionalProperties: map[string]any{ + "existing": "value", + }, + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("SetConfigSection_NewSection", func(t *testing.T) { + // Create section data + sectionData := map[string]any{ + "host": "newhost", + "port": 3306, + "ssl": true, + } + sectionStruct, err := structpb.NewStruct(sectionData) + require.NoError(t, err) + + // Set the section + _, err = service.SetConfigSection(*mockContext.Context, &azdext.SetProjectConfigSectionRequest{ + Path: "mysql", + Section: sectionStruct, + }) + require.NoError(t, err) + + // Reload config from disk to verify changes were persisted + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + mysqlSection, found := cfg.GetMap("mysql") + require.True(t, found, "mysql section should exist") + require.Equal(t, "newhost", mysqlSection["host"]) + require.Equal(t, 3306, mysqlSection["port"]) + require.Equal(t, true, mysqlSection["ssl"]) + }) + + t.Run("SetConfigSection_NestedSection", func(t *testing.T) { + // Create nested section data + sectionData := map[string]any{ + "username": "admin", + "password": "secret123", + } + sectionStruct, err := structpb.NewStruct(sectionData) + require.NoError(t, err) + + // Set the nested section + _, err = service.SetConfigSection(*mockContext.Context, &azdext.SetProjectConfigSectionRequest{ + Path: "mysql.credentials", + Section: sectionStruct, + }) + require.NoError(t, err) + + // Reload config from disk to verify changes were persisted + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + credentials, found := cfg.GetMap("mysql.credentials") + require.True(t, found, "mysql.credentials section should exist") + require.Equal(t, "admin", credentials["username"]) + require.Equal(t, "secret123", credentials["password"]) + }) +} + +func Test_ProjectService_SetConfigValue(t *testing.T) { + // Setup mock context and temporary project directory + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create initial project config + projectConfig := &project.ProjectConfig{ + Name: "test", + AdditionalProperties: map[string]any{ + "existing": "value", + }, + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("SetConfigValue_String", func(t *testing.T) { + value, err := structpb.NewValue("test-string") + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "app.name", + Value: value, + }) + require.NoError(t, err) + + // Reload config from disk to verify value was set + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + name, found := cfg.Get("app.name") + require.True(t, found, "app.name should exist") + require.Equal(t, "test-string", name) + }) + + t.Run("SetConfigValue_Number", func(t *testing.T) { + value, err := structpb.NewValue(8080) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "app.port", + Value: value, + }) + require.NoError(t, err) + + // Reload config from disk to verify value was set + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + port, found := cfg.Get("app.port") + require.True(t, found, "app.port should exist") + require.Equal(t, 8080, port) + }) + + t.Run("SetConfigValue_Boolean", func(t *testing.T) { + value, err := structpb.NewValue(true) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "app.debug", + Value: value, + }) + require.NoError(t, err) + + // Reload config from disk to verify value was set + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + debug, found := cfg.Get("app.debug") + require.True(t, found, "app.debug should exist") + require.Equal(t, true, debug) + }) +} + +func Test_ProjectService_UnsetConfig(t *testing.T) { + // Setup mock context and temporary project directory + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create project config with additional properties to unset + projectConfig := &project.ProjectConfig{ + Name: "test", + AdditionalProperties: map[string]any{ + "database": map[string]any{ + "host": "localhost", + "port": 5432, + "credentials": map[string]any{ + "username": "admin", + "password": "secret", + }, + }, + "cache": map[string]any{ + "enabled": true, + "ttl": 300, + }, + }, + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("UnsetConfig_NestedValue", func(t *testing.T) { + _, err := service.UnsetConfig(*mockContext.Context, &azdext.UnsetProjectConfigRequest{ + Path: "database.credentials.password", + }) + require.NoError(t, err) + + // Reload config from disk to verify nested value was removed + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + _, exists := cfg.Get("database.credentials.password") + require.False(t, exists, "password should be removed") + // But username should still exist + username, exists := cfg.Get("database.credentials.username") + require.True(t, exists, "username should still exist") + require.Equal(t, "admin", username) + }) + + t.Run("UnsetConfig_EntireSection", func(t *testing.T) { + _, err := service.UnsetConfig(*mockContext.Context, &azdext.UnsetProjectConfigRequest{ + Path: "cache", + }) + require.NoError(t, err) + + // Reload config from disk to verify entire section was removed + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + _, exists := cfg.GetMap("cache") + require.False(t, exists, "cache section should be removed") + // But database section should still exist + _, exists = cfg.GetMap("database") + require.True(t, exists, "database section should still exist") + }) + + t.Run("UnsetConfig_NonexistentPath", func(t *testing.T) { + _, err := service.UnsetConfig(*mockContext.Context, &azdext.UnsetProjectConfigRequest{ + Path: "nonexistent.path", + }) + // Should not error even if path doesn't exist + require.NoError(t, err) + }) +} + +func Test_ProjectService_ConfigNilAdditionalProperties(t *testing.T) { + // Test behavior when AdditionalProperties is nil + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create project config WITHOUT additional properties + projectConfig := &project.ProjectConfig{ + Name: "test", + // AdditionalProperties is nil + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("GetConfigValue_NilAdditionalProperties", func(t *testing.T) { + resp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "any.path", + }) + require.NoError(t, err) + require.False(t, resp.Found) + }) + + t.Run("SetConfigValue_NilAdditionalProperties", func(t *testing.T) { + value, err := structpb.NewValue("test-value") + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "new.value", + Value: value, + }) + require.NoError(t, err) + + // Reload config from disk to verify value was set + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + val, found := cfg.Get("new.value") + require.True(t, found, "new.value should exist") + require.Equal(t, "test-value", val) + }) +} + +// Test_ProjectService_ServiceConfiguration validates service-level configuration operations. +func Test_ProjectService_ServiceConfiguration(t *testing.T) { + // Setup a mock context and temporary project directory. + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + + // Initialize project configuration with a service. + projectConfig := &project.ProjectConfig{ + Name: "test-project", + Path: temp, + Services: map[string]*project.ServiceConfig{ + "api": { + Name: "api", + Host: project.ContainerAppTarget, + Language: "javascript", + OutputPath: "./dist", + AdditionalProperties: map[string]any{ + "custom": map[string]any{ + "setting": "value", + "nested": map[string]any{ + "key": "nested-value", + }, + }, + "database": map[string]any{ + "host": "localhost", + "port": float64(5432), // JSON numbers become float64 + }, + }, + }, + "web": { + Name: "web", + Host: project.StaticWebAppTarget, + Language: "typescript", + }, + }, + } + + // Mock AzdContext with project path. + azdContext := &azdcontext.AzdContext{} + azdContext.SetProjectDirectory(temp) + + // Save the initial project config to disk + 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, lazyProjectConfig, importManager, nil) + + t.Run("GetServiceConfigSection_Found", func(t *testing.T) { + resp, err := service.GetServiceConfigSection(*mockContext.Context, &azdext.GetServiceConfigSectionRequest{ + ServiceName: "api", + Path: "custom", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.NotNil(t, resp.Section) + + // Verify the section content + sectionMap := resp.Section.AsMap() + require.Equal(t, "value", sectionMap["setting"]) + + nested := sectionMap["nested"].(map[string]any) + require.Equal(t, "nested-value", nested["key"]) + }) + + t.Run("GetServiceConfigSection_NotFound", func(t *testing.T) { + resp, err := service.GetServiceConfigSection(*mockContext.Context, &azdext.GetServiceConfigSectionRequest{ + ServiceName: "api", + Path: "nonexistent", + }) + require.NoError(t, err) + require.False(t, resp.Found) + require.Nil(t, resp.Section) + }) + + t.Run("GetServiceConfigSection_ServiceNotFound", func(t *testing.T) { + _, err := service.GetServiceConfigSection(*mockContext.Context, &azdext.GetServiceConfigSectionRequest{ + ServiceName: "nonexistent", + Path: "custom", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "service 'nonexistent' not found") + }) + + t.Run("GetServiceConfigValue_Found", func(t *testing.T) { + resp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "api", + Path: "custom.setting", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.NotNil(t, resp.Value) + require.Equal(t, "value", resp.Value.AsInterface()) + }) + + t.Run("GetServiceConfigValue_NestedValue", func(t *testing.T) { + resp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "api", + Path: "custom.nested.key", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.NotNil(t, resp.Value) + require.Equal(t, "nested-value", resp.Value.AsInterface()) + }) + + t.Run("GetServiceConfigValue_NumericValue", func(t *testing.T) { + resp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "api", + Path: "database.port", + }) + require.NoError(t, err) + require.True(t, resp.Found) + require.NotNil(t, resp.Value) + require.Equal(t, float64(5432), resp.Value.AsInterface()) + }) + + t.Run("GetServiceConfigValue_NotFound", func(t *testing.T) { + resp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "api", + Path: "nonexistent.path", + }) + require.NoError(t, err) + require.False(t, resp.Found) + require.Nil(t, resp.Value) + }) + + t.Run("GetServiceConfigValue_ServiceNotFound", func(t *testing.T) { + _, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "nonexistent", + Path: "custom.setting", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "service 'nonexistent' not found") + }) + + t.Run("SetServiceConfigSection", func(t *testing.T) { + sectionData := map[string]any{ + "newSetting": "new-value", + "anotherSetting": map[string]any{ + "innerKey": "inner-value", + }, + } + sectionStruct, err := structpb.NewStruct(sectionData) + require.NoError(t, err) + + _, err = service.SetServiceConfigSection(*mockContext.Context, &azdext.SetServiceConfigSectionRequest{ + ServiceName: "api", + Path: "newSection", + Section: sectionStruct, + }) + require.NoError(t, err) + + // Verify the section was set by loading from disk + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + newSection, found := cfg.GetMap("services.api.newSection") + require.True(t, found, "services.api.newSection should exist") + require.Equal(t, "new-value", newSection["newSetting"]) + + anotherSetting := newSection["anotherSetting"].(map[string]any) + require.Equal(t, "inner-value", anotherSetting["innerKey"]) + }) + + t.Run("SetServiceConfigSection_ServiceNotFound", func(t *testing.T) { + sectionStruct, err := structpb.NewStruct(map[string]any{"key": "value"}) + require.NoError(t, err) + + _, err = service.SetServiceConfigSection(*mockContext.Context, &azdext.SetServiceConfigSectionRequest{ + ServiceName: "nonexistent", + Path: "section", + Section: sectionStruct, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "service 'nonexistent' not found") + }) + + t.Run("SetServiceConfigValue", func(t *testing.T) { + value, err := structpb.NewValue("updated-value") + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "api", + Path: "custom.setting", + Value: value, + }) + require.NoError(t, err) + + // Verify the value was updated by loading from disk + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + updatedValue, found := cfg.Get("services.api.custom.setting") + require.True(t, found, "services.api.custom.setting should exist") + require.Equal(t, "updated-value", updatedValue) + }) + + t.Run("SetServiceConfigValue_NewPath", func(t *testing.T) { + value, err := structpb.NewValue(float64(8080)) + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "api", + Path: "server.port", + Value: value, + }) + require.NoError(t, err) + + // Verify the new path was created by loading from disk + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + portValue, found := cfg.Get("services.api.server.port") + require.True(t, found, "services.api.server.port should exist") + require.Equal(t, 8080, portValue) + }) + + t.Run("SetServiceConfigValue_ServiceNotFound", func(t *testing.T) { + value, err := structpb.NewValue("value") + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "nonexistent", + Path: "path", + Value: value, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "service 'nonexistent' not found") + }) + + t.Run("UnsetServiceConfig", func(t *testing.T) { + _, err := service.UnsetServiceConfig(*mockContext.Context, &azdext.UnsetServiceConfigRequest{ + ServiceName: "api", + Path: "custom.setting", + }) + require.NoError(t, err) + + // Verify the value was removed by loading from disk + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + _, found := cfg.Get("services.api.custom.setting") + require.False(t, found, "services.api.custom.setting should not exist after unset") + }) + + t.Run("UnsetServiceConfig_EntireSection", func(t *testing.T) { + _, err := service.UnsetServiceConfig(*mockContext.Context, &azdext.UnsetServiceConfigRequest{ + ServiceName: "api", + Path: "database", + }) + require.NoError(t, err) + + // Verify the entire section was removed by loading from disk + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + _, found := cfg.Get("services.api.database") + require.False(t, found, "services.api.database should not exist after unset") + }) + + t.Run("UnsetServiceConfig_ServiceNotFound", func(t *testing.T) { + _, err := service.UnsetServiceConfig(*mockContext.Context, &azdext.UnsetServiceConfigRequest{ + ServiceName: "nonexistent", + Path: "path", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "service 'nonexistent' not found") + }) + + t.Run("UnsetServiceConfig_NonexistentPath", func(t *testing.T) { + _, err := service.UnsetServiceConfig(*mockContext.Context, &azdext.UnsetServiceConfigRequest{ + ServiceName: "api", + Path: "nonexistent.path", + }) + require.NoError(t, err) // Should not error even if path doesn't exist + }) +} + +// Test_ProjectService_ServiceConfiguration_NilAdditionalProperties validates service configuration +// operations when AdditionalProperties is nil. +func Test_ProjectService_ServiceConfiguration_NilAdditionalProperties(t *testing.T) { + // Setup a mock context and temporary project directory. + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + + // Initialize project configuration with a service that has nil AdditionalProperties. + projectConfig := &project.ProjectConfig{ + Name: "test-project", + Path: temp, + Services: map[string]*project.ServiceConfig{ + "api": { + Name: "api", + Host: project.ContainerAppTarget, + Language: "javascript", + // AdditionalProperties is nil + }, + }, + } + + // Mock AzdContext with project path. + azdContext := &azdcontext.AzdContext{} + azdContext.SetProjectDirectory(temp) + + // Save the initial project config to disk + 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, lazyProjectConfig, importManager, nil) + + t.Run("GetServiceConfigSection_NilAdditionalProperties", func(t *testing.T) { + resp, err := service.GetServiceConfigSection(*mockContext.Context, &azdext.GetServiceConfigSectionRequest{ + ServiceName: "api", + Path: "any.path", + }) + require.NoError(t, err) + require.False(t, resp.Found) + }) + + t.Run("GetServiceConfigValue_NilAdditionalProperties", func(t *testing.T) { + resp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "api", + Path: "any.path", + }) + require.NoError(t, err) + require.False(t, resp.Found) + }) + + t.Run("SetServiceConfigValue_NilAdditionalProperties", func(t *testing.T) { + value, err := structpb.NewValue("test-value") + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "api", + Path: "new.value", + Value: value, + }) + require.NoError(t, err) + + // Verify AdditionalProperties was initialized and value was set by loading from disk + cfg, err := project.LoadConfig(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + val, found := cfg.Get("services.api.new.value") + require.True(t, found, "services.api.new.value should exist") + require.Equal(t, "test-value", val) + }) +} + +// Test_ProjectService_ChangeServiceHost validates that core service configuration fields +// (like "host") can be retrieved and modified using the config methods after migrating +// to the unified LoadConfig/SaveConfig approach. +func Test_ProjectService_ChangeServiceHost(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create project with a service that has host=containerapp + projectConfig := &project.ProjectConfig{ + Name: "test", + Services: map[string]*project.ServiceConfig{ + "web": { + Name: "web", + Host: project.ContainerAppTarget, + Language: project.ServiceLanguageTypeScript, + RelativePath: "./src/web", + }, + }, + } + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + // Test 1: Get the current host value + getResp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "web", + Path: "host", + }) + require.NoError(t, err) + require.True(t, getResp.Found, "host field should be found") + require.Equal(t, string(project.ContainerAppTarget), getResp.Value.GetStringValue(), + "host should be 'containerapp'") + + // Test 2: Change the host to appservice + value, err := structpb.NewValue(string(project.AppServiceTarget)) + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "web", + Path: "host", + Value: value, + }) + require.NoError(t, err, "setting core field 'host' should succeed") + + // Test 3: Verify the host was changed + getResp2, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "web", + Path: "host", + }) + require.NoError(t, err) + require.True(t, getResp2.Found, "host field should still be found") + require.Equal(t, string(project.AppServiceTarget), getResp2.Value.GetStringValue(), + "host should now be 'appservice'") + + // Test 4: Verify the change was persisted to disk + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Equal(t, project.AppServiceTarget, reloadedConfig.Services["web"].Host, + "persisted host should be 'appservice'") +} + +// Test_ProjectService_TypeValidation_InvalidChangesNotPersisted tests that invalid type changes +// fail validation and are not persisted to disk. +func Test_ProjectService_TypeValidation_InvalidChangesNotPersisted(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create initial project with a service + projectConfig := &project.ProjectConfig{ + Name: "test-project", + Services: map[string]*project.ServiceConfig{ + "web": { + Name: "web", + RelativePath: "./src/web", + Host: project.ContainerAppTarget, + Language: project.ServiceLanguageDotNet, + }, + }, + } + + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + loadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("Project_SetInfraToInt_ShouldFailAndNotPersist", func(t *testing.T) { + // Try to set "infra" (which should be an object) to an integer + intValue, err := structpb.NewValue(123) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "infra", + Value: intValue, + }) + + // This should fail because "infra" expects a provisioning.Options struct, not an int + require.Error(t, err, "setting infra to int should fail validation") + + // Verify the change was NOT persisted to disk + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.NotNil(t, reloadedConfig.Infra, "infra should still be valid object") + require.Empty(t, reloadedConfig.Infra.Provider, "infra.provider should be empty (default)") + }) + + t.Run("Project_SetInfraProviderToObject_ShouldFailAndNotPersist", func(t *testing.T) { + // Try to set "infra.provider" (which should be a string) to an object + objectValue, err := structpb.NewStruct(map[string]interface{}{ + "nested": "value", + }) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "infra.provider", + Value: structpb.NewStructValue(objectValue), + }) + + // This should fail because "infra.provider" expects a string, not an object + require.Error(t, err, "setting infra.provider to object should fail validation") + + // Verify the change was NOT persisted to disk + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Empty(t, reloadedConfig.Infra.Provider, "infra.provider should still be empty") + }) + + t.Run("Project_SetInfraProviderToInt_FailsDuringSet", func(t *testing.T) { + // Try to set "infra.provider" to an int instead of a string + invalidProvider, err := structpb.NewValue(999) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "infra.provider", + Value: invalidProvider, + }) + + // SetConfigValue calls reloadAndCacheProjectConfig which calls project.Load + // project.Load fails because "999" is not a valid provider + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported IaC provider '999'") + + // Verify the change was NOT persisted to disk (should still be valid) + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Empty(t, reloadedConfig.Infra.Provider) + }) + + t.Run("Service_SetHostToInt_CoercesToString", func(t *testing.T) { + // Save the current state + originalConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + originalHost := originalConfig.Services["web"].Host + + // Try to set "host" to an integer instead of a string + invalidValue, err := structpb.NewValue(789) + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "web", + Path: "host", + Value: invalidValue, + }) + + // This succeeds at the config level (YAML allows numbers) + require.NoError(t, err) + + // YAML coerces 789 to string "789", which is then treated as a custom host value + // (project.Load doesn't fail on unknown host types, it treats them as custom) + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Equal(t, project.ServiceTargetKind("789"), reloadedConfig.Services["web"].Host) + + // Restore the original valid configuration + err = project.Save(*mockContext.Context, originalConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // Verify restoration succeeded + restoredConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Equal(t, originalHost, restoredConfig.Services["web"].Host) + }) + + t.Run("Service_SetLanguageToArray_ShouldFailAndNotPersist", func(t *testing.T) { + // Get current language value + originalConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + originalLanguage := originalConfig.Services["web"].Language + + // Try to set "language" to an array + arrayValue, err := structpb.NewList([]interface{}{"go", "python"}) + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "web", + Path: "language", + Value: structpb.NewListValue(arrayValue), + }) + + // This should fail because "language" expects a string, not an array + require.Error(t, err, "setting language to array should fail validation") + + // Verify the change was NOT persisted to disk + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Equal(t, originalLanguage, reloadedConfig.Services["web"].Language, + "language should still have original value") + }) + + t.Run("Service_SetDockerToInvalidStructure_ShouldSucceedButFailOnReload", func(t *testing.T) { + // Save the current state + originalConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + // Try to set "docker.path" to an int instead of a string + invalidPath, err := structpb.NewValue(123) + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "web", + Path: "docker.path", + Value: invalidPath, + }) + + // This succeeds at the config level (YAML allows numbers) + require.NoError(t, err, "setting docker.path to int succeeds at config level") + + // When we reload, YAML will coerce 123 to string "123", which is technically valid + // but semantically wrong (not a valid file path) + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err, "parsing succeeds because YAML coerces int to string") + require.Equal(t, "123", reloadedConfig.Services["web"].Docker.Path, "path is coerced to string '123'") + + // Restore the original valid configuration + err = project.Save(*mockContext.Context, originalConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // Verify restoration succeeded + restoredConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Empty(t, restoredConfig.Services["web"].Docker.Path) + }) +} + +// Test_ProjectService_TypeValidation_CoercedValues tests YAML type coercion behavior +func Test_ProjectService_TypeValidation_CoercedValues(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Create initial project + projectConfig := &project.ProjectConfig{ + Name: "test-project", + } + + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + loadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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, lazyProjectConfig, importManager, nil) + + t.Run("SetNameToInt_GetsCoercedToString", func(t *testing.T) { + // Try to set "name" (which should be a string) to an integer + intValue, err := structpb.NewValue(456) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "name", + Value: intValue, + }) + + // YAML will coerce the int to a string, so this succeeds + require.NoError(t, err, "YAML coerces int to string, so this succeeds") + + // When loaded as ProjectConfig, it gets coerced to string "456" + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Equal(t, "456", reloadedConfig.Name, "YAML unmarshals int as string '456'") + }) + + t.Run("SetNameToBool_GetsCoercedToString", func(t *testing.T) { + // Try to set "name" to a boolean + boolValue, err := structpb.NewValue(true) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "name", + Value: boolValue, + }) + + // YAML will coerce bool to string + require.NoError(t, err, "YAML coerces bool to string") + + // When loaded as ProjectConfig, it gets coerced to string "true" + reloadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + require.Equal(t, "true", reloadedConfig.Name, "YAML unmarshals bool as string 'true'") + }) +} + +// Test_ProjectService_EventDispatcherPreservation validates that EventDispatchers +// are preserved across configuration updates for both projects and services. +// This ensures that event handlers registered by azure.yaml hooks and azd extensions +// continue to work after configuration modifications. +func Test_ProjectService_EventDispatcherPreservation(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + // Step 1: Load project using lazy project config + projectConfig := &project.ProjectConfig{ + Name: "test-project", + Services: map[string]*project.ServiceConfig{ + "web": { + Name: "web", + RelativePath: "./src/web", + Host: project.ContainerAppTarget, + Language: project.ServiceLanguageDotNet, + }, + "api": { + Name: "api", + RelativePath: "./src/api", + Host: project.ContainerAppTarget, + Language: project.ServiceLanguagePython, + }, + }, + } + + // Save initial project configuration + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + // Load project config to get proper initialization + loadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + require.NoError(t, err) + + // 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 + // EventDispatchers are already initialized by project.Load() + projectEventCount := atomic.Int32{} + webServiceEventCount := atomic.Int32{} + apiServiceEventCount := atomic.Int32{} + + // Register project-level event handler + err = loadedConfig.AddHandler( + *mockContext.Context, + project.ProjectEventDeploy, + func(ctx context.Context, args project.ProjectLifecycleEventArgs) error { + projectEventCount.Add(1) + return nil + }, + ) + require.NoError(t, err) + + // Register service-level event handlers + err = loadedConfig.Services["web"].AddHandler( + *mockContext.Context, + project.ServiceEventDeploy, + func(ctx context.Context, args project.ServiceLifecycleEventArgs) error { + webServiceEventCount.Add(1) + return nil + }, + ) + require.NoError(t, err) + + err = loadedConfig.Services["api"].AddHandler( + *mockContext.Context, + project.ServiceEventDeploy, + func(ctx context.Context, args project.ServiceLifecycleEventArgs) error { + apiServiceEventCount.Add(1) + return nil + }, + ) + require.NoError(t, err) + + // Create project service + importManager := project.NewImportManager(&project.DotNetImporter{}) + service := NewProjectService(lazyAzdContext, lazyEnvManager, lazyProjectConfig, importManager, nil) + + // Step 3: Modify project configuration + customValue, err := structpb.NewValue("project-custom-value") + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "custom.setting", + Value: customValue, + }) + require.NoError(t, err) + + // Step 4: Modify service configuration (web) + webCustomValue, err := structpb.NewValue("web-custom-value") + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "web", + Path: "custom.endpoint", + Value: webCustomValue, + }) + require.NoError(t, err) + + // Modify service configuration (api) + apiCustomValue, err := structpb.NewValue("api-custom-value") + require.NoError(t, err) + + _, err = service.SetServiceConfigValue(*mockContext.Context, &azdext.SetServiceConfigValueRequest{ + ServiceName: "api", + Path: "custom.port", + Value: apiCustomValue, + }) + require.NoError(t, err) + + // Step 5: Get the updated project config from lazy loader to verify event dispatchers are preserved + updatedConfig, err := lazyProjectConfig.GetValue() + require.NoError(t, err) + + // The project config should be a NEW instance (reloaded from disk) + require.NotSame(t, loadedConfig, updatedConfig, "project config should be a new instance after reload") + + // But the EventDispatchers should be the SAME instances (preserved pointers) + require.Same(t, loadedConfig.EventDispatcher, updatedConfig.EventDispatcher, + "project EventDispatcher should be the same instance (preserved)") + require.Same(t, loadedConfig.Services["web"].EventDispatcher, updatedConfig.Services["web"].EventDispatcher, + "web service EventDispatcher should be the same instance (preserved)") + require.Same(t, loadedConfig.Services["api"].EventDispatcher, updatedConfig.Services["api"].EventDispatcher, + "api service EventDispatcher should be the same instance (preserved)") + + // Verify event dispatchers are not nil + require.NotNil(t, updatedConfig.EventDispatcher, "project EventDispatcher should be preserved") + require.NotNil( + t, + updatedConfig.Services["web"].EventDispatcher, + "web service EventDispatcher should be preserved", + ) + require.NotNil( + t, + updatedConfig.Services["api"].EventDispatcher, + "api service EventDispatcher should be preserved", + ) + + // Step 6: Invoke event handlers on project by raising the event directly + err = updatedConfig.RaiseEvent( + *mockContext.Context, + project.ProjectEventDeploy, + project.ProjectLifecycleEventArgs{ + Project: updatedConfig, + }, + ) + require.NoError(t, err) + + // Step 7: Invoke event handlers on services by raising the events directly + err = updatedConfig.Services["web"].RaiseEvent( + *mockContext.Context, + project.ServiceEventDeploy, + project.ServiceLifecycleEventArgs{ + Project: updatedConfig, + Service: updatedConfig.Services["web"], + }, + ) + require.NoError(t, err) + + err = updatedConfig.Services["api"].RaiseEvent( + *mockContext.Context, + project.ServiceEventDeploy, + project.ServiceLifecycleEventArgs{ + Project: updatedConfig, + Service: updatedConfig.Services["api"], + }, + ) + require.NoError(t, err) + + // Step 8: Validate event handlers were invoked + require.Equal(t, int32(1), projectEventCount.Load(), "project event handler should be invoked once") + require.Equal(t, int32(1), webServiceEventCount.Load(), "web service event handler should be invoked once") + require.Equal(t, int32(1), apiServiceEventCount.Load(), "api service event handler should be invoked once") + + // Additional verification: Ensure configuration changes were persisted + verifyResp, err := service.GetConfigValue(*mockContext.Context, &azdext.GetProjectConfigValueRequest{ + Path: "custom.setting", + }) + require.NoError(t, err) + require.True(t, verifyResp.Found) + require.Equal(t, "project-custom-value", verifyResp.Value.GetStringValue()) + + webVerifyResp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "web", + Path: "custom.endpoint", + }) + require.NoError(t, err) + require.True(t, webVerifyResp.Found) + require.Equal(t, "web-custom-value", webVerifyResp.Value.GetStringValue()) + + apiVerifyResp, err := service.GetServiceConfigValue(*mockContext.Context, &azdext.GetServiceConfigValueRequest{ + ServiceName: "api", + Path: "custom.port", + }) + require.NoError(t, err) + require.True(t, apiVerifyResp.Found) + require.Equal(t, "api-custom-value", apiVerifyResp.Value.GetStringValue()) +} + +// Test_ProjectService_EventDispatcherPreservation_MultipleUpdates tests that event dispatchers +// remain functional after multiple sequential configuration updates. +func Test_ProjectService_EventDispatcherPreservation_MultipleUpdates(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + temp := t.TempDir() + + azdContext := azdcontext.NewAzdContextWithDirectory(temp) + + projectConfig := &project.ProjectConfig{ + Name: "test-project", + Services: map[string]*project.ServiceConfig{ + "web": { + Name: "web", + RelativePath: "./src/web", + Host: project.ContainerAppTarget, + Language: project.ServiceLanguageDotNet, + }, + }, + } + + err := project.Save(*mockContext.Context, projectConfig, azdContext.ProjectPath()) + require.NoError(t, err) + + loadedConfig, err := project.Load(*mockContext.Context, azdContext.ProjectPath()) + 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()) + eventCount := atomic.Int32{} + err = loadedConfig.AddHandler( + *mockContext.Context, + project.ProjectEventDeploy, + func(ctx context.Context, args project.ProjectLifecycleEventArgs) error { + eventCount.Add(1) + return nil + }, + ) + require.NoError(t, err) + + importManager := project.NewImportManager(&project.DotNetImporter{}) + service := NewProjectService(lazyAzdContext, lazyEnvManager, lazyProjectConfig, importManager, nil) + + // Perform multiple configuration updates + for i := 1; i <= 3; i++ { + value, err := structpb.NewValue(i) + require.NoError(t, err) + + _, err = service.SetConfigValue(*mockContext.Context, &azdext.SetProjectConfigValueRequest{ + Path: "custom.counter", + Value: value, + }) + require.NoError(t, err) + } + + // Verify event dispatcher still works after multiple updates + updatedConfig, err := lazyProjectConfig.GetValue() + require.NoError(t, err) + + // The project config should be a NEW instance (reloaded from disk) + require.NotSame(t, loadedConfig, updatedConfig, "project config should be a new instance after reload") + + // But the EventDispatcher should be the SAME instance (preserved pointer) + require.Same(t, loadedConfig.EventDispatcher, updatedConfig.EventDispatcher, + "project EventDispatcher should be the same instance (preserved)") + require.NotNil(t, updatedConfig.EventDispatcher) + + err = updatedConfig.RaiseEvent( + *mockContext.Context, + project.ProjectEventDeploy, + project.ProjectLifecycleEventArgs{Project: updatedConfig}, + ) + require.NoError(t, err) + + require.Equal(t, int32(1), eventCount.Load(), "event handler should be invoked after multiple config updates") +} diff --git a/cli/azd/pkg/azdext/models.pb.go b/cli/azd/pkg/azdext/models.pb.go index 0421097ec66..731e5fd2227 100644 --- a/cli/azd/pkg/azdext/models.pb.go +++ b/cli/azd/pkg/azdext/models.pb.go @@ -677,15 +677,16 @@ func (x *ResourceExtended) GetKind() string { // ProjectConfig message definition type ProjectConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - ResourceGroupName string `protobuf:"bytes,2,opt,name=resource_group_name,json=resourceGroupName,proto3" json:"resource_group_name,omitempty"` - Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` - Metadata *ProjectMetadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` - Services map[string]*ServiceConfig `protobuf:"bytes,5,rep,name=services,proto3" json:"services,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - Infra *InfraOptions `protobuf:"bytes,6,opt,name=infra,proto3" json:"infra,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + ResourceGroupName string `protobuf:"bytes,2,opt,name=resource_group_name,json=resourceGroupName,proto3" json:"resource_group_name,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Metadata *ProjectMetadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` + Services map[string]*ServiceConfig `protobuf:"bytes,5,rep,name=services,proto3" json:"services,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Infra *InfraOptions `protobuf:"bytes,6,opt,name=infra,proto3" json:"infra,omitempty"` + AdditionalProperties *structpb.Struct `protobuf:"bytes,7,opt,name=additional_properties,json=additionalProperties,proto3" json:"additional_properties,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProjectConfig) Reset() { @@ -760,6 +761,13 @@ func (x *ProjectConfig) GetInfra() *InfraOptions { return nil } +func (x *ProjectConfig) GetAdditionalProperties() *structpb.Struct { + if x != nil { + return x.AdditionalProperties + } + return nil +} + // RequiredVersions message definition type RequiredVersions struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -852,20 +860,21 @@ func (x *ProjectMetadata) GetTemplate() string { // ServiceConfig message definition type ServiceConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - ResourceGroupName string `protobuf:"bytes,2,opt,name=resource_group_name,json=resourceGroupName,proto3" json:"resource_group_name,omitempty"` - ResourceName string `protobuf:"bytes,3,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` - ApiVersion string `protobuf:"bytes,4,opt,name=api_version,json=apiVersion,proto3" json:"api_version,omitempty"` - RelativePath string `protobuf:"bytes,5,opt,name=relative_path,json=relativePath,proto3" json:"relative_path,omitempty"` - Host string `protobuf:"bytes,6,opt,name=host,proto3" json:"host,omitempty"` - Language string `protobuf:"bytes,7,opt,name=language,proto3" json:"language,omitempty"` - OutputPath string `protobuf:"bytes,8,opt,name=output_path,json=outputPath,proto3" json:"output_path,omitempty"` - Image string `protobuf:"bytes,9,opt,name=image,proto3" json:"image,omitempty"` - Docker *DockerProjectOptions `protobuf:"bytes,10,opt,name=docker,proto3" json:"docker,omitempty"` - Config *structpb.Struct `protobuf:"bytes,11,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + ResourceGroupName string `protobuf:"bytes,2,opt,name=resource_group_name,json=resourceGroupName,proto3" json:"resource_group_name,omitempty"` + ResourceName string `protobuf:"bytes,3,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` + ApiVersion string `protobuf:"bytes,4,opt,name=api_version,json=apiVersion,proto3" json:"api_version,omitempty"` + RelativePath string `protobuf:"bytes,5,opt,name=relative_path,json=relativePath,proto3" json:"relative_path,omitempty"` + Host string `protobuf:"bytes,6,opt,name=host,proto3" json:"host,omitempty"` + Language string `protobuf:"bytes,7,opt,name=language,proto3" json:"language,omitempty"` + OutputPath string `protobuf:"bytes,8,opt,name=output_path,json=outputPath,proto3" json:"output_path,omitempty"` + Image string `protobuf:"bytes,9,opt,name=image,proto3" json:"image,omitempty"` + Docker *DockerProjectOptions `protobuf:"bytes,10,opt,name=docker,proto3" json:"docker,omitempty"` + 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ServiceConfig) Reset() { @@ -975,6 +984,13 @@ func (x *ServiceConfig) GetConfig() *structpb.Struct { return nil } +func (x *ServiceConfig) GetAdditionalProperties() *structpb.Struct { + if x != nil { + return x.AdditionalProperties + } + return nil +} + // InfraOptions message definition type InfraOptions struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1377,21 +1393,22 @@ const file_models_proto_rawDesc = "" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + "\x04type\x18\x03 \x01(\tR\x04type\x12\x1a\n" + "\blocation\x18\x04 \x01(\tR\blocation\x12\x12\n" + - "\x04kind\x18\x05 \x01(\tR\x04kind\"\xdd\x02\n" + + "\x04kind\x18\x05 \x01(\tR\x04kind\"\xab\x03\n" + "\rProjectConfig\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12.\n" + "\x13resource_group_name\x18\x02 \x01(\tR\x11resourceGroupName\x12\x12\n" + "\x04path\x18\x03 \x01(\tR\x04path\x123\n" + "\bmetadata\x18\x04 \x01(\v2\x17.azdext.ProjectMetadataR\bmetadata\x12?\n" + "\bservices\x18\x05 \x03(\v2#.azdext.ProjectConfig.ServicesEntryR\bservices\x12*\n" + - "\x05infra\x18\x06 \x01(\v2\x14.azdext.InfraOptionsR\x05infra\x1aR\n" + + "\x05infra\x18\x06 \x01(\v2\x14.azdext.InfraOptionsR\x05infra\x12L\n" + + "\x15additional_properties\x18\a \x01(\v2\x17.google.protobuf.StructR\x14additionalProperties\x1aR\n" + "\rServicesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + "\x05value\x18\x02 \x01(\v2\x15.azdext.ServiceConfigR\x05value:\x028\x01\"$\n" + "\x10RequiredVersions\x12\x10\n" + "\x03azd\x18\x01 \x01(\tR\x03azd\"-\n" + "\x0fProjectMetadata\x12\x1a\n" + - "\btemplate\x18\x01 \x01(\tR\btemplate\"\x8c\x03\n" + + "\btemplate\x18\x01 \x01(\tR\btemplate\"\xda\x03\n" + "\rServiceConfig\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12.\n" + "\x13resource_group_name\x18\x02 \x01(\tR\x11resourceGroupName\x12#\n" + @@ -1406,7 +1423,8 @@ const file_models_proto_rawDesc = "" + "\x05image\x18\t \x01(\tR\x05image\x124\n" + "\x06docker\x18\n" + " \x01(\v2\x1c.azdext.DockerProjectOptionsR\x06docker\x12/\n" + - "\x06config\x18\v \x01(\v2\x17.google.protobuf.StructR\x06config\"V\n" + + "\x06config\x18\v \x01(\v2\x17.google.protobuf.StructR\x06config\x12L\n" + + "\x15additional_properties\x18\f \x01(\v2\x17.google.protobuf.StructR\x14additionalProperties\"V\n" + "\fInfraOptions\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x16\n" + @@ -1496,23 +1514,25 @@ var file_models_proto_depIdxs = []int32{ 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 - 16, // 4: azdext.ServiceConfig.docker:type_name -> azdext.DockerProjectOptions - 22, // 5: azdext.ServiceConfig.config:type_name -> google.protobuf.Struct - 19, // 6: azdext.ServiceContext.restore:type_name -> azdext.Artifact - 19, // 7: azdext.ServiceContext.build:type_name -> azdext.Artifact - 19, // 8: azdext.ServiceContext.package:type_name -> azdext.Artifact - 19, // 9: azdext.ServiceContext.publish:type_name -> azdext.Artifact - 19, // 10: azdext.ServiceContext.deploy:type_name -> azdext.Artifact - 19, // 11: azdext.ArtifactList.artifacts:type_name -> azdext.Artifact - 0, // 12: azdext.Artifact.kind:type_name -> azdext.ArtifactKind - 1, // 13: azdext.Artifact.location_kind:type_name -> azdext.LocationKind - 21, // 14: azdext.Artifact.metadata:type_name -> azdext.Artifact.MetadataEntry - 14, // 15: azdext.ProjectConfig.ServicesEntry.value:type_name -> azdext.ServiceConfig - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 22, // 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 } func init() { file_models_proto_init() } diff --git a/cli/azd/pkg/azdext/project.pb.go b/cli/azd/pkg/azdext/project.pb.go index 85373023546..0970f6d714a 100644 --- a/cli/azd/pkg/azdext/project.pb.go +++ b/cli/azd/pkg/azdext/project.pb.go @@ -12,6 +12,7 @@ package azdext import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -205,6 +206,104 @@ func (x *ParseGitHubUrlRequest) GetUrl() string { return "" } +// Request message for GetConfigSection +type GetProjectConfigSectionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProjectConfigSectionRequest) Reset() { + *x = GetProjectConfigSectionRequest{} + mi := &file_project_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProjectConfigSectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProjectConfigSectionRequest) ProtoMessage() {} + +func (x *GetProjectConfigSectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProjectConfigSectionRequest.ProtoReflect.Descriptor instead. +func (*GetProjectConfigSectionRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{4} +} + +func (x *GetProjectConfigSectionRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Response message for GetConfigSection +type GetProjectConfigSectionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Section *structpb.Struct `protobuf:"bytes,1,opt,name=section,proto3" json:"section,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProjectConfigSectionResponse) Reset() { + *x = GetProjectConfigSectionResponse{} + mi := &file_project_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProjectConfigSectionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProjectConfigSectionResponse) ProtoMessage() {} + +func (x *GetProjectConfigSectionResponse) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProjectConfigSectionResponse.ProtoReflect.Descriptor instead. +func (*GetProjectConfigSectionResponse) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{5} +} + +func (x *GetProjectConfigSectionResponse) GetSection() *structpb.Struct { + if x != nil { + return x.Section + } + return nil +} + +func (x *GetProjectConfigSectionResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + // ParseGitHubUrlResponse message definition type ParseGitHubUrlResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -218,7 +317,7 @@ type ParseGitHubUrlResponse struct { func (x *ParseGitHubUrlResponse) Reset() { *x = ParseGitHubUrlResponse{} - mi := &file_project_proto_msgTypes[4] + mi := &file_project_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -230,7 +329,7 @@ func (x *ParseGitHubUrlResponse) String() string { func (*ParseGitHubUrlResponse) ProtoMessage() {} func (x *ParseGitHubUrlResponse) ProtoReflect() protoreflect.Message { - mi := &file_project_proto_msgTypes[4] + mi := &file_project_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -243,7 +342,7 @@ func (x *ParseGitHubUrlResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ParseGitHubUrlResponse.ProtoReflect.Descriptor instead. func (*ParseGitHubUrlResponse) Descriptor() ([]byte, []int) { - return file_project_proto_rawDescGZIP(), []int{4} + return file_project_proto_rawDescGZIP(), []int{6} } func (x *ParseGitHubUrlResponse) GetHostname() string { @@ -274,11 +373,647 @@ func (x *ParseGitHubUrlResponse) GetFilePath() string { return "" } +// Request message for GetConfigValue +type GetProjectConfigValueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProjectConfigValueRequest) Reset() { + *x = GetProjectConfigValueRequest{} + mi := &file_project_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProjectConfigValueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProjectConfigValueRequest) ProtoMessage() {} + +func (x *GetProjectConfigValueRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProjectConfigValueRequest.ProtoReflect.Descriptor instead. +func (*GetProjectConfigValueRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{7} +} + +func (x *GetProjectConfigValueRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Response message for GetConfigValue +type GetProjectConfigValueResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value *structpb.Value `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProjectConfigValueResponse) Reset() { + *x = GetProjectConfigValueResponse{} + mi := &file_project_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProjectConfigValueResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProjectConfigValueResponse) ProtoMessage() {} + +func (x *GetProjectConfigValueResponse) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProjectConfigValueResponse.ProtoReflect.Descriptor instead. +func (*GetProjectConfigValueResponse) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{8} +} + +func (x *GetProjectConfigValueResponse) GetValue() *structpb.Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *GetProjectConfigValueResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +// Request message for SetConfigSection +type SetProjectConfigSectionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Section *structpb.Struct `protobuf:"bytes,2,opt,name=section,proto3" json:"section,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetProjectConfigSectionRequest) Reset() { + *x = SetProjectConfigSectionRequest{} + mi := &file_project_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetProjectConfigSectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetProjectConfigSectionRequest) ProtoMessage() {} + +func (x *SetProjectConfigSectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetProjectConfigSectionRequest.ProtoReflect.Descriptor instead. +func (*SetProjectConfigSectionRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{9} +} + +func (x *SetProjectConfigSectionRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *SetProjectConfigSectionRequest) GetSection() *structpb.Struct { + if x != nil { + return x.Section + } + return nil +} + +// Request message for SetConfigValue +type SetProjectConfigValueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Value *structpb.Value `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetProjectConfigValueRequest) Reset() { + *x = SetProjectConfigValueRequest{} + mi := &file_project_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetProjectConfigValueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetProjectConfigValueRequest) ProtoMessage() {} + +func (x *SetProjectConfigValueRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetProjectConfigValueRequest.ProtoReflect.Descriptor instead. +func (*SetProjectConfigValueRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{10} +} + +func (x *SetProjectConfigValueRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *SetProjectConfigValueRequest) GetValue() *structpb.Value { + if x != nil { + return x.Value + } + return nil +} + +// Request message for UnsetConfig +type UnsetProjectConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnsetProjectConfigRequest) Reset() { + *x = UnsetProjectConfigRequest{} + mi := &file_project_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnsetProjectConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnsetProjectConfigRequest) ProtoMessage() {} + +func (x *UnsetProjectConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnsetProjectConfigRequest.ProtoReflect.Descriptor instead. +func (*UnsetProjectConfigRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{11} +} + +func (x *UnsetProjectConfigRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Request message for GetServiceConfigSection +type GetServiceConfigSectionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceConfigSectionRequest) Reset() { + *x = GetServiceConfigSectionRequest{} + mi := &file_project_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServiceConfigSectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceConfigSectionRequest) ProtoMessage() {} + +func (x *GetServiceConfigSectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServiceConfigSectionRequest.ProtoReflect.Descriptor instead. +func (*GetServiceConfigSectionRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{12} +} + +func (x *GetServiceConfigSectionRequest) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *GetServiceConfigSectionRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Response message for GetServiceConfigSection +type GetServiceConfigSectionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Section *structpb.Struct `protobuf:"bytes,1,opt,name=section,proto3" json:"section,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceConfigSectionResponse) Reset() { + *x = GetServiceConfigSectionResponse{} + mi := &file_project_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServiceConfigSectionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceConfigSectionResponse) ProtoMessage() {} + +func (x *GetServiceConfigSectionResponse) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServiceConfigSectionResponse.ProtoReflect.Descriptor instead. +func (*GetServiceConfigSectionResponse) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{13} +} + +func (x *GetServiceConfigSectionResponse) GetSection() *structpb.Struct { + if x != nil { + return x.Section + } + return nil +} + +func (x *GetServiceConfigSectionResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +// Request message for GetServiceConfigValue +type GetServiceConfigValueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceConfigValueRequest) Reset() { + *x = GetServiceConfigValueRequest{} + mi := &file_project_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServiceConfigValueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceConfigValueRequest) ProtoMessage() {} + +func (x *GetServiceConfigValueRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServiceConfigValueRequest.ProtoReflect.Descriptor instead. +func (*GetServiceConfigValueRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{14} +} + +func (x *GetServiceConfigValueRequest) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *GetServiceConfigValueRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Response message for GetServiceConfigValue +type GetServiceConfigValueResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value *structpb.Value `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceConfigValueResponse) Reset() { + *x = GetServiceConfigValueResponse{} + mi := &file_project_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServiceConfigValueResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceConfigValueResponse) ProtoMessage() {} + +func (x *GetServiceConfigValueResponse) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServiceConfigValueResponse.ProtoReflect.Descriptor instead. +func (*GetServiceConfigValueResponse) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{15} +} + +func (x *GetServiceConfigValueResponse) GetValue() *structpb.Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *GetServiceConfigValueResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +// Request message for SetServiceConfigSection +type SetServiceConfigSectionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Section *structpb.Struct `protobuf:"bytes,3,opt,name=section,proto3" json:"section,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetServiceConfigSectionRequest) Reset() { + *x = SetServiceConfigSectionRequest{} + mi := &file_project_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetServiceConfigSectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetServiceConfigSectionRequest) ProtoMessage() {} + +func (x *SetServiceConfigSectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetServiceConfigSectionRequest.ProtoReflect.Descriptor instead. +func (*SetServiceConfigSectionRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{16} +} + +func (x *SetServiceConfigSectionRequest) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *SetServiceConfigSectionRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *SetServiceConfigSectionRequest) GetSection() *structpb.Struct { + if x != nil { + return x.Section + } + return nil +} + +// Request message for SetServiceConfigValue +type SetServiceConfigValueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Value *structpb.Value `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetServiceConfigValueRequest) Reset() { + *x = SetServiceConfigValueRequest{} + mi := &file_project_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetServiceConfigValueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetServiceConfigValueRequest) ProtoMessage() {} + +func (x *SetServiceConfigValueRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetServiceConfigValueRequest.ProtoReflect.Descriptor instead. +func (*SetServiceConfigValueRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{17} +} + +func (x *SetServiceConfigValueRequest) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *SetServiceConfigValueRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *SetServiceConfigValueRequest) GetValue() *structpb.Value { + if x != nil { + return x.Value + } + return nil +} + +// Request message for UnsetServiceConfig +type UnsetServiceConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnsetServiceConfigRequest) Reset() { + *x = UnsetServiceConfigRequest{} + mi := &file_project_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnsetServiceConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnsetServiceConfigRequest) ProtoMessage() {} + +func (x *UnsetServiceConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_project_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnsetServiceConfigRequest.ProtoReflect.Descriptor instead. +func (*UnsetServiceConfigRequest) Descriptor() ([]byte, []int) { + return file_project_proto_rawDescGZIP(), []int{18} +} + +func (x *UnsetServiceConfigRequest) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *UnsetServiceConfigRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + var File_project_proto protoreflect.FileDescriptor const file_project_proto_rawDesc = "" + "\n" + - "\rproject.proto\x12\x06azdext\x1a\fmodels.proto\"E\n" + + "\rproject.proto\x12\x06azdext\x1a\fmodels.proto\x1a$include/google/protobuf/struct.proto\"E\n" + "\x12GetProjectResponse\x12/\n" + "\aproject\x18\x01 \x01(\v2\x15.azdext.ProjectConfigR\aproject\"D\n" + "\x11AddServiceRequest\x12/\n" + @@ -289,18 +1024,69 @@ const file_project_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + "\x05value\x18\x02 \x01(\v2\x15.azdext.ServiceConfigR\x05value:\x028\x01\")\n" + "\x15ParseGitHubUrlRequest\x12\x10\n" + - "\x03url\x18\x01 \x01(\tR\x03url\"\x86\x01\n" + + "\x03url\x18\x01 \x01(\tR\x03url\"4\n" + + "\x1eGetProjectConfigSectionRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"j\n" + + "\x1fGetProjectConfigSectionResponse\x121\n" + + "\asection\x18\x01 \x01(\v2\x17.google.protobuf.StructR\asection\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found\"\x86\x01\n" + "\x16ParseGitHubUrlResponse\x12\x1a\n" + "\bhostname\x18\x01 \x01(\tR\bhostname\x12\x1b\n" + "\trepo_slug\x18\x02 \x01(\tR\brepoSlug\x12\x16\n" + "\x06branch\x18\x03 \x01(\tR\x06branch\x12\x1b\n" + - "\tfile_path\x18\x04 \x01(\tR\bfilePath2\xac\x02\n" + + "\tfile_path\x18\x04 \x01(\tR\bfilePath\"2\n" + + "\x1cGetProjectConfigValueRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"c\n" + + "\x1dGetProjectConfigValueResponse\x12,\n" + + "\x05value\x18\x01 \x01(\v2\x16.google.protobuf.ValueR\x05value\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found\"g\n" + + "\x1eSetProjectConfigSectionRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x121\n" + + "\asection\x18\x02 \x01(\v2\x17.google.protobuf.StructR\asection\"`\n" + + "\x1cSetProjectConfigValueRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12,\n" + + "\x05value\x18\x02 \x01(\v2\x16.google.protobuf.ValueR\x05value\"/\n" + + "\x19UnsetProjectConfigRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"W\n" + + "\x1eGetServiceConfigSectionRequest\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"j\n" + + "\x1fGetServiceConfigSectionResponse\x121\n" + + "\asection\x18\x01 \x01(\v2\x17.google.protobuf.StructR\asection\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found\"U\n" + + "\x1cGetServiceConfigValueRequest\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"c\n" + + "\x1dGetServiceConfigValueResponse\x12,\n" + + "\x05value\x18\x01 \x01(\v2\x16.google.protobuf.ValueR\x05value\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found\"\x8a\x01\n" + + "\x1eSetServiceConfigSectionRequest\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x121\n" + + "\asection\x18\x03 \x01(\v2\x17.google.protobuf.StructR\asection\"\x83\x01\n" + + "\x1cSetServiceConfigValueRequest\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12,\n" + + "\x05value\x18\x03 \x01(\v2\x16.google.protobuf.ValueR\x05value\"R\n" + + "\x19UnsetServiceConfigRequest\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path2\xad\t\n" + "\x0eProjectService\x127\n" + "\x03Get\x12\x14.azdext.EmptyRequest\x1a\x1a.azdext.GetProjectResponse\x12>\n" + "\n" + "AddService\x12\x19.azdext.AddServiceRequest\x1a\x15.azdext.EmptyResponse\x12P\n" + "\x13GetResolvedServices\x12\x14.azdext.EmptyRequest\x1a#.azdext.GetResolvedServicesResponse\x12O\n" + - "\x0eParseGitHubUrl\x12\x1d.azdext.ParseGitHubUrlRequest\x1a\x1e.azdext.ParseGitHubUrlResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + "\x0eParseGitHubUrl\x12\x1d.azdext.ParseGitHubUrlRequest\x1a\x1e.azdext.ParseGitHubUrlResponse\x12c\n" + + "\x10GetConfigSection\x12&.azdext.GetProjectConfigSectionRequest\x1a'.azdext.GetProjectConfigSectionResponse\x12]\n" + + "\x0eGetConfigValue\x12$.azdext.GetProjectConfigValueRequest\x1a%.azdext.GetProjectConfigValueResponse\x12Q\n" + + "\x10SetConfigSection\x12&.azdext.SetProjectConfigSectionRequest\x1a\x15.azdext.EmptyResponse\x12M\n" + + "\x0eSetConfigValue\x12$.azdext.SetProjectConfigValueRequest\x1a\x15.azdext.EmptyResponse\x12G\n" + + "\vUnsetConfig\x12!.azdext.UnsetProjectConfigRequest\x1a\x15.azdext.EmptyResponse\x12j\n" + + "\x17GetServiceConfigSection\x12&.azdext.GetServiceConfigSectionRequest\x1a'.azdext.GetServiceConfigSectionResponse\x12d\n" + + "\x15GetServiceConfigValue\x12$.azdext.GetServiceConfigValueRequest\x1a%.azdext.GetServiceConfigValueResponse\x12X\n" + + "\x17SetServiceConfigSection\x12&.azdext.SetServiceConfigSectionRequest\x1a\x15.azdext.EmptyResponse\x12T\n" + + "\x15SetServiceConfigValue\x12$.azdext.SetServiceConfigValueRequest\x1a\x15.azdext.EmptyResponse\x12N\n" + + "\x12UnsetServiceConfig\x12!.azdext.UnsetServiceConfigRequest\x1a\x15.azdext.EmptyResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" var ( file_project_proto_rawDescOnce sync.Once @@ -314,37 +1100,81 @@ func file_project_proto_rawDescGZIP() []byte { return file_project_proto_rawDescData } -var file_project_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_project_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_project_proto_goTypes = []any{ - (*GetProjectResponse)(nil), // 0: azdext.GetProjectResponse - (*AddServiceRequest)(nil), // 1: azdext.AddServiceRequest - (*GetResolvedServicesResponse)(nil), // 2: azdext.GetResolvedServicesResponse - (*ParseGitHubUrlRequest)(nil), // 3: azdext.ParseGitHubUrlRequest - (*ParseGitHubUrlResponse)(nil), // 4: azdext.ParseGitHubUrlResponse - nil, // 5: azdext.GetResolvedServicesResponse.ServicesEntry - (*ProjectConfig)(nil), // 6: azdext.ProjectConfig - (*ServiceConfig)(nil), // 7: azdext.ServiceConfig - (*EmptyRequest)(nil), // 8: azdext.EmptyRequest - (*EmptyResponse)(nil), // 9: azdext.EmptyResponse + (*GetProjectResponse)(nil), // 0: azdext.GetProjectResponse + (*AddServiceRequest)(nil), // 1: azdext.AddServiceRequest + (*GetResolvedServicesResponse)(nil), // 2: azdext.GetResolvedServicesResponse + (*ParseGitHubUrlRequest)(nil), // 3: azdext.ParseGitHubUrlRequest + (*GetProjectConfigSectionRequest)(nil), // 4: azdext.GetProjectConfigSectionRequest + (*GetProjectConfigSectionResponse)(nil), // 5: azdext.GetProjectConfigSectionResponse + (*ParseGitHubUrlResponse)(nil), // 6: azdext.ParseGitHubUrlResponse + (*GetProjectConfigValueRequest)(nil), // 7: azdext.GetProjectConfigValueRequest + (*GetProjectConfigValueResponse)(nil), // 8: azdext.GetProjectConfigValueResponse + (*SetProjectConfigSectionRequest)(nil), // 9: azdext.SetProjectConfigSectionRequest + (*SetProjectConfigValueRequest)(nil), // 10: azdext.SetProjectConfigValueRequest + (*UnsetProjectConfigRequest)(nil), // 11: azdext.UnsetProjectConfigRequest + (*GetServiceConfigSectionRequest)(nil), // 12: azdext.GetServiceConfigSectionRequest + (*GetServiceConfigSectionResponse)(nil), // 13: azdext.GetServiceConfigSectionResponse + (*GetServiceConfigValueRequest)(nil), // 14: azdext.GetServiceConfigValueRequest + (*GetServiceConfigValueResponse)(nil), // 15: azdext.GetServiceConfigValueResponse + (*SetServiceConfigSectionRequest)(nil), // 16: azdext.SetServiceConfigSectionRequest + (*SetServiceConfigValueRequest)(nil), // 17: azdext.SetServiceConfigValueRequest + (*UnsetServiceConfigRequest)(nil), // 18: azdext.UnsetServiceConfigRequest + nil, // 19: azdext.GetResolvedServicesResponse.ServicesEntry + (*ProjectConfig)(nil), // 20: azdext.ProjectConfig + (*ServiceConfig)(nil), // 21: azdext.ServiceConfig + (*structpb.Struct)(nil), // 22: google.protobuf.Struct + (*structpb.Value)(nil), // 23: google.protobuf.Value + (*EmptyRequest)(nil), // 24: azdext.EmptyRequest + (*EmptyResponse)(nil), // 25: azdext.EmptyResponse } var file_project_proto_depIdxs = []int32{ - 6, // 0: azdext.GetProjectResponse.project:type_name -> azdext.ProjectConfig - 7, // 1: azdext.AddServiceRequest.service:type_name -> azdext.ServiceConfig - 5, // 2: azdext.GetResolvedServicesResponse.services:type_name -> azdext.GetResolvedServicesResponse.ServicesEntry - 7, // 3: azdext.GetResolvedServicesResponse.ServicesEntry.value:type_name -> azdext.ServiceConfig - 8, // 4: azdext.ProjectService.Get:input_type -> azdext.EmptyRequest - 1, // 5: azdext.ProjectService.AddService:input_type -> azdext.AddServiceRequest - 8, // 6: azdext.ProjectService.GetResolvedServices:input_type -> azdext.EmptyRequest - 3, // 7: azdext.ProjectService.ParseGitHubUrl:input_type -> azdext.ParseGitHubUrlRequest - 0, // 8: azdext.ProjectService.Get:output_type -> azdext.GetProjectResponse - 9, // 9: azdext.ProjectService.AddService:output_type -> azdext.EmptyResponse - 2, // 10: azdext.ProjectService.GetResolvedServices:output_type -> azdext.GetResolvedServicesResponse - 4, // 11: azdext.ProjectService.ParseGitHubUrl:output_type -> azdext.ParseGitHubUrlResponse - 8, // [8:12] is the sub-list for method output_type - 4, // [4:8] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 20, // 0: azdext.GetProjectResponse.project:type_name -> azdext.ProjectConfig + 21, // 1: azdext.AddServiceRequest.service:type_name -> azdext.ServiceConfig + 19, // 2: azdext.GetResolvedServicesResponse.services:type_name -> azdext.GetResolvedServicesResponse.ServicesEntry + 22, // 3: azdext.GetProjectConfigSectionResponse.section:type_name -> google.protobuf.Struct + 23, // 4: azdext.GetProjectConfigValueResponse.value:type_name -> google.protobuf.Value + 22, // 5: azdext.SetProjectConfigSectionRequest.section:type_name -> google.protobuf.Struct + 23, // 6: azdext.SetProjectConfigValueRequest.value:type_name -> google.protobuf.Value + 22, // 7: azdext.GetServiceConfigSectionResponse.section:type_name -> google.protobuf.Struct + 23, // 8: azdext.GetServiceConfigValueResponse.value:type_name -> google.protobuf.Value + 22, // 9: azdext.SetServiceConfigSectionRequest.section:type_name -> google.protobuf.Struct + 23, // 10: azdext.SetServiceConfigValueRequest.value:type_name -> google.protobuf.Value + 21, // 11: azdext.GetResolvedServicesResponse.ServicesEntry.value:type_name -> azdext.ServiceConfig + 24, // 12: azdext.ProjectService.Get:input_type -> azdext.EmptyRequest + 1, // 13: azdext.ProjectService.AddService:input_type -> azdext.AddServiceRequest + 24, // 14: azdext.ProjectService.GetResolvedServices:input_type -> azdext.EmptyRequest + 3, // 15: azdext.ProjectService.ParseGitHubUrl:input_type -> azdext.ParseGitHubUrlRequest + 4, // 16: azdext.ProjectService.GetConfigSection:input_type -> azdext.GetProjectConfigSectionRequest + 7, // 17: azdext.ProjectService.GetConfigValue:input_type -> azdext.GetProjectConfigValueRequest + 9, // 18: azdext.ProjectService.SetConfigSection:input_type -> azdext.SetProjectConfigSectionRequest + 10, // 19: azdext.ProjectService.SetConfigValue:input_type -> azdext.SetProjectConfigValueRequest + 11, // 20: azdext.ProjectService.UnsetConfig:input_type -> azdext.UnsetProjectConfigRequest + 12, // 21: azdext.ProjectService.GetServiceConfigSection:input_type -> azdext.GetServiceConfigSectionRequest + 14, // 22: azdext.ProjectService.GetServiceConfigValue:input_type -> azdext.GetServiceConfigValueRequest + 16, // 23: azdext.ProjectService.SetServiceConfigSection:input_type -> azdext.SetServiceConfigSectionRequest + 17, // 24: azdext.ProjectService.SetServiceConfigValue:input_type -> azdext.SetServiceConfigValueRequest + 18, // 25: azdext.ProjectService.UnsetServiceConfig:input_type -> azdext.UnsetServiceConfigRequest + 0, // 26: azdext.ProjectService.Get:output_type -> azdext.GetProjectResponse + 25, // 27: azdext.ProjectService.AddService:output_type -> azdext.EmptyResponse + 2, // 28: azdext.ProjectService.GetResolvedServices:output_type -> azdext.GetResolvedServicesResponse + 6, // 29: azdext.ProjectService.ParseGitHubUrl:output_type -> azdext.ParseGitHubUrlResponse + 5, // 30: azdext.ProjectService.GetConfigSection:output_type -> azdext.GetProjectConfigSectionResponse + 8, // 31: azdext.ProjectService.GetConfigValue:output_type -> azdext.GetProjectConfigValueResponse + 25, // 32: azdext.ProjectService.SetConfigSection:output_type -> azdext.EmptyResponse + 25, // 33: azdext.ProjectService.SetConfigValue:output_type -> azdext.EmptyResponse + 25, // 34: azdext.ProjectService.UnsetConfig:output_type -> azdext.EmptyResponse + 13, // 35: azdext.ProjectService.GetServiceConfigSection:output_type -> azdext.GetServiceConfigSectionResponse + 15, // 36: azdext.ProjectService.GetServiceConfigValue:output_type -> azdext.GetServiceConfigValueResponse + 25, // 37: azdext.ProjectService.SetServiceConfigSection:output_type -> azdext.EmptyResponse + 25, // 38: azdext.ProjectService.SetServiceConfigValue:output_type -> azdext.EmptyResponse + 25, // 39: azdext.ProjectService.UnsetServiceConfig:output_type -> azdext.EmptyResponse + 26, // [26:40] is the sub-list for method output_type + 12, // [12:26] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_project_proto_init() } @@ -359,7 +1189,7 @@ func file_project_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_project_proto_rawDesc), len(file_project_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 20, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/project_grpc.pb.go b/cli/azd/pkg/azdext/project_grpc.pb.go index 81d4d63a8a0..2eb9b07475b 100644 --- a/cli/azd/pkg/azdext/project_grpc.pb.go +++ b/cli/azd/pkg/azdext/project_grpc.pb.go @@ -22,10 +22,20 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - ProjectService_Get_FullMethodName = "/azdext.ProjectService/Get" - ProjectService_AddService_FullMethodName = "/azdext.ProjectService/AddService" - ProjectService_GetResolvedServices_FullMethodName = "/azdext.ProjectService/GetResolvedServices" - ProjectService_ParseGitHubUrl_FullMethodName = "/azdext.ProjectService/ParseGitHubUrl" + ProjectService_Get_FullMethodName = "/azdext.ProjectService/Get" + ProjectService_AddService_FullMethodName = "/azdext.ProjectService/AddService" + ProjectService_GetResolvedServices_FullMethodName = "/azdext.ProjectService/GetResolvedServices" + ProjectService_ParseGitHubUrl_FullMethodName = "/azdext.ProjectService/ParseGitHubUrl" + ProjectService_GetConfigSection_FullMethodName = "/azdext.ProjectService/GetConfigSection" + ProjectService_GetConfigValue_FullMethodName = "/azdext.ProjectService/GetConfigValue" + ProjectService_SetConfigSection_FullMethodName = "/azdext.ProjectService/SetConfigSection" + ProjectService_SetConfigValue_FullMethodName = "/azdext.ProjectService/SetConfigValue" + ProjectService_UnsetConfig_FullMethodName = "/azdext.ProjectService/UnsetConfig" + ProjectService_GetServiceConfigSection_FullMethodName = "/azdext.ProjectService/GetServiceConfigSection" + ProjectService_GetServiceConfigValue_FullMethodName = "/azdext.ProjectService/GetServiceConfigValue" + ProjectService_SetServiceConfigSection_FullMethodName = "/azdext.ProjectService/SetServiceConfigSection" + ProjectService_SetServiceConfigValue_FullMethodName = "/azdext.ProjectService/SetServiceConfigValue" + ProjectService_UnsetServiceConfig_FullMethodName = "/azdext.ProjectService/UnsetServiceConfig" ) // ProjectServiceClient is the client API for ProjectService service. @@ -43,6 +53,26 @@ type ProjectServiceClient interface { GetResolvedServices(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*GetResolvedServicesResponse, error) // ParseGitHubUrl parses a GitHub URL and extracts repository information. ParseGitHubUrl(ctx context.Context, in *ParseGitHubUrlRequest, opts ...grpc.CallOption) (*ParseGitHubUrlResponse, error) + // Gets a configuration section by path. + GetConfigSection(ctx context.Context, in *GetProjectConfigSectionRequest, opts ...grpc.CallOption) (*GetProjectConfigSectionResponse, error) + // Gets a configuration value by path. + GetConfigValue(ctx context.Context, in *GetProjectConfigValueRequest, opts ...grpc.CallOption) (*GetProjectConfigValueResponse, error) + // Sets a configuration section by path. + SetConfigSection(ctx context.Context, in *SetProjectConfigSectionRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // Sets a configuration value by path. + SetConfigValue(ctx context.Context, in *SetProjectConfigValueRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // Removes configuration by path. + UnsetConfig(ctx context.Context, in *UnsetProjectConfigRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // Gets a service configuration section by path. + GetServiceConfigSection(ctx context.Context, in *GetServiceConfigSectionRequest, opts ...grpc.CallOption) (*GetServiceConfigSectionResponse, error) + // Gets a service configuration value by path. + GetServiceConfigValue(ctx context.Context, in *GetServiceConfigValueRequest, opts ...grpc.CallOption) (*GetServiceConfigValueResponse, error) + // Sets a service configuration section by path. + SetServiceConfigSection(ctx context.Context, in *SetServiceConfigSectionRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // Sets a service configuration value by path. + SetServiceConfigValue(ctx context.Context, in *SetServiceConfigValueRequest, opts ...grpc.CallOption) (*EmptyResponse, error) + // Removes service configuration by path. + UnsetServiceConfig(ctx context.Context, in *UnsetServiceConfigRequest, opts ...grpc.CallOption) (*EmptyResponse, error) } type projectServiceClient struct { @@ -93,6 +123,106 @@ func (c *projectServiceClient) ParseGitHubUrl(ctx context.Context, in *ParseGitH return out, nil } +func (c *projectServiceClient) GetConfigSection(ctx context.Context, in *GetProjectConfigSectionRequest, opts ...grpc.CallOption) (*GetProjectConfigSectionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetProjectConfigSectionResponse) + err := c.cc.Invoke(ctx, ProjectService_GetConfigSection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) GetConfigValue(ctx context.Context, in *GetProjectConfigValueRequest, opts ...grpc.CallOption) (*GetProjectConfigValueResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetProjectConfigValueResponse) + err := c.cc.Invoke(ctx, ProjectService_GetConfigValue_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) SetConfigSection(ctx context.Context, in *SetProjectConfigSectionRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, ProjectService_SetConfigSection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) SetConfigValue(ctx context.Context, in *SetProjectConfigValueRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, ProjectService_SetConfigValue_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) UnsetConfig(ctx context.Context, in *UnsetProjectConfigRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, ProjectService_UnsetConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) GetServiceConfigSection(ctx context.Context, in *GetServiceConfigSectionRequest, opts ...grpc.CallOption) (*GetServiceConfigSectionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetServiceConfigSectionResponse) + err := c.cc.Invoke(ctx, ProjectService_GetServiceConfigSection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) GetServiceConfigValue(ctx context.Context, in *GetServiceConfigValueRequest, opts ...grpc.CallOption) (*GetServiceConfigValueResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetServiceConfigValueResponse) + err := c.cc.Invoke(ctx, ProjectService_GetServiceConfigValue_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) SetServiceConfigSection(ctx context.Context, in *SetServiceConfigSectionRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, ProjectService_SetServiceConfigSection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) SetServiceConfigValue(ctx context.Context, in *SetServiceConfigValueRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, ProjectService_SetServiceConfigValue_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *projectServiceClient) UnsetServiceConfig(ctx context.Context, in *UnsetServiceConfigRequest, opts ...grpc.CallOption) (*EmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmptyResponse) + err := c.cc.Invoke(ctx, ProjectService_UnsetServiceConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ProjectServiceServer is the server API for ProjectService service. // All implementations must embed UnimplementedProjectServiceServer // for forward compatibility. @@ -108,6 +238,26 @@ type ProjectServiceServer interface { GetResolvedServices(context.Context, *EmptyRequest) (*GetResolvedServicesResponse, error) // ParseGitHubUrl parses a GitHub URL and extracts repository information. ParseGitHubUrl(context.Context, *ParseGitHubUrlRequest) (*ParseGitHubUrlResponse, error) + // Gets a configuration section by path. + GetConfigSection(context.Context, *GetProjectConfigSectionRequest) (*GetProjectConfigSectionResponse, error) + // Gets a configuration value by path. + GetConfigValue(context.Context, *GetProjectConfigValueRequest) (*GetProjectConfigValueResponse, error) + // Sets a configuration section by path. + SetConfigSection(context.Context, *SetProjectConfigSectionRequest) (*EmptyResponse, error) + // Sets a configuration value by path. + SetConfigValue(context.Context, *SetProjectConfigValueRequest) (*EmptyResponse, error) + // Removes configuration by path. + UnsetConfig(context.Context, *UnsetProjectConfigRequest) (*EmptyResponse, error) + // Gets a service configuration section by path. + GetServiceConfigSection(context.Context, *GetServiceConfigSectionRequest) (*GetServiceConfigSectionResponse, error) + // Gets a service configuration value by path. + GetServiceConfigValue(context.Context, *GetServiceConfigValueRequest) (*GetServiceConfigValueResponse, error) + // Sets a service configuration section by path. + SetServiceConfigSection(context.Context, *SetServiceConfigSectionRequest) (*EmptyResponse, error) + // Sets a service configuration value by path. + SetServiceConfigValue(context.Context, *SetServiceConfigValueRequest) (*EmptyResponse, error) + // Removes service configuration by path. + UnsetServiceConfig(context.Context, *UnsetServiceConfigRequest) (*EmptyResponse, error) mustEmbedUnimplementedProjectServiceServer() } @@ -130,6 +280,36 @@ func (UnimplementedProjectServiceServer) GetResolvedServices(context.Context, *E func (UnimplementedProjectServiceServer) ParseGitHubUrl(context.Context, *ParseGitHubUrlRequest) (*ParseGitHubUrlResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ParseGitHubUrl not implemented") } +func (UnimplementedProjectServiceServer) GetConfigSection(context.Context, *GetProjectConfigSectionRequest) (*GetProjectConfigSectionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetConfigSection not implemented") +} +func (UnimplementedProjectServiceServer) GetConfigValue(context.Context, *GetProjectConfigValueRequest) (*GetProjectConfigValueResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetConfigValue not implemented") +} +func (UnimplementedProjectServiceServer) SetConfigSection(context.Context, *SetProjectConfigSectionRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetConfigSection not implemented") +} +func (UnimplementedProjectServiceServer) SetConfigValue(context.Context, *SetProjectConfigValueRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetConfigValue not implemented") +} +func (UnimplementedProjectServiceServer) UnsetConfig(context.Context, *UnsetProjectConfigRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnsetConfig not implemented") +} +func (UnimplementedProjectServiceServer) GetServiceConfigSection(context.Context, *GetServiceConfigSectionRequest) (*GetServiceConfigSectionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetServiceConfigSection not implemented") +} +func (UnimplementedProjectServiceServer) GetServiceConfigValue(context.Context, *GetServiceConfigValueRequest) (*GetServiceConfigValueResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetServiceConfigValue not implemented") +} +func (UnimplementedProjectServiceServer) SetServiceConfigSection(context.Context, *SetServiceConfigSectionRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetServiceConfigSection not implemented") +} +func (UnimplementedProjectServiceServer) SetServiceConfigValue(context.Context, *SetServiceConfigValueRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetServiceConfigValue not implemented") +} +func (UnimplementedProjectServiceServer) UnsetServiceConfig(context.Context, *UnsetServiceConfigRequest) (*EmptyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnsetServiceConfig not implemented") +} func (UnimplementedProjectServiceServer) mustEmbedUnimplementedProjectServiceServer() {} func (UnimplementedProjectServiceServer) testEmbeddedByValue() {} @@ -223,6 +403,186 @@ func _ProjectService_ParseGitHubUrl_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } +func _ProjectService_GetConfigSection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProjectConfigSectionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).GetConfigSection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_GetConfigSection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).GetConfigSection(ctx, req.(*GetProjectConfigSectionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_GetConfigValue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProjectConfigValueRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).GetConfigValue(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_GetConfigValue_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).GetConfigValue(ctx, req.(*GetProjectConfigValueRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_SetConfigSection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetProjectConfigSectionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).SetConfigSection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_SetConfigSection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).SetConfigSection(ctx, req.(*SetProjectConfigSectionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_SetConfigValue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetProjectConfigValueRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).SetConfigValue(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_SetConfigValue_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).SetConfigValue(ctx, req.(*SetProjectConfigValueRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_UnsetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnsetProjectConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).UnsetConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_UnsetConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).UnsetConfig(ctx, req.(*UnsetProjectConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_GetServiceConfigSection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetServiceConfigSectionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).GetServiceConfigSection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_GetServiceConfigSection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).GetServiceConfigSection(ctx, req.(*GetServiceConfigSectionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_GetServiceConfigValue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetServiceConfigValueRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).GetServiceConfigValue(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_GetServiceConfigValue_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).GetServiceConfigValue(ctx, req.(*GetServiceConfigValueRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_SetServiceConfigSection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetServiceConfigSectionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).SetServiceConfigSection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_SetServiceConfigSection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).SetServiceConfigSection(ctx, req.(*SetServiceConfigSectionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_SetServiceConfigValue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetServiceConfigValueRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).SetServiceConfigValue(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_SetServiceConfigValue_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).SetServiceConfigValue(ctx, req.(*SetServiceConfigValueRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProjectService_UnsetServiceConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnsetServiceConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProjectServiceServer).UnsetServiceConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ProjectService_UnsetServiceConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProjectServiceServer).UnsetServiceConfig(ctx, req.(*UnsetServiceConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ProjectService_ServiceDesc is the grpc.ServiceDesc for ProjectService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -246,6 +606,46 @@ var ProjectService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ParseGitHubUrl", Handler: _ProjectService_ParseGitHubUrl_Handler, }, + { + MethodName: "GetConfigSection", + Handler: _ProjectService_GetConfigSection_Handler, + }, + { + MethodName: "GetConfigValue", + Handler: _ProjectService_GetConfigValue_Handler, + }, + { + MethodName: "SetConfigSection", + Handler: _ProjectService_SetConfigSection_Handler, + }, + { + MethodName: "SetConfigValue", + Handler: _ProjectService_SetConfigValue_Handler, + }, + { + MethodName: "UnsetConfig", + Handler: _ProjectService_UnsetConfig_Handler, + }, + { + MethodName: "GetServiceConfigSection", + Handler: _ProjectService_GetServiceConfigSection_Handler, + }, + { + MethodName: "GetServiceConfigValue", + Handler: _ProjectService_GetServiceConfigValue_Handler, + }, + { + MethodName: "SetServiceConfigSection", + Handler: _ProjectService_SetServiceConfigSection_Handler, + }, + { + MethodName: "SetServiceConfigValue", + Handler: _ProjectService_SetServiceConfigValue_Handler, + }, + { + MethodName: "UnsetServiceConfig", + Handler: _ProjectService_UnsetServiceConfig_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "project.proto", diff --git a/cli/azd/pkg/project/mapper_registry.go b/cli/azd/pkg/project/mapper_registry.go index 74469c5bf93..11d6fcf21af 100644 --- a/cli/azd/pkg/project/mapper_registry.go +++ b/cli/azd/pkg/project/mapper_registry.go @@ -135,18 +135,29 @@ func registerProjectMappings() { } } + // Convert additional properties if present + var protoAdditionalProperties *structpb.Struct + if src.AdditionalProperties != nil { + var err error + protoAdditionalProperties, err = structpb.NewStruct(src.AdditionalProperties) + if err != nil { + return nil, fmt.Errorf("converting service additional properties to structpb: %w", err) + } + } + return &azdext.ServiceConfig{ - Name: src.Name, - ResourceGroupName: resourceGroupName, - ResourceName: resourceName, - ApiVersion: src.ApiVersion, - RelativePath: src.RelativePath, - Host: string(src.Host), - Language: string(src.Language), - OutputPath: src.OutputPath, - Image: image, - Docker: docker, - Config: protoConfig, + Name: src.Name, + ResourceGroupName: resourceGroupName, + ResourceName: resourceName, + ApiVersion: src.ApiVersion, + RelativePath: src.RelativePath, + Host: string(src.Host), + Language: string(src.Language), + OutputPath: src.OutputPath, + Image: image, + Docker: docker, + Config: protoConfig, + AdditionalProperties: protoAdditionalProperties, }, nil }) @@ -380,6 +391,10 @@ func registerProjectMappings() { result.Config = src.Config.AsMap() } + if src.AdditionalProperties != nil { + result.AdditionalProperties = src.AdditionalProperties.AsMap() + } + return result, nil }) @@ -687,6 +702,16 @@ func registerProjectMappings() { services[i] = serviceConfig } + // Convert additional properties if present + var protoAdditionalProperties *structpb.Struct + if src.AdditionalProperties != nil { + var err error + protoAdditionalProperties, err = structpb.NewStruct(src.AdditionalProperties) + if err != nil { + return nil, fmt.Errorf("converting project additional properties to structpb: %w", err) + } + } + projectConfig := &azdext.ProjectConfig{ Name: src.Name, ResourceGroupName: resourceGroupName, @@ -702,7 +727,8 @@ func registerProjectMappings() { Path: src.Infra.Path, Module: src.Infra.Module, }, - Services: services, + Services: services, + AdditionalProperties: protoAdditionalProperties, } return projectConfig, nil @@ -746,6 +772,11 @@ func registerProjectMappings() { } } + // Convert additional properties if present + if src.AdditionalProperties != nil { + result.AdditionalProperties = src.AdditionalProperties.AsMap() + } + return result, nil }) } diff --git a/cli/azd/pkg/project/mapper_registry_test.go b/cli/azd/pkg/project/mapper_registry_test.go index 60cb2b2a0a2..657471ede4e 100644 --- a/cli/azd/pkg/project/mapper_registry_test.go +++ b/cli/azd/pkg/project/mapper_registry_test.go @@ -51,6 +51,9 @@ func TestServiceConfigMapping(t *testing.T) { Host: ContainerAppTarget, Language: ServiceLanguageDotNet, RelativePath: "./src/api", + AdditionalProperties: map[string]interface{}{ + "customField": "customValue", + }, } var protoConfig *azdext.ServiceConfig @@ -61,6 +64,9 @@ func TestServiceConfigMapping(t *testing.T) { require.Equal(t, string(ContainerAppTarget), protoConfig.Host) require.Equal(t, string(ServiceLanguageDotNet), protoConfig.Language) require.Equal(t, "./src/api", protoConfig.RelativePath) + require.NotNil(t, protoConfig.AdditionalProperties) + additionalPropsMap := protoConfig.AdditionalProperties.AsMap() + require.Equal(t, "customValue", additionalPropsMap["customField"]) } func TestServiceConfigMappingWithResolver(t *testing.T) { @@ -217,6 +223,7 @@ func TestServiceConfigReverseMapping(t *testing.T) { require.Equal(t, ServiceLanguageDotNet, result.Language) require.Equal(t, "./src/api", result.RelativePath) require.Nil(t, result.Config) + require.Nil(t, result.AdditionalProperties) }, }, { @@ -278,6 +285,32 @@ func TestServiceConfigReverseMapping(t *testing.T) { require.Equal(t, "logging", features[1]) }, }, + { + name: "with additional properties in proto", + setupConfig: func() *azdext.ServiceConfig { + additionalPropsData := map[string]any{ + "extensionField": "extensionValue", + "metadata": map[string]any{"version": "1.0.0"}, + } + additionalProps, err := structpb.NewStruct(additionalPropsData) + require.NoError(t, err) + return &azdext.ServiceConfig{ + Name: "test-service", + Host: string(ContainerAppTarget), + Language: string(ServiceLanguageDotNet), + RelativePath: "./src/api", + AdditionalProperties: additionalProps, + } + }, + validateFn: func(t *testing.T, result *ServiceConfig) { + require.Equal(t, "test-service", result.Name) + require.NotNil(t, result.AdditionalProperties) + require.Equal(t, "extensionValue", result.AdditionalProperties["extensionField"]) + metadata, ok := result.AdditionalProperties["metadata"].(map[string]any) + require.True(t, ok) + require.Equal(t, "1.0.0", metadata["version"]) + }, + }, } for _, tt := range tests { @@ -314,9 +347,11 @@ func TestServiceConfigRoundTripMapping(t *testing.T) { Language: ServiceLanguageDotNet, RelativePath: "./src/api", Config: originalConfig, - } - - // Convert to proto + AdditionalProperties: map[string]any{ + "roundTripField": "roundTripValue", + "nestedData": map[string]any{"key": "value"}, + }, + } // Convert to proto var protoConfig *azdext.ServiceConfig err := mapper.Convert(originalServiceConfig, &protoConfig) require.NoError(t, err) @@ -351,6 +386,13 @@ func TestServiceConfigRoundTripMapping(t *testing.T) { require.True(t, ok) require.Equal(t, "inner_value", nested["inner_key"]) require.Equal(t, float64(456), nested["inner_num"]) // Numbers become float64 + + // Verify AdditionalProperties round-trip + require.NotNil(t, roundTripServiceConfig.AdditionalProperties) + require.Equal(t, "roundTripValue", roundTripServiceConfig.AdditionalProperties["roundTripField"]) + nestedData, ok := roundTripServiceConfig.AdditionalProperties["nestedData"].(map[string]any) + require.True(t, ok) + require.Equal(t, "value", nestedData["key"]) } func TestDockerProjectOptionsMapping(t *testing.T) { @@ -943,6 +985,10 @@ func TestProjectConfigMapping(t *testing.T) { RelativePath: "./api", }, }, + AdditionalProperties: map[string]interface{}{ + "projectExtension": "projectValue", + "globalConfig": map[string]interface{}{"enabled": true}, + }, } testResolver := func(key string) string { @@ -969,6 +1015,13 @@ func TestProjectConfigMapping(t *testing.T) { }) t.Run("proto ProjectConfig -> ProjectConfig", func(t *testing.T) { + additionalPropsData := map[string]interface{}{ + "reverseExtension": "reverseValue", + "config": map[string]interface{}{"timeout": 60}, + } + additionalProps, err := structpb.NewStruct(additionalPropsData) + require.NoError(t, err) + protoConfig := &azdext.ProjectConfig{ Name: "reverse-test-project", ResourceGroupName: "reverse-test-rg", @@ -984,10 +1037,11 @@ func TestProjectConfigMapping(t *testing.T) { RelativePath: "./backend", }, }, + AdditionalProperties: additionalProps, } var projectConfig *ProjectConfig - err := mapper.Convert(protoConfig, &projectConfig) + err = mapper.Convert(protoConfig, &projectConfig) require.NoError(t, err) require.NotNil(t, projectConfig) require.Equal(t, "reverse-test-project", projectConfig.Name) diff --git a/cli/azd/pkg/project/project_config.go b/cli/azd/pkg/project/project_config.go index ccb50cd74cf..1925c86e2f7 100644 --- a/cli/azd/pkg/project/project_config.go +++ b/cli/azd/pkg/project/project_config.go @@ -41,6 +41,9 @@ type ProjectConfig struct { Cloud *cloud.Config `yaml:"cloud,omitempty"` Resources map[string]*ResourceConfig `yaml:"resources,omitempty"` + // AdditionalProperties captures any unknown YAML fields for extension support + AdditionalProperties map[string]interface{} `yaml:",inline"` + *ext.EventDispatcher[ProjectLifecycleEventArgs] `yaml:"-"` } diff --git a/cli/azd/pkg/project/project_test.go b/cli/azd/pkg/project/project_test.go index b2f3cc964d0..21f7a7e2741 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -13,6 +13,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azure" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra" @@ -718,3 +719,287 @@ func TestInfraDefaultsNotSavedToYaml(t *testing.T) { assert.Equal(t, provisioning.ProviderKind(""), loadedProject.Infra.Provider) }) } + +func TestAdditionalPropertiesMarshalling(t *testing.T) { + tests := []struct { + name string + project *ProjectConfig + }{ + { + "project-level-extensions", + &ProjectConfig{ + Name: "test-extension-project", + Services: map[string]*ServiceConfig{ + "api": { + Language: ServiceLanguageJavaScript, + Host: ContainerAppTarget, + RelativePath: "./src/api", + }, + }, + AdditionalProperties: map[string]interface{}{ + "customProjectField": "project-level-extension", + "organizationSettings": map[string]interface{}{ + "billing": "department-a", + "compliance": true, + "tags": []interface{}{"production", "critical"}, + }, + "extensionConfig": map[string]interface{}{ + "timeout": 300, + "retries": 3, + "database": map[string]interface{}{ + "host": "localhost", + "port": 5432, + }, + }, + }, + }, + }, + { + "service-level-extensions", + &ProjectConfig{ + Name: "test-service-extension", + Services: map[string]*ServiceConfig{ + "api": { + Language: ServiceLanguageJavaScript, + Host: ContainerAppTarget, + RelativePath: "./src/api", + AdditionalProperties: map[string]interface{}{ + "customServiceField": "service-level-extension", + "monitoring": map[string]interface{}{ + "metrics": true, + "logging": "verbose", + "alerts": []interface{}{"cpu > 80%", "memory > 90%"}, + }, + "extensionSettings": map[string]interface{}{ + "caching": "redis", + "timeout": 30, + "features": map[string]interface{}{ + "featureA": true, + "featureB": false, + }, + }, + }, + }, + "web": { + Language: ServiceLanguageTypeScript, + Host: StaticWebAppTarget, + RelativePath: "./src/web", + AdditionalProperties: map[string]interface{}{ + "deployment": map[string]interface{}{ + "strategy": "blue-green", + "region": "eastus", + }, + }, + }, + }, + }, + }, + { + "combined-extensions", + &ProjectConfig{ + Name: "test-combined-extensions", + Services: map[string]*ServiceConfig{ + "api": { + Language: ServiceLanguageJavaScript, + Host: ContainerAppTarget, + RelativePath: "./src/api", + AdditionalProperties: map[string]interface{}{ + "serviceExtension": "api-specific", + "customConfig": map[string]interface{}{ + "setting1": "value1", + }, + }, + }, + }, + AdditionalProperties: map[string]interface{}{ + "projectExtension": "global-setting", + "sharedConfig": map[string]interface{}{ + "environment": "production", + "version": "1.0.0", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + + // First save: write the constructed project to YAML + firstSaveFile := filepath.Join(tempDir, "azure-first.yaml") + err := Save(context.Background(), tt.project, firstSaveFile) + require.NoError(t, err) + + // Load the project back (this initializes all internal fields properly) + loadedProject, err := Load(context.Background(), firstSaveFile) + require.NoError(t, err) + + // Second save: save the loaded project to verify round-trip + secondSaveFile := filepath.Join(tempDir, "azure-second.yaml") + err = Save(context.Background(), loadedProject, secondSaveFile) + require.NoError(t, err) + + // Load the second save and compare with first loaded project + reloadedProject, err := Load(context.Background(), secondSaveFile) + require.NoError(t, err) + + // Verify round-trip preservation with deep equality + assert.Equal(t, loadedProject, reloadedProject) + + // Snapshot the marshalled output to verify structure + savedContents, err := os.ReadFile(firstSaveFile) + require.NoError(t, err) + snapshot.SnapshotT(t, string(savedContents)) + }) + } +} + +// ExtensionConfig represents a type-safe configuration structure that an extension might define +type ExtensionConfig struct { + Timeout int `yaml:"timeout"` + Retries int `yaml:"retries"` + Database DatabaseConfig `yaml:"database"` + Features map[string]interface{} `yaml:"features,omitempty"` +} + +type DatabaseConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +func TestAdditionalPropertiesExtraction(t *testing.T) { + // Create a project with AdditionalProperties that includes extension configuration + project := &ProjectConfig{ + Name: "test-extension-extraction", + Services: map[string]*ServiceConfig{ + "api": { + Language: ServiceLanguageJavaScript, + Host: ContainerAppTarget, + RelativePath: "./src/api", + AdditionalProperties: map[string]interface{}{ + "customServiceField": "service-extension", + "monitoring": map[string]interface{}{ + "enabled": true, + "level": "verbose", + }, + }, + }, + }, + AdditionalProperties: map[string]interface{}{ + "customProjectField": "project-extension", + "extensionConfig": map[string]interface{}{ + "timeout": 300, + "retries": 3, + "database": map[string]interface{}{ + "host": "localhost", + "port": 5432, + }, + "features": map[string]interface{}{ + "caching": true, + "monitoring": false, + }, + }, + "otherExtension": map[string]interface{}{ + "setting1": "value1", + "setting2": 42, + }, + }, + } + + t.Run("ExtractProjectLevelConfig", func(t *testing.T) { + // Create a config from the AdditionalProperties map + cfg := config.NewConfig(project.AdditionalProperties) + + // Extract the extensionConfig section using GetSection + var extensionConfig ExtensionConfig + found, err := cfg.GetSection("extensionConfig", &extensionConfig) + require.NoError(t, err) + require.True(t, found, "extensionConfig section should be found") + + // Verify the type-safe configuration was extracted correctly + assert.Equal(t, 300, extensionConfig.Timeout) + assert.Equal(t, 3, extensionConfig.Retries) + assert.Equal(t, "localhost", extensionConfig.Database.Host) + assert.Equal(t, 5432, extensionConfig.Database.Port) + assert.Equal(t, true, extensionConfig.Features["caching"]) + assert.Equal(t, false, extensionConfig.Features["monitoring"]) + }) + + t.Run("ExtractServiceLevelConfig", func(t *testing.T) { + apiService := project.Services["api"] + require.NotNil(t, apiService) + + // Create a config from the service AdditionalProperties + serviceCfg := config.NewConfig(apiService.AdditionalProperties) + + // Define a type-safe structure for monitoring config + type MonitoringConfig struct { + Enabled bool `yaml:"enabled"` + Level string `yaml:"level"` + } + + // Extract monitoring configuration using GetSection + var monitoringConfig MonitoringConfig + found, err := serviceCfg.GetSection("monitoring", &monitoringConfig) + require.NoError(t, err) + require.True(t, found, "monitoring section should be found") + + // Verify the type-safe configuration + assert.True(t, monitoringConfig.Enabled) + assert.Equal(t, "verbose", monitoringConfig.Level) + }) + + t.Run("RoundTripWithExtractedConfig", func(t *testing.T) { + tempDir := t.TempDir() + + // Save the original project + originalFile := filepath.Join(tempDir, "original.yaml") + err := Save(context.Background(), project, originalFile) + require.NoError(t, err) + + // Load it back + loadedProject, err := Load(context.Background(), originalFile) + require.NoError(t, err) + + // Extract the extension config using the config system + cfg := config.NewConfig(loadedProject.AdditionalProperties) + var extensionConfig ExtensionConfig + found, err := cfg.GetSection("extensionConfig", &extensionConfig) + require.NoError(t, err) + require.True(t, found, "extensionConfig section should be found") + + // Modify the configuration + extensionConfig.Timeout = 600 + extensionConfig.Database.Host = "production-db" + + // Create a new config from the modified struct and extract as raw map + modifiedCfg := config.NewConfig(map[string]interface{}{ + "extensionConfig": extensionConfig, + }) + modifiedRaw := modifiedCfg.Raw() + + loadedProject.AdditionalProperties["extensionConfig"] = modifiedRaw["extensionConfig"] + + // Save the modified project + modifiedFile := filepath.Join(tempDir, "modified.yaml") + err = Save(context.Background(), loadedProject, modifiedFile) + require.NoError(t, err) + + // Load and verify the changes were preserved + finalProject, err := Load(context.Background(), modifiedFile) + require.NoError(t, err) + + // Extract the final config using the config system + finalCfg := config.NewConfig(finalProject.AdditionalProperties) + var finalExtensionConfig ExtensionConfig + found, err = finalCfg.GetSection("extensionConfig", &finalExtensionConfig) + require.NoError(t, err) + require.True(t, found, "extensionConfig section should be found") + + // Verify the modifications were preserved + assert.Equal(t, 600, finalExtensionConfig.Timeout) + assert.Equal(t, "production-db", finalExtensionConfig.Database.Host) + assert.Equal(t, 3, finalExtensionConfig.Retries) // Unchanged + }) +} diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 16c0791f3e1..584375bbc37 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -58,6 +58,9 @@ type ServiceConfig struct { // Environment variables to set for the service Environment osutil.ExpandableMap `yaml:"env,omitempty"` + // AdditionalProperties captures any unknown YAML fields for extension support + AdditionalProperties map[string]interface{} `yaml:",inline"` + *ext.EventDispatcher[ServiceLifecycleEventArgs] `yaml:"-"` // Turns service into a service that is only to be built but not deployed. diff --git a/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-combined-extensions.snap b/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-combined-extensions.snap new file mode 100644 index 00000000000..7ccbae14f0f --- /dev/null +++ b/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-combined-extensions.snap @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: test-combined-extensions +services: + api: + project: ./src/api + host: containerapp + language: js + customConfig: + setting1: value1 + serviceExtension: api-specific +projectExtension: global-setting +sharedConfig: + environment: production + version: 1.0.0 + diff --git a/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-project-level-extensions.snap b/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-project-level-extensions.snap new file mode 100644 index 00000000000..85d7e4feee5 --- /dev/null +++ b/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-project-level-extensions.snap @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: test-extension-project +services: + api: + project: ./src/api + host: containerapp + language: js +customProjectField: project-level-extension +extensionConfig: + database: + host: localhost + port: 5432 + retries: 3 + timeout: 300 +organizationSettings: + billing: department-a + compliance: true + tags: + - production + - critical + diff --git a/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-service-level-extensions.snap b/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-service-level-extensions.snap new file mode 100644 index 00000000000..ee9620c01e4 --- /dev/null +++ b/cli/azd/pkg/project/testdata/TestAdditionalPropertiesMarshalling-service-level-extensions.snap @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: test-service-extension +services: + api: + project: ./src/api + host: containerapp + language: js + customServiceField: service-level-extension + extensionSettings: + caching: redis + features: + featureA: true + featureB: false + timeout: 30 + monitoring: + alerts: + - cpu > 80% + - memory > 90% + logging: verbose + metrics: true + web: + project: ./src/web + host: staticwebapp + language: ts + deployment: + region: eastus + strategy: blue-green + diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 13347653e04..83f5155a573 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -5,7 +5,7 @@ "required": [ "name" ], - "additionalProperties": false, + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -159,7 +159,7 @@ "minProperties": 1, "additionalProperties": { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "required": [ "host" ], diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index fb7370d0d11..7b9d019a871 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -5,7 +5,7 @@ "required": [ "name" ], - "additionalProperties": false, + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -66,7 +66,7 @@ "minProperties": 1, "additionalProperties": { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "required": [ "host" ],