From 752d5ba748291e110c64d5cfcc79dc180d2c92e2 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 9 Jan 2026 17:33:57 -0800 Subject: [PATCH 01/11] Start of extensions metadata capability --- .../internal/cmd/coding_agent_config.go | 40 ++- .../internal/cmd/coding_agent_config_test.go | 64 +++- cli/azd/extensions/extension.schema.json | 8 +- .../extensions/microsoft.azd.demo/README.md | 42 +++ .../microsoft.azd.demo/extension.yaml | 1 + .../internal/cmd/metadata.go | 58 ++++ .../microsoft.azd.demo/internal/cmd/root.go | 1 + .../internal/config/types.go | 33 ++ .../microsoft.azd.demo/metadata.json | 165 +++++++++ cli/azd/pkg/azdext/metadata_generator.go | 170 +++++++++ cli/azd/pkg/azdext/metadata_generator_test.go | 271 +++++++++++++++ cli/azd/pkg/extensions/manager.go | 23 +- cli/azd/pkg/extensions/metadata.go | 131 +++++++ cli/azd/pkg/extensions/metadata_manager.go | 178 ++++++++++ .../pkg/extensions/metadata_manager_test.go | 142 ++++++++ cli/azd/pkg/extensions/metadata_test.go | 326 ++++++++++++++++++ cli/azd/pkg/extensions/registry.go | 2 + cli/azd/pkg/extensions/schema_validator.go | 61 ++++ .../pkg/extensions/schema_validator_test.go | 297 ++++++++++++++++ 19 files changed, 1983 insertions(+), 30 deletions(-) create mode 100644 cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go create mode 100644 cli/azd/extensions/microsoft.azd.demo/internal/config/types.go create mode 100644 cli/azd/extensions/microsoft.azd.demo/metadata.json create mode 100644 cli/azd/pkg/azdext/metadata_generator.go create mode 100644 cli/azd/pkg/azdext/metadata_generator_test.go create mode 100644 cli/azd/pkg/extensions/metadata.go create mode 100644 cli/azd/pkg/extensions/metadata_manager.go create mode 100644 cli/azd/pkg/extensions/metadata_manager_test.go create mode 100644 cli/azd/pkg/extensions/metadata_test.go create mode 100644 cli/azd/pkg/extensions/schema_validator.go create mode 100644 cli/azd/pkg/extensions/schema_validator_test.go diff --git a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go index 6e5704101d1..0de059b34d5 100644 --- a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go +++ b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go @@ -114,14 +114,19 @@ func newConfigCommand() *cobra.Command { cc := &cobra.Command{ Use: "config", Short: "Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP", - Long: "Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP.\n\nFor more information about this command, including prerequisites and troubleshooting, view the readme at " + ux.Hyperlink("https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.coding-agent/README.md"), + Long: "Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP.\n\nFor more information about this command, including prerequisites and troubleshooting, view the readme at " + ux.Hyperlink( + "https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.coding-agent/README.md", + ), } flagValues := setupFlags(cc.Flags()) cc.RunE = func(cmd *cobra.Command, args []string) error { if err := runConfigCommand(cmd, flagValues); err != nil { - message := fmt.Sprintf("(!) An error occurred, see the readme for troubleshooting and prerequisites:\n %s", ux.Hyperlink(readmeURL)) //nolint:lll + message := fmt.Sprintf( + "(!) An error occurred, see the readme for troubleshooting and prerequisites:\n %s", + ux.Hyperlink(readmeURL), + ) //nolint:lll fmt.Println(ux.BoldString(message)) return err } @@ -185,7 +190,10 @@ func runConfigCommand(cmd *cobra.Command, flagValues *flagValues) error { if err != nil { //nolint:lll - return fmt.Errorf("failed getting a subscription from prompt. Try logging in manually with 'azd auth login' before running this command %w", err) + return fmt.Errorf( + "failed getting a subscription from prompt. Try logging in manually with 'azd auth login' before running this command %w", + err, + ) } tenantID := subscriptionResponse.Subscription.TenantId @@ -249,8 +257,16 @@ func runConfigCommand(cmd *cobra.Command, flagValues *flagValues) error { return fmt.Errorf("failed to push files to git: %w", err) } - codingAgentURL := fmt.Sprintf("https://github.com/%s/settings/copilot/coding_agent#:~:text=JSON%%20MCP%%20configuration-,MCP%%20configuration,-1", repoSlug) //nolint:lll - managedIdentityPortalURL := formatPortalLinkForManagedIdentity(tenantID, subscriptionID, authConfig.ResourceGroup, authConfig.Name) //nolint:lll + codingAgentURL := fmt.Sprintf( + "https://github.com/%s/settings/copilot/coding_agent#:~:text=JSON%%20MCP%%20configuration-,MCP%%20configuration,-1", + repoSlug, + ) //nolint:lll + managedIdentityPortalURL := formatPortalLinkForManagedIdentity( + tenantID, + subscriptionID, + authConfig.ResourceGroup, + authConfig.Name, + ) //nolint:lll fmt.Println("") fmt.Println(output.WithHighLightFormat("(!)")) @@ -258,7 +274,10 @@ func runConfigCommand(cmd *cobra.Command, flagValues *flagValues) error { fmt.Println(output.WithHighLightFormat("(!)")) fmt.Println("") fmt.Printf("1. The branch created at %s/%s must be merged to %s/main\n", remote, flagValues.BranchName, repoSlug) - fmt.Printf("2. Configure Copilot coding agent's managed identity roles in the Azure portal: %s\n", ux.Hyperlink(managedIdentityPortalURL)) // nolint:lll + fmt.Printf( + "2. Configure Copilot coding agent's managed identity roles in the Azure portal: %s\n", + ux.Hyperlink(managedIdentityPortalURL), + ) // nolint:lll fmt.Printf("3. Visit '%s' and update the \"MCP configuration\" field with this JSON:\n\n", ux.Hyperlink(codingAgentURL)) fmt.Println(mcpJson) @@ -294,7 +313,8 @@ func openBrowserWindows(ctx context.Context, return nil } - fullURL := fmt.Sprintf("https://github.com/%s/compare/main...azd-enable-copilot-coding-agent-with-azure?body=%s&expand=1&title=%s", + fullURL := fmt.Sprintf( + "https://github.com/%s/compare/main...azd-enable-copilot-coding-agent-with-azure?body=%s&expand=1&title=%s", repoSlug, url.QueryEscape(fmt.Sprintf(prBodyMD, codingAgentURL, mcpJson)), url.QueryEscape("Updating/adding copilot-setup-steps.yaml to enable the Copilot coding agent to access Azure"), @@ -963,9 +983,11 @@ func formatPortalLinkForManagedIdentity(tenantID string, resourceGroupName string, managedIdentityName string) string { //nolint:lll - return fmt.Sprintf("https://portal.azure.com/#@%s/resource/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s/azure_resources", + return fmt.Sprintf( + "https://portal.azure.com/#@%s/resource/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s/azure_resources", tenantID, subscriptionID, resourceGroupName, - managedIdentityName) + managedIdentityName, + ) } diff --git a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go index c8edce23eeb..572333a3e83 100644 --- a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go +++ b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go @@ -101,7 +101,13 @@ func TestCodingAgent_promptForRepoSlug(t *testing.T) { promptClient := NewMockPromptServiceClient(ctrl) gitCLI := NewMockgitCLI(ctrl) - slug, err := promptForCodingAgentRepoSlug(context.Background(), promptClient, gitCLI, "repo-root-ignored", repoSlugForTests) //nolint:lll + slug, err := promptForCodingAgentRepoSlug( + context.Background(), + promptClient, + gitCLI, + "repo-root-ignored", + repoSlugForTests, + ) //nolint:lll require.Equal(t, slug, repoSlugForTests) require.NoError(t, err) @@ -114,9 +120,13 @@ func TestCodingAgent_promptForRepoSlug(t *testing.T) { gitCLI.EXPECT().ListRemotes(gomock.Any(), "repo-root-used").Return([]string{"origin", "upstream"}, nil) //nolint:lll - gitCLI.EXPECT().GetRemoteUrl(gomock.Any(), "repo-root-used", "origin").Return("https://github.com/richardpark-msft/tawnygardenslug", nil) + gitCLI.EXPECT(). + GetRemoteUrl(gomock.Any(), "repo-root-used", "origin"). + Return("https://github.com/richardpark-msft/tawnygardenslug", nil) //nolint:lll - gitCLI.EXPECT().GetRemoteUrl(gomock.Any(), "repo-root-used", "upstream").Return("https://github.com/slugs/tawnygardenslug", nil) + gitCLI.EXPECT(). + GetRemoteUrl(gomock.Any(), "repo-root-used", "upstream"). + Return("https://github.com/slugs/tawnygardenslug", nil) promptClient.EXPECT().Select(gomock.Any(), gomock.Any()).Return(&azdext.SelectResponse{ // simulate they chose option 1 @@ -216,7 +226,10 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { promptService, msiService, entraService, resourceService := setup(t) promptService.EXPECT().Select(gomock.Any(), - SelectMatcher{T: t, ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?"}). + SelectMatcher{ + T: t, + ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?", + }). Return(&azdext.SelectResponse{ Value: to.Ptr(int32(0)), // ie, "create a new one" }, nil) @@ -230,7 +243,14 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { TenantID: to.Ptr(fakeTenantID), }, Name: to.Ptr(string(defaultManagedIdentityName)), - ID: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", fakeSubscriptionID, fakeResourceGroup, defaultManagedIdentityName)), + ID: to.Ptr( + fmt.Sprintf( + "/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", + fakeSubscriptionID, + fakeResourceGroup, + defaultManagedIdentityName, + ), + ), }, nil) entraService.EXPECT().EnsureRoleAssignments(gomock.Any(), @@ -273,13 +293,21 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { TenantID: to.Ptr(fakeTenantID), }, Name: to.Ptr(customManagedIdentityName), - ID: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", fakeSubscriptionID, fakeResourceGroup, customManagedIdentityName)), + ID: to.Ptr( + fmt.Sprintf( + "/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", + fakeSubscriptionID, + fakeResourceGroup, + customManagedIdentityName, + ), + ), }, nil) - entraService.EXPECT().EnsureRoleAssignments(gomock.Any(), fakeSubscriptionID, []string{"custom-role-name"}, &graphsdk.ServicePrincipal{ - Id: to.Ptr("principal-id"), - DisplayName: customManagedIdentityName, - }, gomock.Any()). + entraService.EXPECT(). + EnsureRoleAssignments(gomock.Any(), fakeSubscriptionID, []string{"custom-role-name"}, &graphsdk.ServicePrincipal{ + Id: to.Ptr("principal-id"), + DisplayName: customManagedIdentityName, + }, gomock.Any()). Return(nil) authConfig, err := pickOrCreateMSI(context.Background(), @@ -303,7 +331,10 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { promptService, msiService, entraService, resourceService := setup(t) promptService.EXPECT().Select(gomock.Any(), - SelectMatcher{T: t, ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?"}). + SelectMatcher{ + T: t, + ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?", + }). Return(&azdext.SelectResponse{ Value: to.Ptr(int32(1)), // ie, "use an existing one" }, nil) @@ -317,8 +348,15 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { ClientID: to.Ptr(fakeClientID), TenantID: to.Ptr(fakeTenantID), }, - Name: to.Ptr(string(defaultManagedIdentityName)), - ID: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", fakeSubscriptionID, fakeResourceGroup, defaultManagedIdentityName)), + Name: to.Ptr(string(defaultManagedIdentityName)), + ID: to.Ptr( + fmt.Sprintf( + "/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", + fakeSubscriptionID, + fakeResourceGroup, + defaultManagedIdentityName, + ), + ), Location: to.Ptr(fakeLocation), }, }, nil) diff --git a/cli/azd/extensions/extension.schema.json b/cli/azd/extensions/extension.schema.json index a834a5198ea..2293a10accb 100644 --- a/cli/azd/extensions/extension.schema.json +++ b/cli/azd/extensions/extension.schema.json @@ -108,7 +108,7 @@ "capabilities": { "type": "array", "title": "Capabilities", - "description": "List of capabilities provided by the extension. Supported values: custom-commands, lifecycle-events, mcp-server, service-target-provider. Select one or more from the allowed list. Each value must be unique.", + "description": "List of capabilities provided by the extension. Supported values: custom-commands, lifecycle-events, mcp-server, service-target-provider, metadata. Select one or more from the allowed list. Each value must be unique.", "minItems": 1, "uniqueItems": true, "items": { @@ -142,6 +142,12 @@ "const": "framework-service-provider", "title": "Framework Service Provider", "description": "Framework service provider enables extensions to provide custom language frameworks and build systems." + }, + { + "type": "string", + "const": "metadata", + "title": "Metadata", + "description": "Metadata capability enables extensions to provide comprehensive metadata about their commands and capabilities via a metadata command." } ] } diff --git a/cli/azd/extensions/microsoft.azd.demo/README.md b/cli/azd/extensions/microsoft.azd.demo/README.md index 0dab3887e2c..2998127b7f0 100644 --- a/cli/azd/extensions/microsoft.azd.demo/README.md +++ b/cli/azd/extensions/microsoft.azd.demo/README.md @@ -58,6 +58,48 @@ Displays a list of Azure resources based on the current logged in user, and subs Displays a list of Azure resources based on the current logged in user, subscription and resource group filtered by resource type and kind. +### `metadata` + +The `metadata` command demonstrates the metadata capability, which provides command structure and configuration schemas. + +#### Usage: `azd demo metadata` + +This command generates JSON metadata including: +- **Command Tree**: All available commands, subcommands, flags, and arguments with descriptions +- **Configuration Schemas**: Type-safe JSON schemas for project and service-level configuration + +Example configuration in `azure.yaml`: + +```yaml +extensions: + demo: + project: + enableColors: true + maxItems: 20 + labels: + team: "platform" + env: "dev" + +services: + web: + extensions: + demo: + service: + endpoint: "https://api.example.com" + port: 8080 + environment: "staging" + healthCheck: + enabled: true + path: "/health" + interval: 30 +``` + +The metadata capability enables: +- IDE autocomplete and validation for extension configuration +- Automatic help generation +- Configuration validation before deployment +- Better discoverability of extension features + ### `listen` This `listen` command is required when your extension leverages `LifecycleEvents` capability. diff --git a/cli/azd/extensions/microsoft.azd.demo/extension.yaml b/cli/azd/extensions/microsoft.azd.demo/extension.yaml index 76f8a5484c5..c978740f1a8 100644 --- a/cli/azd/extensions/microsoft.azd.demo/extension.yaml +++ b/cli/azd/extensions/microsoft.azd.demo/extension.yaml @@ -12,6 +12,7 @@ capabilities: - mcp-server - service-target-provider - framework-service-provider + - metadata providers: - name: demo type: service-target diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go new file mode 100644 index 00000000000..66748557827 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/config" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/invopop/jsonschema" + "github.com/spf13/cobra" +) + +func newMetadataCommand() *cobra.Command { + return &cobra.Command{ + Use: "metadata", + Short: "Generate extension metadata including command structure and configuration schemas", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Get root command for metadata generation + rootCmd := cmd.Root() + + // Generate extension metadata with commands and configuration + metadata := azdext.GenerateExtensionMetadata( + "1.0", // schema version + "microsoft.azd.demo", // extension id + "0.4.0", // extension version + rootCmd, + ) + + // Add custom configuration schemas + metadata.Configuration = generateConfigurationMetadata() + + // Output as JSON + jsonBytes, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + fmt.Println(string(jsonBytes)) + return nil + }, + } +} + +// generateConfigurationMetadata creates configuration schemas for the demo extension. +// This demonstrates how extension developers can define type-safe configuration +// requirements using Go structs and automatic schema generation. +func generateConfigurationMetadata() *extensions.ConfigurationMetadata { + // Generate schemas from Go types automatically + return &extensions.ConfigurationMetadata{ + Project: jsonschema.Reflect(&config.CustomProjectConfig{}), + Service: jsonschema.Reflect(&config.CustomServiceConfig{}), + } +} 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..a6a6b211158 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go @@ -28,6 +28,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(newMcpCommand()) rootCmd.AddCommand(newGhUrlParseCommand()) + rootCmd.AddCommand(newMetadataCommand()) return rootCmd } diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go new file mode 100644 index 00000000000..95b581ed472 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package config + +// CustomProjectConfig defines project-level configuration for the demo extension +type CustomProjectConfig struct { + // Demo feature flags for project-level configuration + EnableColors bool `json:"enableColors,omitempty" jsonschema:"description=Enable color output,default=true"` + // Maximum number of items to display + MaxItems int `json:"maxItems,omitempty" jsonschema:"description=Max items to display,minimum=1,maximum=100,default=10"` + // Project labels for demo purposes + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Custom project labels"` +} + +// CustomServiceConfig defines service-level configuration for the demo extension +type CustomServiceConfig struct { + // Demo service endpoint configuration + Endpoint string `json:"endpoint" jsonschema:"required,description=Service endpoint URL,format=uri"` + // Port for the demo service + Port int `json:"port,omitempty" jsonschema:"description=Service port,minimum=1,maximum=65535,default=8080"` + // Environment for demo deployment + Environment string `json:"environment,omitempty" jsonschema:"description=Environment,enum=development,enum=staging,enum=production"` + // Health check configuration + HealthCheck *HealthCheckConfig `json:"healthCheck,omitempty" jsonschema:"description=Health check configuration"` +} + +// HealthCheckConfig defines health check configuration +type HealthCheckConfig struct { + Enabled bool `json:"enabled,omitempty" jsonschema:"description=Enable health checks,default=true"` + Path string `json:"path,omitempty" jsonschema:"description=Health check path,default=/health"` + Interval int `json:"interval,omitempty" jsonschema:"description=Check interval (sec),minimum=5,maximum=300,default=30"` +} diff --git a/cli/azd/extensions/microsoft.azd.demo/metadata.json b/cli/azd/extensions/microsoft.azd.demo/metadata.json new file mode 100644 index 00000000000..92942bde2fe --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/metadata.json @@ -0,0 +1,165 @@ +{ + "schemaVersion": "1.0", + "id": "microsoft.azd.demo", + "version": "0.4.0", + "commands": [ + { + "name": [ + "colors" + ], + "short": "Displays all ASCII colors with their standard and high-intensity variants.", + "usage": "azd colors" + }, + { + "name": [ + "context" + ], + "short": "Get the context of the AZD project \u0026 environment.", + "usage": "azd context" + }, + { + "name": [ + "gh-url-parse" + ], + "short": "Parse a GitHub URL and extract repository information.", + "long": "Parse a GitHub URL and extract repository information including hostname, \nrepository slug, branch name, and file path. Supports various GitHub URL formats \nincluding blob, tree, raw, and API URLs. Handles branch names containing slashes.", + "usage": "azd gh-url-parse \u003cgithub-url\u003e" + }, + { + "name": [ + "listen" + ], + "short": "Starts the extension and listens for events.", + "usage": "azd listen" + }, + { + "name": [ + "mcp" + ], + "short": "MCP server commands for demo extension", + "usage": "azd mcp", + "subcommands": [ + { + "name": [ + "mcp", + "start" + ], + "short": "Start MCP server with demo tools", + "usage": "azd mcp start" + } + ] + }, + { + "name": [ + "prompt" + ], + "short": "Examples of prompting the user for input.", + "usage": "azd prompt" + }, + { + "name": [ + "version" + ], + "short": "Prints the version of the application", + "usage": "azd version" + } + ], + "configuration": { + "project": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/config/custom-project-config", + "$ref": "#/$defs/CustomProjectConfig", + "$defs": { + "CustomProjectConfig": { + "properties": { + "enableColors": { + "type": "boolean", + "description": "Enable color output", + "default": true + }, + "maxItems": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Max items to display", + "default": 10 + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Custom project labels" + } + }, + "additionalProperties": false, + "type": "object" + } + } + }, + "service": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/config/custom-service-config", + "$ref": "#/$defs/CustomServiceConfig", + "$defs": { + "CustomServiceConfig": { + "properties": { + "endpoint": { + "type": "string", + "format": "uri", + "description": "Service endpoint URL" + }, + "port": { + "type": "integer", + "maximum": 65535, + "minimum": 1, + "description": "Service port", + "default": 8080 + }, + "environment": { + "type": "string", + "enum": [ + "development", + "staging", + "production" + ], + "description": "Environment" + }, + "healthCheck": { + "$ref": "#/$defs/HealthCheckConfig", + "description": "Health check configuration" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "endpoint" + ] + }, + "HealthCheckConfig": { + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable health checks", + "default": true + }, + "path": { + "type": "string", + "description": "Health check path", + "default": "/health" + }, + "interval": { + "type": "integer", + "maximum": 300, + "minimum": 5, + "description": "Check interval (sec)", + "default": 30 + } + }, + "additionalProperties": false, + "type": "object" + } + } + } + } +} diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go new file mode 100644 index 00000000000..72d54b9b3ce --- /dev/null +++ b/cli/azd/pkg/azdext/metadata_generator.go @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// GenerateExtensionMetadata generates ExtensionCommandMetadata from a Cobra root command +// This function is typically called by extensions to generate their metadata +func GenerateExtensionMetadata(schemaVersion, id, version string, root *cobra.Command) *extensions.ExtensionCommandMetadata { + return &extensions.ExtensionCommandMetadata{ + SchemaVersion: schemaVersion, + ID: id, + Version: version, + Commands: generateCommands(root), + } +} + +// generateCommands recursively generates Command metadata from a Cobra command tree +func generateCommands(cmd *cobra.Command) []extensions.Command { + var commands []extensions.Command + + for _, subCmd := range cmd.Commands() { + // Skip hidden commands + if subCmd.Hidden { + continue + } + + command := generateCommand(subCmd) + commands = append(commands, command) + } + + return commands +} + +// generateCommand generates Command metadata from a single Cobra command +func generateCommand(cmd *cobra.Command) extensions.Command { + // Build command path + path := buildCommandPath(cmd) + + command := extensions.Command{ + Name: path, + Short: cmd.Short, + Long: cmd.Long, + Usage: cmd.UseLine(), + Examples: generateExamples(cmd), + Args: generateArgs(cmd), + Flags: generateFlags(cmd), + Hidden: cmd.Hidden, + Aliases: cmd.Aliases, + } + + if cmd.Deprecated != "" { + command.Deprecated = cmd.Deprecated + } + + // Recursively generate subcommands + if cmd.HasSubCommands() { + command.Subcommands = generateCommands(cmd) + } + + return command +} + +// buildCommandPath builds the full command path for a Cobra command +func buildCommandPath(cmd *cobra.Command) []string { + var path []string + current := cmd + + // Walk up the command tree to build the path + for current != nil && current.Use != "" { + // Extract command name from Use (format: "name [flags]" or "name") + use := current.Use + name := use + for i, r := range use { + if r == ' ' || r == '\t' { + name = use[:i] + break + } + } + path = append([]string{name}, path...) + current = current.Parent() + } + + // Remove root command name (typically the binary name) + if len(path) > 0 { + path = path[1:] + } + + return path +} + +// generateExamples generates CommandExample metadata from Cobra command examples +func generateExamples(cmd *cobra.Command) []extensions.CommandExample { + if cmd.Example == "" { + return nil + } + + // Cobra stores examples as a single string with multiple examples + // For now, we'll return it as a single example + // Extension developers can customize this if needed + return []extensions.CommandExample{ + { + Description: "Usage example", + Command: cmd.Example, + }, + } +} + +// generateArgs generates Argument metadata from Cobra command arguments +// Note: Cobra doesn't have built-in argument metadata, so we provide a best-effort approach +func generateArgs(cmd *cobra.Command) []extensions.Argument { + // Cobra doesn't expose detailed argument metadata + // Extension developers should manually define argument metadata if needed + return nil +} + +// generateFlags generates Flag metadata from Cobra command flags +func generateFlags(cmd *cobra.Command) []extensions.Flag { + var flags []extensions.Flag + + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + // Skip hidden flags + if flag.Hidden { + return + } + + flagMeta := extensions.Flag{ + Name: flag.Name, + Shorthand: flag.Shorthand, + Description: flag.Usage, + Type: getFlagType(flag), + Hidden: flag.Hidden, + } + + if flag.DefValue != "" { + flagMeta.Default = flag.DefValue + } + + if flag.Deprecated != "" { + flagMeta.Deprecated = flag.Deprecated + } + + flags = append(flags, flagMeta) + }) + + return flags +} + +// getFlagType maps Cobra/pflag types to metadata type strings +func getFlagType(flag *pflag.Flag) string { + switch flag.Value.Type() { + case "bool": + return "bool" + case "int", "int32", "int64": + return "int" + case "string": + return "string" + case "stringSlice", "stringArray": + return "stringArray" + case "intSlice": + return "intArray" + default: + return "string" + } +} diff --git a/cli/azd/pkg/azdext/metadata_generator_test.go b/cli/azd/pkg/azdext/metadata_generator_test.go new file mode 100644 index 00000000000..f6c711a288d --- /dev/null +++ b/cli/azd/pkg/azdext/metadata_generator_test.go @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateExtensionMetadata(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + greetCmd := &cobra.Command{ + Use: "greet [name]", + Short: "Greet someone", + Long: "This command greets someone with a friendly message.", + } + greetCmd.Flags().StringP("format", "f", "text", "Output format") + greetCmd.Flags().BoolP("verbose", "v", false, "Verbose output") + + rootCmd.AddCommand(greetCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.NotNil(t, metadata) + assert.Equal(t, "1.0", metadata.SchemaVersion) + assert.Equal(t, "test.extension", metadata.ID) + assert.Equal(t, "1.0.0", metadata.Version) + assert.Len(t, metadata.Commands, 1) + + cmd := metadata.Commands[0] + assert.Equal(t, []string{"greet"}, cmd.Name) + assert.Equal(t, "Greet someone", cmd.Short) + assert.Equal(t, "This command greets someone with a friendly message.", cmd.Long) + assert.Len(t, cmd.Flags, 2) + + // Check flags + formatFlag := findFlag(cmd.Flags, "format") + require.NotNil(t, formatFlag) + assert.Equal(t, "f", formatFlag.Shorthand) + assert.Equal(t, "string", formatFlag.Type) + assert.Equal(t, "text", formatFlag.Default) + + verboseFlag := findFlag(cmd.Flags, "verbose") + require.NotNil(t, verboseFlag) + assert.Equal(t, "v", verboseFlag.Shorthand) + assert.Equal(t, "bool", verboseFlag.Type) +} + +func TestGenerateExtensionMetadata_NestedCommands(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + demoCmd := &cobra.Command{ + Use: "demo", + Short: "Demo commands", + } + + greetCmd := &cobra.Command{ + Use: "greet", + Short: "Greet command", + } + + farewellCmd := &cobra.Command{ + Use: "farewell", + Short: "Farewell command", + } + + demoCmd.AddCommand(greetCmd) + demoCmd.AddCommand(farewellCmd) + rootCmd.AddCommand(demoCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.Len(t, metadata.Commands, 1) + assert.Equal(t, []string{"demo"}, metadata.Commands[0].Name) + assert.Len(t, metadata.Commands[0].Subcommands, 2) + + // Find subcommands by name (order may vary) + var greet, farewell *extensions.Command + for i := range metadata.Commands[0].Subcommands { + if metadata.Commands[0].Subcommands[i].Name[1] == "greet" { + greet = &metadata.Commands[0].Subcommands[i] + } else if metadata.Commands[0].Subcommands[i].Name[1] == "farewell" { + farewell = &metadata.Commands[0].Subcommands[i] + } + } + + require.NotNil(t, greet) + assert.Equal(t, []string{"demo", "greet"}, greet.Name) + + require.NotNil(t, farewell) + assert.Equal(t, []string{"demo", "farewell"}, farewell.Name) +} + +func TestGenerateExtensionMetadata_HiddenCommands(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + visibleCmd := &cobra.Command{ + Use: "visible", + Short: "Visible command", + } + + hiddenCmd := &cobra.Command{ + Use: "hidden", + Short: "Hidden command", + Hidden: true, + } + + rootCmd.AddCommand(visibleCmd) + rootCmd.AddCommand(hiddenCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + // Hidden commands should not be included + assert.Len(t, metadata.Commands, 1) + assert.Equal(t, []string{"visible"}, metadata.Commands[0].Name) +} + +func TestGenerateExtensionMetadata_HiddenFlags(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + testCmd := &cobra.Command{ + Use: "test", + Short: "Test command", + } + testCmd.Flags().String("visible", "", "Visible flag") + testCmd.Flags().String("hidden", "", "Hidden flag") + testCmd.Flags().MarkHidden("hidden") + + rootCmd.AddCommand(testCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.Len(t, metadata.Commands, 1) + // Hidden flags should not be included + assert.Len(t, metadata.Commands[0].Flags, 1) + assert.Equal(t, "visible", metadata.Commands[0].Flags[0].Name) +} + +func TestGenerateExtensionMetadata_DeprecatedCommands(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + deprecatedCmd := &cobra.Command{ + Use: "old-command", + Short: "Old command", + Deprecated: "use 'new-command' instead", + } + + rootCmd.AddCommand(deprecatedCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.Len(t, metadata.Commands, 1) + assert.Equal(t, "use 'new-command' instead", metadata.Commands[0].Deprecated) +} + +func TestGenerateExtensionMetadata_Aliases(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + cmdWithAliases := &cobra.Command{ + Use: "command", + Short: "Command with aliases", + Aliases: []string{"cmd", "c"}, + } + + rootCmd.AddCommand(cmdWithAliases) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.Len(t, metadata.Commands, 1) + assert.Equal(t, []string{"cmd", "c"}, metadata.Commands[0].Aliases) +} + +func TestGenerateExtensionMetadata_Examples(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + cmdWithExamples := &cobra.Command{ + Use: "command", + Short: "Command with examples", + Example: "azd x test-ext command --flag value", + } + + rootCmd.AddCommand(cmdWithExamples) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.Len(t, metadata.Commands, 1) + require.Len(t, metadata.Commands[0].Examples, 1) + assert.Equal(t, "azd x test-ext command --flag value", metadata.Commands[0].Examples[0].Command) +} + +func TestGenerateExtensionMetadata_FlagTypes(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + testCmd := &cobra.Command{ + Use: "test", + Short: "Test command", + } + + testCmd.Flags().String("string-flag", "", "A string flag") + testCmd.Flags().Bool("bool-flag", false, "A bool flag") + testCmd.Flags().Int("int-flag", 0, "An int flag") + testCmd.Flags().StringSlice("string-slice-flag", []string{}, "A string slice flag") + testCmd.Flags().IntSlice("int-slice-flag", []int{}, "An int slice flag") + + rootCmd.AddCommand(testCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + + require.Len(t, metadata.Commands, 1) + flags := metadata.Commands[0].Flags + + stringFlag := findFlag(flags, "string-flag") + require.NotNil(t, stringFlag) + assert.Equal(t, "string", stringFlag.Type) + + boolFlag := findFlag(flags, "bool-flag") + require.NotNil(t, boolFlag) + assert.Equal(t, "bool", boolFlag.Type) + + intFlag := findFlag(flags, "int-flag") + require.NotNil(t, intFlag) + assert.Equal(t, "int", intFlag.Type) + + stringSliceFlag := findFlag(flags, "string-slice-flag") + require.NotNil(t, stringSliceFlag) + assert.Equal(t, "stringArray", stringSliceFlag.Type) + + intSliceFlag := findFlag(flags, "int-slice-flag") + require.NotNil(t, intSliceFlag) + assert.Equal(t, "intArray", intSliceFlag.Type) +} + +// Helper function to find a flag by name +func findFlag(flags []extensions.Flag, name string) *extensions.Flag { + for i := range flags { + if flags[i].Name == name { + return &flags[i] + } + } + return nil +} diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 065c7e5f7f4..d6363727d15 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -139,9 +139,10 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { // Manager is responsible for managing extensions type Manager struct { - sourceManager *SourceManager - sources []Source - installed map[string]*Extension + sourceManager *SourceManager + sources []Source + installed map[string]*Extension + metadataManager *MetadataManager configManager config.UserConfigManager userConfig config.Config @@ -164,10 +165,11 @@ func NewManager( }) return &Manager{ - userConfig: userConfig, - configManager: configManager, - sourceManager: sourceManager, - pipeline: pipeline, + userConfig: userConfig, + configManager: configManager, + sourceManager: sourceManager, + pipeline: pipeline, + metadataManager: NewMetadataManager(configManager), }, nil } @@ -503,6 +505,13 @@ func (m *Manager) Install( targetPath, ) + // Fetch and cache metadata if extension supports it + installedExtension := extensions[extension.Id] + if err := m.metadataManager.FetchAndCache(ctx, installedExtension); err != nil { + // Log warning but don't fail installation + log.Printf("Warning: Failed to fetch extension metadata for '%s': %v\n", extension.Id, err) + } + return selectedVersion, nil } diff --git a/cli/azd/pkg/extensions/metadata.go b/cli/azd/pkg/extensions/metadata.go new file mode 100644 index 00000000000..3f16abdad05 --- /dev/null +++ b/cli/azd/pkg/extensions/metadata.go @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "github.com/invopop/jsonschema" +) + +// ExtensionCommandMetadata represents the complete metadata for an extension including commands and configuration +type ExtensionCommandMetadata struct { + // SchemaVersion is the version of the metadata schema (e.g., "1.0") + SchemaVersion string `json:"schemaVersion"` + // ID is the extension identifier matching extension.yaml + ID string `json:"id"` + // Version is the extension version matching extension.yaml + Version string `json:"version"` + // Commands is the list of root-level commands provided by the extension + Commands []Command `json:"commands"` + // Configuration describes extension configuration options (Phase 2) + Configuration *ConfigurationMetadata `json:"configuration,omitempty"` +} + +// Command represents a command or subcommand in the extension's command tree +type Command struct { + // Name is the command path as an array of strings (e.g., ["demo", "context"]) + Name []string `json:"name"` + // Short is a brief one-line description of the command + Short string `json:"short"` + // Long is an optional detailed multi-line description (markdown supported) + Long string `json:"long,omitempty"` + // Usage is an optional usage template string + Usage string `json:"usage,omitempty"` + // Examples contains example usages of the command + Examples []CommandExample `json:"examples,omitempty"` + // Args defines the positional arguments accepted by the command + Args []Argument `json:"args,omitempty"` + // Flags defines the flags/options accepted by the command + Flags []Flag `json:"flags,omitempty"` + // Subcommands contains nested subcommands + Subcommands []Command `json:"subcommands,omitempty"` + // Hidden indicates if the command should be hidden from help output + Hidden bool `json:"hidden,omitempty"` + // Aliases contains alternative names for the command + Aliases []string `json:"aliases,omitempty"` + // Deprecated contains a deprecation notice if the command is deprecated + Deprecated string `json:"deprecated,omitempty"` +} + +// CommandExample represents an example usage of a command +type CommandExample struct { + // Description explains what the example demonstrates + Description string `json:"description"` + // Command is the example command string + Command string `json:"command"` +} + +// Argument represents a positional argument for a command +type Argument struct { + // Name is the argument name + Name string `json:"name"` + // Description explains the purpose of the argument + Description string `json:"description"` + // Required indicates if the argument is required + Required bool `json:"required"` + // Variadic indicates if the argument accepts multiple values + Variadic bool `json:"variadic,omitempty"` + // ValidValues contains the allowed values for the argument + ValidValues []string `json:"validValues,omitempty"` +} + +// Flag represents a command-line flag/option +type Flag struct { + // Name is the flag name without dashes + Name string `json:"name"` + // Shorthand is the optional single character shorthand (without dash) + Shorthand string `json:"shorthand,omitempty"` + // Description explains the purpose of the flag + Description string `json:"description"` + // Type is the data type: "string", "bool", "int", "stringArray", "intArray" + Type string `json:"type"` + // Default is the default value when the flag is not provided + Default interface{} `json:"default,omitempty"` + // Required indicates if the flag is required + Required bool `json:"required,omitempty"` + // ValidValues contains the allowed values for the flag + ValidValues []string `json:"validValues,omitempty"` + // Hidden indicates if the flag should be hidden from help output + Hidden bool `json:"hidden,omitempty"` + // Deprecated contains a deprecation notice if the flag is deprecated + Deprecated string `json:"deprecated,omitempty"` +} + +// ConfigurationMetadata describes extension configuration options (Phase 2). +// Each field contains a JSON Schema (github.com/invopop/jsonschema) that defines +// the structure and validation rules for extension configuration at different scopes. +// +// RECOMMENDED: Extension developers should define configuration using Go types +// with json tags and generate schemas via reflection: +// +// type CustomGlobalConfig struct { +// APIKey string `json:"apiKey" jsonschema:"required,description=API key,minLength=10"` +// Timeout int `json:"timeout,omitempty" jsonschema:"minimum=1,maximum=300,default=60"` +// } +// +// config := &extensions.ConfigurationMetadata{ +// Global: jsonschema.Reflect(&CustomGlobalConfig{}), +// } +// +// Advanced: Schemas can also be built programmatically: +// +// props := jsonschema.NewProperties() +// props.Set("apiKey", &jsonschema.Schema{ +// Type: "string", +// Description: "API key for authentication", +// }) +// config := &extensions.ConfigurationMetadata{ +// Global: &jsonschema.Schema{ +// Type: "object", +// Properties: props, +// Required: []string{"apiKey"}, +// }, +// } +type ConfigurationMetadata struct { + // Global contains JSON Schema for global-level configuration options + Global *jsonschema.Schema `json:"global,omitempty"` + // Project contains JSON Schema for project-level configuration options + Project *jsonschema.Schema `json:"project,omitempty"` + // Service contains JSON Schema for service-level configuration options + Service *jsonschema.Schema `json:"service,omitempty"` +} diff --git a/cli/azd/pkg/extensions/metadata_manager.go b/cli/azd/pkg/extensions/metadata_manager.go new file mode 100644 index 00000000000..ebb18c6343f --- /dev/null +++ b/cli/azd/pkg/extensions/metadata_manager.go @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/config" +) + +const ( + metadataFileName = "metadata.json" + metadataCommandName = "metadata" + metadataTimeout = 10 * time.Second +) + +// MetadataManager handles extension metadata fetching and caching +type MetadataManager struct { + configManager config.UserConfigManager +} + +// NewMetadataManager creates a new metadata manager +func NewMetadataManager(configManager config.UserConfigManager) *MetadataManager { + return &MetadataManager{ + configManager: configManager, + } +} + +// FetchAndCache fetches metadata from an extension and caches it to disk +// Returns nil error if metadata was successfully fetched and cached, or if extension doesn't support metadata +// Returns warning-level error if metadata fetch failed (installation should still succeed) +func (m *MetadataManager) FetchAndCache( + ctx context.Context, + extension *Extension, +) error { + // Check if extension has metadata capability + if !extension.HasCapability(MetadataCapability) { + return nil // Extension doesn't support metadata - this is fine + } + + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config directory: %w", err) + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extension.Id) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + // Check if metadata.json already exists (pre-packaged) + if _, err := os.Stat(metadataPath); err == nil { + log.Printf("Extension '%s' has pre-packaged metadata.json, skipping metadata command", extension.Id) + return nil + } + + // Get extension binary path + extensionPath := filepath.Join(userConfigDir, extension.Path) + if _, err := os.Stat(extensionPath); err != nil { + return fmt.Errorf("extension binary not found at %s: %w", extensionPath, err) + } + + // Execute metadata command with timeout + cmdCtx, cancel := context.WithTimeout(ctx, metadataTimeout) + defer cancel() + + // #nosec G204 - extensionPath is validated from trusted extension installation directory + cmd := exec.CommandContext(cmdCtx, extensionPath, metadataCommandName) + output, err := cmd.Output() + if err != nil { + if cmdCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("metadata command timed out after %v", metadataTimeout) + } + return fmt.Errorf("metadata command failed: %w", err) + } + + // Parse metadata JSON + var metadata ExtensionCommandMetadata + if err := json.Unmarshal(output, &metadata); err != nil { + return fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + // Validate metadata + if metadata.ID != extension.Id { + return fmt.Errorf( + "metadata ID '%s' does not match extension ID '%s'", + metadata.ID, + extension.Id, + ) + } + + if metadata.Version != extension.Version { + log.Printf( + "Warning: metadata version '%s' does not match extension version '%s'", + metadata.Version, + extension.Version, + ) + } + + // Write metadata to cache + metadataJSON, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + if err := os.WriteFile(metadataPath, metadataJSON, 0600); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + log.Printf("Extension '%s' metadata cached successfully", extension.Id) + return nil +} + +// Load loads cached metadata for an extension +func (m *MetadataManager) Load(extensionId string) (*ExtensionCommandMetadata, error) { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get user config directory: %w", err) + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + data, err := os.ReadFile(metadataPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) + } + return nil, fmt.Errorf("failed to read metadata file: %w", err) + } + + var metadata ExtensionCommandMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + return &metadata, nil +} + +// Delete removes cached metadata for an extension +func (m *MetadataManager) Delete(extensionId string) error { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config directory: %w", err) + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + if err := os.Remove(metadataPath); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to remove metadata file: %w", err) + } + // File doesn't exist, which is fine + } + + return nil +} + +// Exists checks if cached metadata exists for an extension +func (m *MetadataManager) Exists(extensionId string) bool { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return false + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + _, err = os.Stat(metadataPath) + return err == nil +} diff --git a/cli/azd/pkg/extensions/metadata_manager_test.go b/cli/azd/pkg/extensions/metadata_manager_test.go new file mode 100644 index 00000000000..acfd81a9077 --- /dev/null +++ b/cli/azd/pkg/extensions/metadata_manager_test.go @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetadataManager_FetchAndCache_NoCapability(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + configManager := config.NewUserConfigManager(mockContext.ConfigManager) + manager := NewMetadataManager(configManager) + + extension := &Extension{ + Id: "test.extension", + Version: "1.0.0", + Capabilities: []CapabilityType{CustomCommandCapability}, + } + + err := manager.FetchAndCache(context.Background(), extension) + require.NoError(t, err, "Should not error when extension doesn't have metadata capability") +} + +func TestMetadataManager_LoadAndDelete(t *testing.T) { + // Create temp directory for test + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + mockContext := mocks.NewMockContext(context.Background()) + configManager := config.NewUserConfigManager(mockContext.ConfigManager) + manager := NewMetadataManager(configManager) + + extensionId := "test.extension" + extensionDir := filepath.Join(tempDir, "extensions", extensionId) + require.NoError(t, os.MkdirAll(extensionDir, 0755)) + + // Create test metadata + testMetadata := ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: extensionId, + Version: "1.0.0", + Commands: []Command{ + { + Name: []string{"test", "command"}, + Short: "Test command", + }, + }, + } + + metadataJSON, err := json.MarshalIndent(testMetadata, "", " ") + require.NoError(t, err) + + metadataPath := filepath.Join(extensionDir, metadataFileName) + require.NoError(t, os.WriteFile(metadataPath, metadataJSON, 0600)) + + // Test Load + loaded, err := manager.Load(extensionId) + require.NoError(t, err) + assert.Equal(t, testMetadata.SchemaVersion, loaded.SchemaVersion) + assert.Equal(t, testMetadata.ID, loaded.ID) + assert.Equal(t, testMetadata.Version, loaded.Version) + assert.Len(t, loaded.Commands, 1) + assert.Equal(t, []string{"test", "command"}, loaded.Commands[0].Name) + + // Test Exists + assert.True(t, manager.Exists(extensionId)) + + // Test Delete + err = manager.Delete(extensionId) + require.NoError(t, err) + assert.False(t, manager.Exists(extensionId)) + + // Test Delete when file doesn't exist (should not error) + err = manager.Delete(extensionId) + require.NoError(t, err) +} + +func TestMetadataManager_Load_NotFound(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + mockContext := mocks.NewMockContext(context.Background()) + configManager := config.NewUserConfigManager(mockContext.ConfigManager) + manager := NewMetadataManager(configManager) + + _, err := manager.Load("nonexistent.extension") + require.Error(t, err) + assert.Contains(t, err.Error(), "metadata not found") +} + +func TestMetadataManager_Load_InvalidJSON(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + mockContext := mocks.NewMockContext(context.Background()) + configManager := config.NewUserConfigManager(mockContext.ConfigManager) + manager := NewMetadataManager(configManager) + + extensionId := "test.extension" + extensionDir := filepath.Join(tempDir, "extensions", extensionId) + require.NoError(t, os.MkdirAll(extensionDir, 0755)) + + metadataPath := filepath.Join(extensionDir, metadataFileName) + require.NoError(t, os.WriteFile(metadataPath, []byte("invalid json"), 0600)) + + _, err := manager.Load(extensionId) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse metadata JSON") +} + +func TestMetadataManager_Exists(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + mockContext := mocks.NewMockContext(context.Background()) + configManager := config.NewUserConfigManager(mockContext.ConfigManager) + manager := NewMetadataManager(configManager) + + extensionId := "test.extension" + + // Should return false when metadata doesn't exist + assert.False(t, manager.Exists(extensionId)) + + // Create metadata file + extensionDir := filepath.Join(tempDir, "extensions", extensionId) + require.NoError(t, os.MkdirAll(extensionDir, 0755)) + metadataPath := filepath.Join(extensionDir, metadataFileName) + require.NoError(t, os.WriteFile(metadataPath, []byte("{}"), 0600)) + + // Should return true now + assert.True(t, manager.Exists(extensionId)) +} diff --git a/cli/azd/pkg/extensions/metadata_test.go b/cli/azd/pkg/extensions/metadata_test.go new file mode 100644 index 00000000000..6ff423712ed --- /dev/null +++ b/cli/azd/pkg/extensions/metadata_test.go @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "encoding/json" + "testing" + + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtensionCommandMetadata_Marshaling(t *testing.T) { + metadata := &ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "microsoft.azd.demo", + Version: "1.0.0", + Commands: []Command{ + { + Name: []string{"demo", "greet"}, + Short: "Greet the user", + Long: "This command greets the user with a friendly message.", + Usage: "greet [name]", + Examples: []CommandExample{ + { + Description: "Greet with default name", + Command: "azd x demo greet", + }, + { + Description: "Greet with custom name", + Command: "azd x demo greet Alice", + }, + }, + Args: []Argument{ + { + Name: "name", + Description: "The name to greet", + Required: false, + }, + }, + Flags: []Flag{ + { + Name: "format", + Shorthand: "f", + Description: "Output format", + Type: "string", + Default: "text", + ValidValues: []string{"text", "json"}, + }, + { + Name: "verbose", + Shorthand: "v", + Description: "Enable verbose output", + Type: "bool", + Default: false, + }, + }, + }, + }, + } + + data, err := json.Marshal(metadata) + require.NoError(t, err) + assert.Contains(t, string(data), `"schemaVersion":"1.0"`) + assert.Contains(t, string(data), `"id":"microsoft.azd.demo"`) + assert.Contains(t, string(data), `"version":"1.0.0"`) + assert.Contains(t, string(data), `"name":["demo","greet"]`) +} + +func TestExtensionMetadata_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "schemaVersion": "1.0", + "id": "microsoft.azd.demo", + "version": "1.0.0", + "commands": [ + { + "name": ["demo", "greet"], + "short": "Greet the user", + "long": "This command greets the user with a friendly message.", + "usage": "greet [name]", + "examples": [ + { + "description": "Greet with default name", + "command": "azd x demo greet" + } + ], + "args": [ + { + "name": "name", + "description": "The name to greet", + "required": false + } + ], + "flags": [ + { + "name": "format", + "shorthand": "f", + "description": "Output format", + "type": "string", + "default": "text", + "validValues": ["text", "json"] + } + ] + } + ] + }` + + var metadata ExtensionCommandMetadata + err := json.Unmarshal([]byte(jsonData), &metadata) + require.NoError(t, err) + + assert.Equal(t, "1.0", metadata.SchemaVersion) + assert.Equal(t, "microsoft.azd.demo", metadata.ID) + assert.Equal(t, "1.0.0", metadata.Version) + assert.Len(t, metadata.Commands, 1) + + cmd := metadata.Commands[0] + assert.Equal(t, []string{"demo", "greet"}, cmd.Name) + assert.Equal(t, "Greet the user", cmd.Short) + assert.Equal(t, "This command greets the user with a friendly message.", cmd.Long) + assert.Len(t, cmd.Examples, 1) + assert.Len(t, cmd.Args, 1) + assert.Len(t, cmd.Flags, 1) + + assert.Equal(t, "name", cmd.Args[0].Name) + assert.False(t, cmd.Args[0].Required) + + assert.Equal(t, "format", cmd.Flags[0].Name) + assert.Equal(t, "f", cmd.Flags[0].Shorthand) + assert.Equal(t, "string", cmd.Flags[0].Type) + assert.Equal(t, "text", cmd.Flags[0].Default) + assert.Equal(t, []string{"text", "json"}, cmd.Flags[0].ValidValues) +} + +func TestCommand_NestedSubcommands(t *testing.T) { + metadata := ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "microsoft.azd.test", + Version: "1.0.0", + Commands: []Command{ + { + Name: []string{"test"}, + Short: "Test commands", + Subcommands: []Command{ + { + Name: []string{"test", "unit"}, + Short: "Run unit tests", + }, + { + Name: []string{"test", "integration"}, + Short: "Run integration tests", + }, + }, + }, + }, + } + + data, err := json.Marshal(metadata) + require.NoError(t, err) + + var unmarshaled ExtensionCommandMetadata + err = json.Unmarshal(data, &unmarshaled) + require.NoError(t, err) + + assert.Len(t, unmarshaled.Commands, 1) + assert.Len(t, unmarshaled.Commands[0].Subcommands, 2) + assert.Equal(t, []string{"test", "unit"}, unmarshaled.Commands[0].Subcommands[0].Name) + assert.Equal(t, []string{"test", "integration"}, unmarshaled.Commands[0].Subcommands[1].Name) +} + +func TestFlag_AllTypes(t *testing.T) { + flags := []Flag{ + { + Name: "string-flag", + Type: "string", + Description: "A string flag", + Default: "default", + }, + { + Name: "bool-flag", + Type: "bool", + Description: "A boolean flag", + Default: true, + }, + { + Name: "int-flag", + Type: "int", + Description: "An integer flag", + Default: 42, + }, + { + Name: "string-array-flag", + Type: "stringArray", + Description: "A string array flag", + Default: []string{"value1", "value2"}, + }, + { + Name: "int-array-flag", + Type: "intArray", + Description: "An integer array flag", + Default: []int{1, 2, 3}, + }, + } + + data, err := json.Marshal(flags) + require.NoError(t, err) + + var unmarshaled []Flag + err = json.Unmarshal(data, &unmarshaled) + require.NoError(t, err) + + assert.Len(t, unmarshaled, 5) + assert.Equal(t, "string", unmarshaled[0].Type) + assert.Equal(t, "bool", unmarshaled[1].Type) + assert.Equal(t, "int", unmarshaled[2].Type) + assert.Equal(t, "stringArray", unmarshaled[3].Type) + assert.Equal(t, "intArray", unmarshaled[4].Type) +} + +func TestCommand_OptionalFields(t *testing.T) { + cmd := Command{ + Name: []string{"test"}, + Short: "Test command", + Hidden: true, + Aliases: []string{"t", "tst"}, + Deprecated: "Use 'new-test' instead", + } + + data, err := json.Marshal(cmd) + require.NoError(t, err) + + var unmarshaled Command + err = json.Unmarshal(data, &unmarshaled) + require.NoError(t, err) + + assert.Equal(t, []string{"test"}, unmarshaled.Name) + assert.True(t, unmarshaled.Hidden) + assert.Equal(t, []string{"t", "tst"}, unmarshaled.Aliases) + assert.Equal(t, "Use 'new-test' instead", unmarshaled.Deprecated) +} + +func TestArgument_Variadic(t *testing.T) { + arg := Argument{ + Name: "files", + Description: "Files to process", + Required: true, + Variadic: true, + ValidValues: []string{".txt", ".md"}, + } + + data, err := json.Marshal(arg) + require.NoError(t, err) + + var unmarshaled Argument + err = json.Unmarshal(data, &unmarshaled) + require.NoError(t, err) + + assert.Equal(t, "files", unmarshaled.Name) + assert.True(t, unmarshaled.Required) + assert.True(t, unmarshaled.Variadic) + assert.Equal(t, []string{".txt", ".md"}, unmarshaled.ValidValues) +} + +func TestConfigurationMetadata_Optional(t *testing.T) { + // Without configuration + metadata := ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "test", + Version: "1.0.0", + Commands: []Command{}, + } + + data1, err := json.Marshal(metadata) + require.NoError(t, err) + assert.NotContains(t, string(data1), "configuration") + + // With configuration + globalProps := jsonschema.NewProperties() + globalProps.Set("apiKey", &jsonschema.Schema{ + Type: "string", + Description: "API key for the service", + }) + + metadata2 := ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "test", + Version: "1.0.0", + Commands: []Command{}, + Configuration: &ConfigurationMetadata{ + Global: &jsonschema.Schema{ + Type: "object", + Properties: globalProps, + }, + }, + } + + data2, err := json.Marshal(metadata2) + require.NoError(t, err) + assert.Contains(t, string(data2), "configuration") + assert.Contains(t, string(data2), "global") + assert.Contains(t, string(data2), "apiKey") +} + +func TestExtensionMetadata_FutureSchemaVersion(t *testing.T) { + // Simulate future schema version with unknown fields + jsonData := `{ + "schemaVersion": "2.0", + "id": "test", + "version": "1.0.0", + "commands": [], + "newFeature": "some value", + "anotherNewField": 123 + }` + + var metadata ExtensionCommandMetadata + err := json.Unmarshal([]byte(jsonData), &metadata) + require.NoError(t, err) + + // Should parse known fields successfully + assert.Equal(t, "2.0", metadata.SchemaVersion) + assert.Equal(t, "test", metadata.ID) + assert.Equal(t, "1.0.0", metadata.Version) + assert.Empty(t, metadata.Commands) +} diff --git a/cli/azd/pkg/extensions/registry.go b/cli/azd/pkg/extensions/registry.go index 8b39342abce..3f59a37eb19 100644 --- a/cli/azd/pkg/extensions/registry.go +++ b/cli/azd/pkg/extensions/registry.go @@ -40,6 +40,8 @@ const ( ServiceTargetProviderCapability CapabilityType = "service-target-provider" // Framework service providers enable extensions to provide custom language frameworks and build systems FrameworkServiceProviderCapability CapabilityType = "framework-service-provider" + // Metadata capability enables extensions to provide comprehensive metadata about their commands and capabilities + MetadataCapability CapabilityType = "metadata" ) type ProviderType string diff --git a/cli/azd/pkg/extensions/schema_validator.go b/cli/azd/pkg/extensions/schema_validator.go new file mode 100644 index 00000000000..b13694d0f53 --- /dev/null +++ b/cli/azd/pkg/extensions/schema_validator.go @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "encoding/json" + "fmt" + + "github.com/invopop/jsonschema" + jsonschemav6 "github.com/santhosh-tekuri/jsonschema/v6" +) + +// CompileSchema compiles a JSON Schema for validation. +// Accepts either *jsonschema.Schema (from invopop/jsonschema) or json.RawMessage. +// Returns a compiled schema from santhosh-tekuri/jsonschema/v6 for validation. +func CompileSchema(schema *jsonschema.Schema) (*jsonschemav6.Schema, error) { + if schema == nil { + return nil, fmt.Errorf("schema cannot be nil") + } + + // Marshal the invopop schema to JSON + schemaBytes, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("failed to marshal schema: %w", err) + } + + var schemaData interface{} + if err := json.Unmarshal(schemaBytes, &schemaData); err != nil { + return nil, fmt.Errorf("invalid JSON schema: %w", err) + } + + // Compile the schema using santhosh-tekuri + const resourceURI = "mem://extension-config.json" + compiler := jsonschemav6.NewCompiler() + if err := compiler.AddResource(resourceURI, schemaData); err != nil { + return nil, fmt.Errorf("failed to add schema resource: %w", err) + } + + compiled, err := compiler.Compile(resourceURI) + if err != nil { + return nil, fmt.Errorf("failed to compile schema: %w", err) + } + + return compiled, nil +} + +// ValidateAgainstSchema validates data against a JSON Schema. +// Returns nil if validation succeeds, or a detailed error if it fails. +func ValidateAgainstSchema(schema *jsonschema.Schema, data interface{}) error { + compiled, err := CompileSchema(schema) + if err != nil { + return fmt.Errorf("schema compilation failed: %w", err) + } + + if err := compiled.Validate(data); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + return nil +} diff --git a/cli/azd/pkg/extensions/schema_validator_test.go b/cli/azd/pkg/extensions/schema_validator_test.go new file mode 100644 index 00000000000..1b05c98cf8b --- /dev/null +++ b/cli/azd/pkg/extensions/schema_validator_test.go @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "encoding/json" + "testing" + + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompileSchema(t *testing.T) { + props := jsonschema.NewProperties() + props.Set("apiKey", &jsonschema.Schema{ + Type: "string", + Description: "API key for authentication", + }) + props.Set("timeout", &jsonschema.Schema{ + Type: "integer", + Description: "Timeout in seconds", + Minimum: json.Number("1"), + }) + + schema := &jsonschema.Schema{ + Version: jsonschema.Version, + Type: "object", + Properties: props, + Required: []string{"apiKey"}, + } + + compiled, err := CompileSchema(schema) + require.NoError(t, err) + assert.NotNil(t, compiled) +} + +func TestCompileSchema_NilSchema(t *testing.T) { + _, err := CompileSchema(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be nil") +} + +func TestCompileSchema_InvalidSchema(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "invalid-type", + } + + _, err := CompileSchema(schema) + assert.Error(t, err) +} + +func TestValidateAgainstSchema_Success(t *testing.T) { + props := jsonschema.NewProperties() + props.Set("name", &jsonschema.Schema{Type: "string"}) + props.Set("age", &jsonschema.Schema{ + Type: "integer", + Minimum: json.Number("0"), + }) + + schema := &jsonschema.Schema{ + Type: "object", + Properties: props, + Required: []string{"name"}, + } + + validData := map[string]interface{}{ + "name": "John Doe", + "age": float64(30), + } + + err := ValidateAgainstSchema(schema, validData) + assert.NoError(t, err) +} + +func TestValidateAgainstSchema_MissingRequired(t *testing.T) { + props := jsonschema.NewProperties() + props.Set("apiKey", &jsonschema.Schema{Type: "string"}) + + schema := &jsonschema.Schema{ + Type: "object", + Properties: props, + Required: []string{"apiKey"}, + } + + invalidData := map[string]interface{}{ + "otherField": "value", + } + + err := ValidateAgainstSchema(schema, invalidData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "validation failed") +} + +func TestValidateAgainstSchema_WrongType(t *testing.T) { + props := jsonschema.NewProperties() + props.Set("count", &jsonschema.Schema{Type: "integer"}) + + schema := &jsonschema.Schema{ + Type: "object", + Properties: props, + } + + invalidData := map[string]interface{}{ + "count": "not-a-number", + } + + err := ValidateAgainstSchema(schema, invalidData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "validation failed") +} + +func TestValidateAgainstSchema_MinimumConstraint(t *testing.T) { + props := jsonschema.NewProperties() + props.Set("timeout", &jsonschema.Schema{ + Type: "integer", + Minimum: json.Number("1"), + }) + + schema := &jsonschema.Schema{ + Type: "object", + Properties: props, + } + + invalidData := map[string]interface{}{ + "timeout": float64(0), + } + + err := ValidateAgainstSchema(schema, invalidData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "validation failed") +} + +func TestValidateAgainstSchema_ComplexSchema(t *testing.T) { + serverProps := jsonschema.NewProperties() + serverProps.Set("host", &jsonschema.Schema{Type: "string"}) + serverProps.Set("port", &jsonschema.Schema{ + Type: "integer", + Minimum: json.Number("1"), + Maximum: json.Number("65535"), + }) + + props := jsonschema.NewProperties() + props.Set("server", &jsonschema.Schema{ + Type: "object", + Properties: serverProps, + Required: []string{"host", "port"}, + }) + props.Set("features", &jsonschema.Schema{ + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }) + + schema := &jsonschema.Schema{ + Version: jsonschema.Version, + Type: "object", + Properties: props, + Required: []string{"server"}, + } + + validData := map[string]interface{}{ + "server": map[string]interface{}{ + "host": "localhost", + "port": float64(8080), + }, + "features": []interface{}{"auth", "logging"}, + } + + err := ValidateAgainstSchema(schema, validData) + assert.NoError(t, err) +} + +func TestConfigurationMetadata_JSONMarshaling(t *testing.T) { + globalProps := jsonschema.NewProperties() + globalProps.Set("apiKey", &jsonschema.Schema{Type: "string"}) + + projectProps := jsonschema.NewProperties() + projectProps.Set("projectName", &jsonschema.Schema{Type: "string"}) + + config := ConfigurationMetadata{ + Global: &jsonschema.Schema{ + Type: "object", + Properties: globalProps, + }, + Project: &jsonschema.Schema{ + Type: "object", + Properties: projectProps, + }, + } + + // Test marshaling + jsonBytes, err := json.Marshal(config) + require.NoError(t, err) + assert.Contains(t, string(jsonBytes), "apiKey") + assert.Contains(t, string(jsonBytes), "projectName") + + // Test unmarshaling + var unmarshaled ConfigurationMetadata + err = json.Unmarshal(jsonBytes, &unmarshaled) + require.NoError(t, err) + assert.NotNil(t, unmarshaled.Global) + assert.NotNil(t, unmarshaled.Project) + assert.Nil(t, unmarshaled.Service) +} + +// TestConfigurationMetadata_FromGoTypes demonstrates the RECOMMENDED way for +// extension developers to define configuration schemas - using Go types with +// json tags and generating schemas via reflection. +func TestConfigurationMetadata_FromGoTypes(t *testing.T) { + // Define configuration structures using Go types + type CustomGlobalConfig struct { + APIKey string `json:"apiKey" jsonschema:"required,description=API key for authentication,minLength=10"` + // Timeout in seconds (1-300, default 60) + Timeout int `json:"timeout,omitempty" jsonschema:"minimum=1,maximum=300,default=60"` + Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging"` + } + + type CustomProjectConfig struct { + ProjectName string `json:"projectName" jsonschema:"required,description=Name of the project"` + // Deployment environment: dev, staging, or prod + Environment string `json:"environment,omitempty" jsonschema:"enum=dev,enum=staging,enum=prod"` + Features []string `json:"features,omitempty" jsonschema:"description=Enabled features"` + } + + type CustomServiceConfig struct { + Port int `json:"port" jsonschema:"required,description=Service port,minimum=1,maximum=65535"` + HostName string `json:"hostName,omitempty" jsonschema:"description=Service hostname"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Service labels"` + } + + // Generate schemas from Go types - THIS IS THE EASIEST WAY! + config := ConfigurationMetadata{ + Global: jsonschema.Reflect(&CustomGlobalConfig{}), + Project: jsonschema.Reflect(&CustomProjectConfig{}), + Service: jsonschema.Reflect(&CustomServiceConfig{}), + } + + // Verify schemas were generated correctly + require.NotNil(t, config.Global) + require.NotNil(t, config.Project) + require.NotNil(t, config.Service) + + // Test that generated schema validates correctly + validGlobalData := map[string]interface{}{ + "apiKey": "my-secret-key-123", + "timeout": float64(30), + "debug": true, + } + + err := ValidateAgainstSchema(config.Global, validGlobalData) + assert.NoError(t, err) + + // Test validation failure for required field + invalidGlobalData := map[string]interface{}{ + "timeout": float64(30), + // missing required apiKey + } + + err = ValidateAgainstSchema(config.Global, invalidGlobalData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "validation failed") + + // Test validation failure for minimum constraint + invalidGlobalData2 := map[string]interface{}{ + "apiKey": "short", // violates minLength=10 + "timeout": float64(30), + } + + err = ValidateAgainstSchema(config.Global, invalidGlobalData2) + assert.Error(t, err) + + // Test enum validation + validProjectData := map[string]interface{}{ + "projectName": "my-project", + "environment": "staging", + "features": []interface{}{"auth", "logging"}, + } + + err = ValidateAgainstSchema(config.Project, validProjectData) + assert.NoError(t, err) + + invalidProjectData := map[string]interface{}{ + "projectName": "my-project", + "environment": "invalid-env", // not in enum + } + + err = ValidateAgainstSchema(config.Project, invalidProjectData) + assert.Error(t, err) + + // Test marshaling - the schema can be serialized to JSON + jsonBytes, err := json.Marshal(config) + require.NoError(t, err) + assert.Contains(t, string(jsonBytes), "apiKey") + assert.Contains(t, string(jsonBytes), "projectName") + assert.Contains(t, string(jsonBytes), "port") +} From 336833424ab2c3d93569f4191cc12eb7b1797f08 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 12 Jan 2026 13:15:45 -0800 Subject: [PATCH 02/11] Adds support for metadata storage --- cli/azd/cmd/container.go | 13 +- .../internal/cmd/metadata.go | 1 + .../internal/config/types.go | 19 ++ .../microsoft.azd.demo/metadata.json | 165 ---------------- cli/azd/pkg/extensions/manager.go | 178 +++++++++++++++-- cli/azd/pkg/extensions/manager_test.go | 183 +++++++++++++++++- cli/azd/pkg/extensions/metadata_manager.go | 178 ----------------- .../pkg/extensions/metadata_manager_test.go | 142 -------------- 8 files changed, 371 insertions(+), 508 deletions(-) delete mode 100644 cli/azd/extensions/microsoft.azd.demo/metadata.json delete mode 100644 cli/azd/pkg/extensions/metadata_manager.go delete mode 100644 cli/azd/pkg/extensions/metadata_manager_test.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index e32c390cfa5..45d3fe5bedc 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -142,7 +142,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }, formatter, nil) }) - container.MustRegisterSingleton( + container.MustRegisterScoped( func(console input.Console, rootOptions *internal.GlobalCommandOptions) exec.CommandRunner { return exec.NewCommandRunner( &exec.RunnerOptions{ @@ -873,9 +873,16 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterScoped(prompt.NewPromptService) // Extensions - container.MustRegisterSingleton(extensions.NewManager) - container.MustRegisterSingleton(extensions.NewRunner) container.MustRegisterSingleton(extensions.NewSourceManager) + container.MustRegisterScoped(extensions.NewRunner) + container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[*extensions.Runner] { + return lazy.NewLazy(func() (*extensions.Runner, error) { + var runner *extensions.Runner + err := serviceLocator.Resolve(&runner) + return runner, err + }) + }) + container.MustRegisterSingleton(extensions.NewManager) // gRPC Server container.MustRegisterScoped(grpcserver.NewServer) diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go index 66748557827..97c2521dad1 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go @@ -52,6 +52,7 @@ func newMetadataCommand() *cobra.Command { func generateConfigurationMetadata() *extensions.ConfigurationMetadata { // Generate schemas from Go types automatically return &extensions.ConfigurationMetadata{ + Global: jsonschema.Reflect(&config.CustomGlobalConfig{}), Project: jsonschema.Reflect(&config.CustomProjectConfig{}), Service: jsonschema.Reflect(&config.CustomServiceConfig{}), } diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go index 95b581ed472..d0e38ce452f 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go @@ -3,6 +3,25 @@ package config +// CustomGlobalConfig defines global-level configuration for the demo extension +// Uses namespacing to avoid conflicts with other extensions or azd configuration +type CustomGlobalConfig struct { + // Demo extension settings namespace + Demo *DemoGlobalSettings `json:"demo,omitempty" jsonschema:"description=Demo extension global settings"` +} + +// DemoGlobalSettings contains the actual global configuration for the demo extension +type DemoGlobalSettings struct { + // API key for external services + APIKey string `json:"apiKey,omitempty" jsonschema:"description=API key for external service integration"` + // Log level for demo extension + LogLevel string `json:"logLevel,omitempty" jsonschema:"description=Logging level,enum=debug,enum=info,enum=warn,enum=error,default=info"` + // Enable telemetry collection + EnableTelemetry bool `json:"enableTelemetry,omitempty" jsonschema:"description=Enable telemetry collection,default=false"` + // Timeout for operations in seconds + Timeout int `json:"timeout,omitempty" jsonschema:"description=Operation timeout in seconds,minimum=1,maximum=300,default=30"` +} + // CustomProjectConfig defines project-level configuration for the demo extension type CustomProjectConfig struct { // Demo feature flags for project-level configuration diff --git a/cli/azd/extensions/microsoft.azd.demo/metadata.json b/cli/azd/extensions/microsoft.azd.demo/metadata.json deleted file mode 100644 index 92942bde2fe..00000000000 --- a/cli/azd/extensions/microsoft.azd.demo/metadata.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "schemaVersion": "1.0", - "id": "microsoft.azd.demo", - "version": "0.4.0", - "commands": [ - { - "name": [ - "colors" - ], - "short": "Displays all ASCII colors with their standard and high-intensity variants.", - "usage": "azd colors" - }, - { - "name": [ - "context" - ], - "short": "Get the context of the AZD project \u0026 environment.", - "usage": "azd context" - }, - { - "name": [ - "gh-url-parse" - ], - "short": "Parse a GitHub URL and extract repository information.", - "long": "Parse a GitHub URL and extract repository information including hostname, \nrepository slug, branch name, and file path. Supports various GitHub URL formats \nincluding blob, tree, raw, and API URLs. Handles branch names containing slashes.", - "usage": "azd gh-url-parse \u003cgithub-url\u003e" - }, - { - "name": [ - "listen" - ], - "short": "Starts the extension and listens for events.", - "usage": "azd listen" - }, - { - "name": [ - "mcp" - ], - "short": "MCP server commands for demo extension", - "usage": "azd mcp", - "subcommands": [ - { - "name": [ - "mcp", - "start" - ], - "short": "Start MCP server with demo tools", - "usage": "azd mcp start" - } - ] - }, - { - "name": [ - "prompt" - ], - "short": "Examples of prompting the user for input.", - "usage": "azd prompt" - }, - { - "name": [ - "version" - ], - "short": "Prints the version of the application", - "usage": "azd version" - } - ], - "configuration": { - "project": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/config/custom-project-config", - "$ref": "#/$defs/CustomProjectConfig", - "$defs": { - "CustomProjectConfig": { - "properties": { - "enableColors": { - "type": "boolean", - "description": "Enable color output", - "default": true - }, - "maxItems": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "description": "Max items to display", - "default": 10 - }, - "labels": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "description": "Custom project labels" - } - }, - "additionalProperties": false, - "type": "object" - } - } - }, - "service": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/config/custom-service-config", - "$ref": "#/$defs/CustomServiceConfig", - "$defs": { - "CustomServiceConfig": { - "properties": { - "endpoint": { - "type": "string", - "format": "uri", - "description": "Service endpoint URL" - }, - "port": { - "type": "integer", - "maximum": 65535, - "minimum": 1, - "description": "Service port", - "default": 8080 - }, - "environment": { - "type": "string", - "enum": [ - "development", - "staging", - "production" - ], - "description": "Environment" - }, - "healthCheck": { - "$ref": "#/$defs/HealthCheckConfig", - "description": "Health check configuration" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "endpoint" - ] - }, - "HealthCheckConfig": { - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable health checks", - "default": true - }, - "path": { - "type": "string", - "description": "Health check path", - "default": "/health" - }, - "interval": { - "type": "integer", - "maximum": 300, - "minimum": 5, - "description": "Check interval (sec)", - "default": 30 - } - }, - "additionalProperties": false, - "type": "object" - } - } - } - } -} diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index d6363727d15..20da12ffccb 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "crypto/sha512" "encoding/hex" + "encoding/json" "errors" "fmt" "hash" @@ -20,6 +21,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" azruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" @@ -28,6 +30,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/tracing/events" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/rzip" ) @@ -139,20 +142,22 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { // Manager is responsible for managing extensions type Manager struct { - sourceManager *SourceManager - sources []Source - installed map[string]*Extension - metadataManager *MetadataManager - + sourceManager *SourceManager + sources []Source + installed map[string]*Extension configManager config.UserConfigManager userConfig config.Config pipeline azruntime.Pipeline + + // Lazy runner to avoid circular dependency issues since extension manager is used during command bootstrapping + lazyRunner *lazy.Lazy[*Runner] } // NewManager creates a new extension manager func NewManager( configManager config.UserConfigManager, sourceManager *SourceManager, + lazyRunner *lazy.Lazy[*Runner], transport policy.Transporter, ) (*Manager, error) { userConfig, err := configManager.Load() @@ -165,11 +170,11 @@ func NewManager( }) return &Manager{ - userConfig: userConfig, - configManager: configManager, - sourceManager: sourceManager, - pipeline: pipeline, - metadataManager: NewMetadataManager(configManager), + userConfig: userConfig, + configManager: configManager, + sourceManager: sourceManager, + lazyRunner: lazyRunner, + pipeline: pipeline, }, nil } @@ -507,7 +512,7 @@ func (m *Manager) Install( // Fetch and cache metadata if extension supports it installedExtension := extensions[extension.Id] - if err := m.metadataManager.FetchAndCache(ctx, installedExtension); err != nil { + if err := m.fetchAndCacheMetadata(ctx, installedExtension); err != nil { // Log warning but don't fail installation log.Printf("Warning: Failed to fetch extension metadata for '%s': %v\n", extension.Id, err) } @@ -786,3 +791,154 @@ func copyFile(src, dst string) error { return nil } + +const ( + metadataFileName = "metadata.json" + metadataCommandName = "metadata" + metadataTimeout = 10 * time.Second +) + +// fetchAndCacheMetadata fetches metadata from an extension and caches it to disk +// Returns nil error if metadata was successfully fetched and cached, or if extension doesn't support metadata +// Returns warning-level error if metadata fetch failed (installation should still succeed) +func (m *Manager) fetchAndCacheMetadata( + ctx context.Context, + extension *Extension, +) error { + // Check if extension has metadata capability + if !extension.HasCapability(MetadataCapability) { + return nil // Extension doesn't support metadata - this is fine + } + + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config directory: %w", err) + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extension.Id) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + // Check if metadata.json already exists (pre-packaged) + if _, err := os.Stat(metadataPath); err == nil { + log.Printf("Extension '%s' has pre-packaged metadata.json, skipping metadata command", extension.Id) + return nil + } + + // Execute metadata command with timeout using the runner + cmdCtx, cancel := context.WithTimeout(ctx, metadataTimeout) + defer cancel() + + runner, err := m.lazyRunner.GetValue() + if err != nil { + return fmt.Errorf("failed to resolve extension runner: %w", err) + } + + runResult, err := runner.Invoke(cmdCtx, extension, &InvokeOptions{ + Args: []string{metadataCommandName}, + }) + if err != nil { + if cmdCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("metadata command timed out after %v", metadataTimeout) + } + return fmt.Errorf("metadata command failed: %w", err) + } + if runResult.ExitCode != 0 { + return fmt.Errorf("metadata command exited with code %d", runResult.ExitCode) + } + + // Parse metadata JSON from stdout + var metadata ExtensionCommandMetadata + if err := json.Unmarshal([]byte(runResult.Stdout), &metadata); err != nil { + return fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + // Validate metadata + if metadata.ID != extension.Id { + return fmt.Errorf( + "metadata ID '%s' does not match extension ID '%s'", + metadata.ID, + extension.Id, + ) + } + + if metadata.Version != extension.Version { + log.Printf( + "Warning: metadata version '%s' does not match extension version '%s'", + metadata.Version, + extension.Version, + ) + } + + // Write metadata to cache + metadataJSON, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + if err := os.WriteFile(metadataPath, metadataJSON, 0600); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + log.Printf("Extension '%s' metadata cached successfully", extension.Id) + return nil +} + +// LoadMetadata loads cached metadata for an extension +func (m *Manager) LoadMetadata(extensionId string) (*ExtensionCommandMetadata, error) { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get user config directory: %w", err) + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + data, err := os.ReadFile(metadataPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) + } + return nil, fmt.Errorf("failed to read metadata file: %w", err) + } + + var metadata ExtensionCommandMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + return &metadata, nil +} + +// DeleteMetadata removes cached metadata for an extension +func (m *Manager) DeleteMetadata(extensionId string) error { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config directory: %w", err) + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + if err := os.Remove(metadataPath); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to remove metadata file: %w", err) + } + // File doesn't exist, which is fine + } + + return nil +} + +// MetadataExists checks if cached metadata exists for an extension +func (m *Manager) MetadataExists(extensionId string) bool { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return false + } + + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + _, err = os.Stat(metadataPath) + return err == nil +} diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index b0ecb971dd5..2ed32f1ea98 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -8,12 +8,16 @@ import ( "crypto/sha256" "crypto/sha512" "encoding/hex" + "encoding/json" "net/http" "os" + "path/filepath" "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" ) @@ -147,7 +151,10 @@ func Test_List_Install_Uninstall_Flow(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) // List installed extensions (expect 0) @@ -191,7 +198,10 @@ func Test_Install_With_SemverConstraints(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) // Generate a list of tests cases to validate the semver constraints of the Install function. @@ -295,7 +305,10 @@ func Test_DownloadArtifact_Remote(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) tempFilePath, err := manager.downloadArtifact(*mockContext.Context, "https://example.com/artifact.zip") @@ -321,7 +334,10 @@ func Test_DownloadArtifact_Local(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) tempFilePath, err := manager.downloadArtifact(*mockContext.Context, tempFile.Name()) @@ -337,7 +353,10 @@ func Test_DownloadArtifact_Local_Error(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) // Provide an invalid local file path @@ -361,7 +380,10 @@ func Test_DownloadArtifact_Remote_Error(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) tempFilePath, err := manager.downloadArtifact(*mockContext.Context, "https://example.com/invalid-artifact.zip") @@ -641,7 +663,10 @@ func Test_FindExtensions_MultipleMatches_ErrorHandling(t *testing.T) { }) // Create a manager and mock two sources - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) // Create mock sources that will return the same extension @@ -719,7 +744,10 @@ func Test_Install_WithMcpConfig(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) // Install extension with MCP configuration @@ -781,7 +809,10 @@ func Test_FilterExtensions_ByCapabilityAndProvider(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) - manager, err := NewManager(userConfigManager, sourceManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) require.NoError(t, err) t.Run("filter by service-target-provider capability", func(t *testing.T) { @@ -912,3 +943,137 @@ func Test_FilterExtensions_ByCapabilityAndProvider(t *testing.T) { require.Len(t, extensions, 0, "Should find no extensions with MCP capability AND containerapp provider") }) } + +func Test_FetchAndCacheMetadata(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + createRegistryMocks(mockContext) + + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) + + // Create a temporary directory for test extensions + tempDir := t.TempDir() + extDir := filepath.Join(tempDir, "extensions", "test.metadata.extension") + err := os.MkdirAll(extDir, os.ModePerm) + require.NoError(t, err) + + // Create a dummy extension binary + extBinary := filepath.Join(extDir, "test-ext.exe") + err = os.WriteFile(extBinary, []byte("fake binary"), 0755) + require.NoError(t, err) + + // Get relative path from temp dir + relPath, err := filepath.Rel(tempDir, extBinary) + require.NoError(t, err) + + // Override user config directory for this test + t.Setenv("AZD_CONFIG_DIR", tempDir) + + // Create a mock extension that supports metadata capability + extension := &Extension{ + Id: "test.metadata.extension", + Namespace: "test", + DisplayName: "Test Metadata Extension", + Version: "1.0.0", + Path: relPath, + Capabilities: []CapabilityType{MetadataCapability}, + } + + // Mock the runner to return metadata JSON + mockMetadata := ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "test.metadata.extension", + Version: "1.0.0", + Commands: []Command{ + { + Name: []string{"test"}, + Short: "Test command", + Usage: "azd test", + }, + }, + } + + metadataJSON, err := json.Marshal(mockMetadata) + require.NoError(t, err) + + // Mock CommandRunner to return metadata JSON + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "metadata") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: string(metadataJSON), + ExitCode: 0, + }, nil + }) + + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + t.Run("fetch metadata for extension with metadata capability", func(t *testing.T) { + // Install extension first (simulate by adding to config) + extensions, err := manager.ListInstalled() + require.NoError(t, err) + extensions[extension.Id] = extension + err = manager.userConfig.Set(installedConfigKey, extensions) + require.NoError(t, err) + + // Fetch and cache metadata + err = manager.fetchAndCacheMetadata(*mockContext.Context, extension) + require.NoError(t, err) + + // Verify metadata exists + exists := manager.MetadataExists(extension.Id) + require.True(t, exists, "Metadata should exist after fetch") + + // Load and verify metadata + loadedMetadata, err := manager.LoadMetadata(extension.Id) + require.NoError(t, err) + require.NotNil(t, loadedMetadata) + require.Equal(t, mockMetadata.ID, loadedMetadata.ID) + require.Equal(t, mockMetadata.Version, loadedMetadata.Version) + require.Len(t, loadedMetadata.Commands, 1) + require.Equal(t, "test", loadedMetadata.Commands[0].Name[0]) + }) + + t.Run("skip metadata fetch for extension without metadata capability", func(t *testing.T) { + extensionNoMetadata := &Extension{ + Id: "test.no.metadata", + Namespace: "test", + DisplayName: "Test No Metadata Extension", + Version: "1.0.0", + Path: "extensions/test.no.metadata/test-ext.exe", + Capabilities: []CapabilityType{}, // No metadata capability + } + + // Should not error even though extension doesn't support metadata + err := manager.fetchAndCacheMetadata(*mockContext.Context, extensionNoMetadata) + require.NoError(t, err) + + // Metadata should not exist + exists := manager.MetadataExists(extensionNoMetadata.Id) + require.False(t, exists, "Metadata should not exist for extension without capability") + }) + + t.Run("delete metadata", func(t *testing.T) { + // Ensure metadata exists first + exists := manager.MetadataExists(extension.Id) + require.True(t, exists) + + // Delete metadata + err := manager.DeleteMetadata(extension.Id) + require.NoError(t, err) + + // Verify metadata no longer exists + exists = manager.MetadataExists(extension.Id) + require.False(t, exists, "Metadata should not exist after deletion") + }) + + t.Run("load non-existent metadata returns error", func(t *testing.T) { + _, err := manager.LoadMetadata("non.existent.extension") + require.Error(t, err) + require.Contains(t, err.Error(), "metadata not found") + }) +} diff --git a/cli/azd/pkg/extensions/metadata_manager.go b/cli/azd/pkg/extensions/metadata_manager.go deleted file mode 100644 index ebb18c6343f..00000000000 --- a/cli/azd/pkg/extensions/metadata_manager.go +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package extensions - -import ( - "context" - "encoding/json" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/azure/azure-dev/cli/azd/pkg/config" -) - -const ( - metadataFileName = "metadata.json" - metadataCommandName = "metadata" - metadataTimeout = 10 * time.Second -) - -// MetadataManager handles extension metadata fetching and caching -type MetadataManager struct { - configManager config.UserConfigManager -} - -// NewMetadataManager creates a new metadata manager -func NewMetadataManager(configManager config.UserConfigManager) *MetadataManager { - return &MetadataManager{ - configManager: configManager, - } -} - -// FetchAndCache fetches metadata from an extension and caches it to disk -// Returns nil error if metadata was successfully fetched and cached, or if extension doesn't support metadata -// Returns warning-level error if metadata fetch failed (installation should still succeed) -func (m *MetadataManager) FetchAndCache( - ctx context.Context, - extension *Extension, -) error { - // Check if extension has metadata capability - if !extension.HasCapability(MetadataCapability) { - return nil // Extension doesn't support metadata - this is fine - } - - userConfigDir, err := config.GetUserConfigDir() - if err != nil { - return fmt.Errorf("failed to get user config directory: %w", err) - } - - extensionDir := filepath.Join(userConfigDir, "extensions", extension.Id) - metadataPath := filepath.Join(extensionDir, metadataFileName) - - // Check if metadata.json already exists (pre-packaged) - if _, err := os.Stat(metadataPath); err == nil { - log.Printf("Extension '%s' has pre-packaged metadata.json, skipping metadata command", extension.Id) - return nil - } - - // Get extension binary path - extensionPath := filepath.Join(userConfigDir, extension.Path) - if _, err := os.Stat(extensionPath); err != nil { - return fmt.Errorf("extension binary not found at %s: %w", extensionPath, err) - } - - // Execute metadata command with timeout - cmdCtx, cancel := context.WithTimeout(ctx, metadataTimeout) - defer cancel() - - // #nosec G204 - extensionPath is validated from trusted extension installation directory - cmd := exec.CommandContext(cmdCtx, extensionPath, metadataCommandName) - output, err := cmd.Output() - if err != nil { - if cmdCtx.Err() == context.DeadlineExceeded { - return fmt.Errorf("metadata command timed out after %v", metadataTimeout) - } - return fmt.Errorf("metadata command failed: %w", err) - } - - // Parse metadata JSON - var metadata ExtensionCommandMetadata - if err := json.Unmarshal(output, &metadata); err != nil { - return fmt.Errorf("failed to parse metadata JSON: %w", err) - } - - // Validate metadata - if metadata.ID != extension.Id { - return fmt.Errorf( - "metadata ID '%s' does not match extension ID '%s'", - metadata.ID, - extension.Id, - ) - } - - if metadata.Version != extension.Version { - log.Printf( - "Warning: metadata version '%s' does not match extension version '%s'", - metadata.Version, - extension.Version, - ) - } - - // Write metadata to cache - metadataJSON, err := json.MarshalIndent(metadata, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal metadata: %w", err) - } - - if err := os.WriteFile(metadataPath, metadataJSON, 0600); err != nil { - return fmt.Errorf("failed to write metadata file: %w", err) - } - - log.Printf("Extension '%s' metadata cached successfully", extension.Id) - return nil -} - -// Load loads cached metadata for an extension -func (m *MetadataManager) Load(extensionId string) (*ExtensionCommandMetadata, error) { - userConfigDir, err := config.GetUserConfigDir() - if err != nil { - return nil, fmt.Errorf("failed to get user config directory: %w", err) - } - - extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) - metadataPath := filepath.Join(extensionDir, metadataFileName) - - data, err := os.ReadFile(metadataPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) - } - return nil, fmt.Errorf("failed to read metadata file: %w", err) - } - - var metadata ExtensionCommandMetadata - if err := json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) - } - - return &metadata, nil -} - -// Delete removes cached metadata for an extension -func (m *MetadataManager) Delete(extensionId string) error { - userConfigDir, err := config.GetUserConfigDir() - if err != nil { - return fmt.Errorf("failed to get user config directory: %w", err) - } - - extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) - metadataPath := filepath.Join(extensionDir, metadataFileName) - - if err := os.Remove(metadataPath); err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("failed to remove metadata file: %w", err) - } - // File doesn't exist, which is fine - } - - return nil -} - -// Exists checks if cached metadata exists for an extension -func (m *MetadataManager) Exists(extensionId string) bool { - userConfigDir, err := config.GetUserConfigDir() - if err != nil { - return false - } - - extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) - metadataPath := filepath.Join(extensionDir, metadataFileName) - - _, err = os.Stat(metadataPath) - return err == nil -} diff --git a/cli/azd/pkg/extensions/metadata_manager_test.go b/cli/azd/pkg/extensions/metadata_manager_test.go deleted file mode 100644 index acfd81a9077..00000000000 --- a/cli/azd/pkg/extensions/metadata_manager_test.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package extensions - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/azure/azure-dev/cli/azd/pkg/config" - "github.com/azure/azure-dev/cli/azd/test/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMetadataManager_FetchAndCache_NoCapability(t *testing.T) { - mockContext := mocks.NewMockContext(context.Background()) - configManager := config.NewUserConfigManager(mockContext.ConfigManager) - manager := NewMetadataManager(configManager) - - extension := &Extension{ - Id: "test.extension", - Version: "1.0.0", - Capabilities: []CapabilityType{CustomCommandCapability}, - } - - err := manager.FetchAndCache(context.Background(), extension) - require.NoError(t, err, "Should not error when extension doesn't have metadata capability") -} - -func TestMetadataManager_LoadAndDelete(t *testing.T) { - // Create temp directory for test - tempDir := t.TempDir() - t.Setenv("AZD_CONFIG_DIR", tempDir) - - mockContext := mocks.NewMockContext(context.Background()) - configManager := config.NewUserConfigManager(mockContext.ConfigManager) - manager := NewMetadataManager(configManager) - - extensionId := "test.extension" - extensionDir := filepath.Join(tempDir, "extensions", extensionId) - require.NoError(t, os.MkdirAll(extensionDir, 0755)) - - // Create test metadata - testMetadata := ExtensionCommandMetadata{ - SchemaVersion: "1.0", - ID: extensionId, - Version: "1.0.0", - Commands: []Command{ - { - Name: []string{"test", "command"}, - Short: "Test command", - }, - }, - } - - metadataJSON, err := json.MarshalIndent(testMetadata, "", " ") - require.NoError(t, err) - - metadataPath := filepath.Join(extensionDir, metadataFileName) - require.NoError(t, os.WriteFile(metadataPath, metadataJSON, 0600)) - - // Test Load - loaded, err := manager.Load(extensionId) - require.NoError(t, err) - assert.Equal(t, testMetadata.SchemaVersion, loaded.SchemaVersion) - assert.Equal(t, testMetadata.ID, loaded.ID) - assert.Equal(t, testMetadata.Version, loaded.Version) - assert.Len(t, loaded.Commands, 1) - assert.Equal(t, []string{"test", "command"}, loaded.Commands[0].Name) - - // Test Exists - assert.True(t, manager.Exists(extensionId)) - - // Test Delete - err = manager.Delete(extensionId) - require.NoError(t, err) - assert.False(t, manager.Exists(extensionId)) - - // Test Delete when file doesn't exist (should not error) - err = manager.Delete(extensionId) - require.NoError(t, err) -} - -func TestMetadataManager_Load_NotFound(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("AZD_CONFIG_DIR", tempDir) - - mockContext := mocks.NewMockContext(context.Background()) - configManager := config.NewUserConfigManager(mockContext.ConfigManager) - manager := NewMetadataManager(configManager) - - _, err := manager.Load("nonexistent.extension") - require.Error(t, err) - assert.Contains(t, err.Error(), "metadata not found") -} - -func TestMetadataManager_Load_InvalidJSON(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("AZD_CONFIG_DIR", tempDir) - - mockContext := mocks.NewMockContext(context.Background()) - configManager := config.NewUserConfigManager(mockContext.ConfigManager) - manager := NewMetadataManager(configManager) - - extensionId := "test.extension" - extensionDir := filepath.Join(tempDir, "extensions", extensionId) - require.NoError(t, os.MkdirAll(extensionDir, 0755)) - - metadataPath := filepath.Join(extensionDir, metadataFileName) - require.NoError(t, os.WriteFile(metadataPath, []byte("invalid json"), 0600)) - - _, err := manager.Load(extensionId) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse metadata JSON") -} - -func TestMetadataManager_Exists(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("AZD_CONFIG_DIR", tempDir) - - mockContext := mocks.NewMockContext(context.Background()) - configManager := config.NewUserConfigManager(mockContext.ConfigManager) - manager := NewMetadataManager(configManager) - - extensionId := "test.extension" - - // Should return false when metadata doesn't exist - assert.False(t, manager.Exists(extensionId)) - - // Create metadata file - extensionDir := filepath.Join(tempDir, "extensions", extensionId) - require.NoError(t, os.MkdirAll(extensionDir, 0755)) - metadataPath := filepath.Join(extensionDir, metadataFileName) - require.NoError(t, os.WriteFile(metadataPath, []byte("{}"), 0600)) - - // Should return true now - assert.True(t, manager.Exists(extensionId)) -} From 7587219ff641455c71e399f74b28c2ff7df695e7 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 12 Jan 2026 13:20:00 -0800 Subject: [PATCH 03/11] Generate metadata for hidden commands --- cli/azd/pkg/azdext/metadata_generator.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go index 72d54b9b3ce..2ec6fadc138 100644 --- a/cli/azd/pkg/azdext/metadata_generator.go +++ b/cli/azd/pkg/azdext/metadata_generator.go @@ -25,11 +25,6 @@ func generateCommands(cmd *cobra.Command) []extensions.Command { var commands []extensions.Command for _, subCmd := range cmd.Commands() { - // Skip hidden commands - if subCmd.Hidden { - continue - } - command := generateCommand(subCmd) commands = append(commands, command) } From dba73ccdb93ac4e262bee17ec86cafd91814c5e9 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 12 Jan 2026 13:26:36 -0800 Subject: [PATCH 04/11] Fixes lint issues --- cli/azd/pkg/azdext/metadata_generator.go | 4 ++++ cli/azd/pkg/extensions/manager_test.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go index 2ec6fadc138..72c7b16d50d 100644 --- a/cli/azd/pkg/azdext/metadata_generator.go +++ b/cli/azd/pkg/azdext/metadata_generator.go @@ -25,6 +25,10 @@ func generateCommands(cmd *cobra.Command) []extensions.Command { var commands []extensions.Command for _, subCmd := range cmd.Commands() { + // Skip hidden commands + if subCmd.Hidden { + continue + } command := generateCommand(subCmd) commands = append(commands, command) } diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 2ed32f1ea98..673d17f11e4 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -959,7 +959,7 @@ func Test_FetchAndCacheMetadata(t *testing.T) { // Create a dummy extension binary extBinary := filepath.Join(extDir, "test-ext.exe") - err = os.WriteFile(extBinary, []byte("fake binary"), 0755) + err = os.WriteFile(extBinary, []byte("fake binary"), 0600) require.NoError(t, err) // Get relative path from temp dir From f29616bfed1b89878e3bb8bd9f6dd297a4f657d7 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 12 Jan 2026 13:33:43 -0800 Subject: [PATCH 05/11] Rollback some unnesscary changes --- cli/azd/cmd/container.go | 8 +-- .../internal/cmd/coding_agent_config.go | 40 +++--------- .../internal/cmd/coding_agent_config_test.go | 64 ++++--------------- 3 files changed, 26 insertions(+), 86 deletions(-) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 45d3fe5bedc..cc143dd6847 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -142,7 +142,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }, formatter, nil) }) - container.MustRegisterScoped( + container.MustRegisterSingleton( func(console input.Console, rootOptions *internal.GlobalCommandOptions) exec.CommandRunner { return exec.NewCommandRunner( &exec.RunnerOptions{ @@ -873,16 +873,16 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterScoped(prompt.NewPromptService) // Extensions + container.MustRegisterSingleton(extensions.NewManager) container.MustRegisterSingleton(extensions.NewSourceManager) - container.MustRegisterScoped(extensions.NewRunner) - container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[*extensions.Runner] { + container.MustRegisterSingleton(extensions.NewRunner) + container.MustRegisterSingleton(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[*extensions.Runner] { return lazy.NewLazy(func() (*extensions.Runner, error) { var runner *extensions.Runner err := serviceLocator.Resolve(&runner) return runner, err }) }) - container.MustRegisterSingleton(extensions.NewManager) // gRPC Server container.MustRegisterScoped(grpcserver.NewServer) diff --git a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go index 0de059b34d5..6e5704101d1 100644 --- a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go +++ b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config.go @@ -114,19 +114,14 @@ func newConfigCommand() *cobra.Command { cc := &cobra.Command{ Use: "config", Short: "Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP", - Long: "Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP.\n\nFor more information about this command, including prerequisites and troubleshooting, view the readme at " + ux.Hyperlink( - "https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.coding-agent/README.md", - ), + Long: "Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP.\n\nFor more information about this command, including prerequisites and troubleshooting, view the readme at " + ux.Hyperlink("https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.coding-agent/README.md"), } flagValues := setupFlags(cc.Flags()) cc.RunE = func(cmd *cobra.Command, args []string) error { if err := runConfigCommand(cmd, flagValues); err != nil { - message := fmt.Sprintf( - "(!) An error occurred, see the readme for troubleshooting and prerequisites:\n %s", - ux.Hyperlink(readmeURL), - ) //nolint:lll + message := fmt.Sprintf("(!) An error occurred, see the readme for troubleshooting and prerequisites:\n %s", ux.Hyperlink(readmeURL)) //nolint:lll fmt.Println(ux.BoldString(message)) return err } @@ -190,10 +185,7 @@ func runConfigCommand(cmd *cobra.Command, flagValues *flagValues) error { if err != nil { //nolint:lll - return fmt.Errorf( - "failed getting a subscription from prompt. Try logging in manually with 'azd auth login' before running this command %w", - err, - ) + return fmt.Errorf("failed getting a subscription from prompt. Try logging in manually with 'azd auth login' before running this command %w", err) } tenantID := subscriptionResponse.Subscription.TenantId @@ -257,16 +249,8 @@ func runConfigCommand(cmd *cobra.Command, flagValues *flagValues) error { return fmt.Errorf("failed to push files to git: %w", err) } - codingAgentURL := fmt.Sprintf( - "https://github.com/%s/settings/copilot/coding_agent#:~:text=JSON%%20MCP%%20configuration-,MCP%%20configuration,-1", - repoSlug, - ) //nolint:lll - managedIdentityPortalURL := formatPortalLinkForManagedIdentity( - tenantID, - subscriptionID, - authConfig.ResourceGroup, - authConfig.Name, - ) //nolint:lll + codingAgentURL := fmt.Sprintf("https://github.com/%s/settings/copilot/coding_agent#:~:text=JSON%%20MCP%%20configuration-,MCP%%20configuration,-1", repoSlug) //nolint:lll + managedIdentityPortalURL := formatPortalLinkForManagedIdentity(tenantID, subscriptionID, authConfig.ResourceGroup, authConfig.Name) //nolint:lll fmt.Println("") fmt.Println(output.WithHighLightFormat("(!)")) @@ -274,10 +258,7 @@ func runConfigCommand(cmd *cobra.Command, flagValues *flagValues) error { fmt.Println(output.WithHighLightFormat("(!)")) fmt.Println("") fmt.Printf("1. The branch created at %s/%s must be merged to %s/main\n", remote, flagValues.BranchName, repoSlug) - fmt.Printf( - "2. Configure Copilot coding agent's managed identity roles in the Azure portal: %s\n", - ux.Hyperlink(managedIdentityPortalURL), - ) // nolint:lll + fmt.Printf("2. Configure Copilot coding agent's managed identity roles in the Azure portal: %s\n", ux.Hyperlink(managedIdentityPortalURL)) // nolint:lll fmt.Printf("3. Visit '%s' and update the \"MCP configuration\" field with this JSON:\n\n", ux.Hyperlink(codingAgentURL)) fmt.Println(mcpJson) @@ -313,8 +294,7 @@ func openBrowserWindows(ctx context.Context, return nil } - fullURL := fmt.Sprintf( - "https://github.com/%s/compare/main...azd-enable-copilot-coding-agent-with-azure?body=%s&expand=1&title=%s", + fullURL := fmt.Sprintf("https://github.com/%s/compare/main...azd-enable-copilot-coding-agent-with-azure?body=%s&expand=1&title=%s", repoSlug, url.QueryEscape(fmt.Sprintf(prBodyMD, codingAgentURL, mcpJson)), url.QueryEscape("Updating/adding copilot-setup-steps.yaml to enable the Copilot coding agent to access Azure"), @@ -983,11 +963,9 @@ func formatPortalLinkForManagedIdentity(tenantID string, resourceGroupName string, managedIdentityName string) string { //nolint:lll - return fmt.Sprintf( - "https://portal.azure.com/#@%s/resource/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s/azure_resources", + return fmt.Sprintf("https://portal.azure.com/#@%s/resource/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s/azure_resources", tenantID, subscriptionID, resourceGroupName, - managedIdentityName, - ) + managedIdentityName) } diff --git a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go index 572333a3e83..c8edce23eeb 100644 --- a/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go +++ b/cli/azd/extensions/azure.coding-agent/internal/cmd/coding_agent_config_test.go @@ -101,13 +101,7 @@ func TestCodingAgent_promptForRepoSlug(t *testing.T) { promptClient := NewMockPromptServiceClient(ctrl) gitCLI := NewMockgitCLI(ctrl) - slug, err := promptForCodingAgentRepoSlug( - context.Background(), - promptClient, - gitCLI, - "repo-root-ignored", - repoSlugForTests, - ) //nolint:lll + slug, err := promptForCodingAgentRepoSlug(context.Background(), promptClient, gitCLI, "repo-root-ignored", repoSlugForTests) //nolint:lll require.Equal(t, slug, repoSlugForTests) require.NoError(t, err) @@ -120,13 +114,9 @@ func TestCodingAgent_promptForRepoSlug(t *testing.T) { gitCLI.EXPECT().ListRemotes(gomock.Any(), "repo-root-used").Return([]string{"origin", "upstream"}, nil) //nolint:lll - gitCLI.EXPECT(). - GetRemoteUrl(gomock.Any(), "repo-root-used", "origin"). - Return("https://github.com/richardpark-msft/tawnygardenslug", nil) + gitCLI.EXPECT().GetRemoteUrl(gomock.Any(), "repo-root-used", "origin").Return("https://github.com/richardpark-msft/tawnygardenslug", nil) //nolint:lll - gitCLI.EXPECT(). - GetRemoteUrl(gomock.Any(), "repo-root-used", "upstream"). - Return("https://github.com/slugs/tawnygardenslug", nil) + gitCLI.EXPECT().GetRemoteUrl(gomock.Any(), "repo-root-used", "upstream").Return("https://github.com/slugs/tawnygardenslug", nil) promptClient.EXPECT().Select(gomock.Any(), gomock.Any()).Return(&azdext.SelectResponse{ // simulate they chose option 1 @@ -226,10 +216,7 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { promptService, msiService, entraService, resourceService := setup(t) promptService.EXPECT().Select(gomock.Any(), - SelectMatcher{ - T: t, - ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?", - }). + SelectMatcher{T: t, ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?"}). Return(&azdext.SelectResponse{ Value: to.Ptr(int32(0)), // ie, "create a new one" }, nil) @@ -243,14 +230,7 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { TenantID: to.Ptr(fakeTenantID), }, Name: to.Ptr(string(defaultManagedIdentityName)), - ID: to.Ptr( - fmt.Sprintf( - "/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", - fakeSubscriptionID, - fakeResourceGroup, - defaultManagedIdentityName, - ), - ), + ID: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", fakeSubscriptionID, fakeResourceGroup, defaultManagedIdentityName)), }, nil) entraService.EXPECT().EnsureRoleAssignments(gomock.Any(), @@ -293,21 +273,13 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { TenantID: to.Ptr(fakeTenantID), }, Name: to.Ptr(customManagedIdentityName), - ID: to.Ptr( - fmt.Sprintf( - "/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", - fakeSubscriptionID, - fakeResourceGroup, - customManagedIdentityName, - ), - ), + ID: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", fakeSubscriptionID, fakeResourceGroup, customManagedIdentityName)), }, nil) - entraService.EXPECT(). - EnsureRoleAssignments(gomock.Any(), fakeSubscriptionID, []string{"custom-role-name"}, &graphsdk.ServicePrincipal{ - Id: to.Ptr("principal-id"), - DisplayName: customManagedIdentityName, - }, gomock.Any()). + entraService.EXPECT().EnsureRoleAssignments(gomock.Any(), fakeSubscriptionID, []string{"custom-role-name"}, &graphsdk.ServicePrincipal{ + Id: to.Ptr("principal-id"), + DisplayName: customManagedIdentityName, + }, gomock.Any()). Return(nil) authConfig, err := pickOrCreateMSI(context.Background(), @@ -331,10 +303,7 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { promptService, msiService, entraService, resourceService := setup(t) promptService.EXPECT().Select(gomock.Any(), - SelectMatcher{ - T: t, - ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?", - }). + SelectMatcher{T: t, ExpectedQuestion: "Do you want to create a new Azure user-assigned managed identity or use an existing one?"}). Return(&azdext.SelectResponse{ Value: to.Ptr(int32(1)), // ie, "use an existing one" }, nil) @@ -348,15 +317,8 @@ func TestCodingAgent_pickOrCreateMSI(t *testing.T) { ClientID: to.Ptr(fakeClientID), TenantID: to.Ptr(fakeTenantID), }, - Name: to.Ptr(string(defaultManagedIdentityName)), - ID: to.Ptr( - fmt.Sprintf( - "/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", - fakeSubscriptionID, - fakeResourceGroup, - defaultManagedIdentityName, - ), - ), + Name: to.Ptr(string(defaultManagedIdentityName)), + ID: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourcegroups/%s/providers/Microsoft.ManagedIdentity/userAssignedIdentities/%s", fakeSubscriptionID, fakeResourceGroup, defaultManagedIdentityName)), Location: to.Ptr(fakeLocation), }, }, nil) From f1108c7f3fb09b23619379a9f5614718c8bcf5a2 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 12 Jan 2026 13:47:23 -0800 Subject: [PATCH 06/11] Removes extension version from metadata schema --- .../microsoft.azd.demo/internal/cmd/metadata.go | 1 - .../microsoft.azd.demo/internal/config/types.go | 16 ++++++++-------- cli/azd/pkg/azdext/metadata_generator.go | 3 +-- cli/azd/pkg/azdext/metadata_generator_test.go | 17 ++++++++--------- cli/azd/pkg/extensions/manager.go | 8 -------- cli/azd/pkg/extensions/manager_test.go | 2 -- cli/azd/pkg/extensions/metadata.go | 2 -- cli/azd/pkg/extensions/metadata_test.go | 7 ------- 8 files changed, 17 insertions(+), 39 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go index 97c2521dad1..b595155023d 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go @@ -27,7 +27,6 @@ func newMetadataCommand() *cobra.Command { metadata := azdext.GenerateExtensionMetadata( "1.0", // schema version "microsoft.azd.demo", // extension id - "0.4.0", // extension version rootCmd, ) diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go index d0e38ce452f..26723a18797 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go @@ -13,13 +13,13 @@ type CustomGlobalConfig struct { // DemoGlobalSettings contains the actual global configuration for the demo extension type DemoGlobalSettings struct { // API key for external services - APIKey string `json:"apiKey,omitempty" jsonschema:"description=API key for external service integration"` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=API key for service integration"` // Log level for demo extension - LogLevel string `json:"logLevel,omitempty" jsonschema:"description=Logging level,enum=debug,enum=info,enum=warn,enum=error,default=info"` + LogLevel string `json:"logLevel,omitempty" jsonschema:"description=Log level,enum=debug,enum=info,enum=warn,enum=error"` // Enable telemetry collection - EnableTelemetry bool `json:"enableTelemetry,omitempty" jsonschema:"description=Enable telemetry collection,default=false"` + EnableTelemetry bool `json:"enableTelemetry,omitempty" jsonschema:"description=Enable telemetry,default=false"` // Timeout for operations in seconds - Timeout int `json:"timeout,omitempty" jsonschema:"description=Operation timeout in seconds,minimum=1,maximum=300,default=30"` + Timeout int `json:"timeout,omitempty" jsonschema:"description=Operation timeout (sec),minimum=1,maximum=300,default=30"` } // CustomProjectConfig defines project-level configuration for the demo extension @@ -27,7 +27,7 @@ type CustomProjectConfig struct { // Demo feature flags for project-level configuration EnableColors bool `json:"enableColors,omitempty" jsonschema:"description=Enable color output,default=true"` // Maximum number of items to display - MaxItems int `json:"maxItems,omitempty" jsonschema:"description=Max items to display,minimum=1,maximum=100,default=10"` + MaxItems int `json:"maxItems,omitempty" jsonschema:"description=Max items,minimum=1,maximum=100,default=10"` // Project labels for demo purposes Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Custom project labels"` } @@ -39,14 +39,14 @@ type CustomServiceConfig struct { // Port for the demo service Port int `json:"port,omitempty" jsonschema:"description=Service port,minimum=1,maximum=65535,default=8080"` // Environment for demo deployment - Environment string `json:"environment,omitempty" jsonschema:"description=Environment,enum=development,enum=staging,enum=production"` + Environment string `json:"environment,omitempty" jsonschema:"enum=development,enum=staging,enum=production"` // Health check configuration - HealthCheck *HealthCheckConfig `json:"healthCheck,omitempty" jsonschema:"description=Health check configuration"` + HealthCheck *HealthCheckConfig `json:"healthCheck,omitempty" jsonschema:"description=Health check config"` } // HealthCheckConfig defines health check configuration type HealthCheckConfig struct { Enabled bool `json:"enabled,omitempty" jsonschema:"description=Enable health checks,default=true"` Path string `json:"path,omitempty" jsonschema:"description=Health check path,default=/health"` - Interval int `json:"interval,omitempty" jsonschema:"description=Check interval (sec),minimum=5,maximum=300,default=30"` + Interval int `json:"interval,omitempty" jsonschema:"description=Check interval (sec),minimum=5,maximum=300"` } diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go index 72c7b16d50d..3c5bf751f5d 100644 --- a/cli/azd/pkg/azdext/metadata_generator.go +++ b/cli/azd/pkg/azdext/metadata_generator.go @@ -11,11 +11,10 @@ import ( // GenerateExtensionMetadata generates ExtensionCommandMetadata from a Cobra root command // This function is typically called by extensions to generate their metadata -func GenerateExtensionMetadata(schemaVersion, id, version string, root *cobra.Command) *extensions.ExtensionCommandMetadata { +func GenerateExtensionMetadata(schemaVersion, id string, root *cobra.Command) *extensions.ExtensionCommandMetadata { return &extensions.ExtensionCommandMetadata{ SchemaVersion: schemaVersion, ID: id, - Version: version, Commands: generateCommands(root), } } diff --git a/cli/azd/pkg/azdext/metadata_generator_test.go b/cli/azd/pkg/azdext/metadata_generator_test.go index f6c711a288d..80fe8a93666 100644 --- a/cli/azd/pkg/azdext/metadata_generator_test.go +++ b/cli/azd/pkg/azdext/metadata_generator_test.go @@ -28,12 +28,11 @@ func TestGenerateExtensionMetadata(t *testing.T) { rootCmd.AddCommand(greetCmd) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.NotNil(t, metadata) assert.Equal(t, "1.0", metadata.SchemaVersion) assert.Equal(t, "test.extension", metadata.ID) - assert.Equal(t, "1.0.0", metadata.Version) assert.Len(t, metadata.Commands, 1) cmd := metadata.Commands[0] @@ -80,7 +79,7 @@ func TestGenerateExtensionMetadata_NestedCommands(t *testing.T) { demoCmd.AddCommand(farewellCmd) rootCmd.AddCommand(demoCmd) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) assert.Equal(t, []string{"demo"}, metadata.Commands[0].Name) @@ -123,7 +122,7 @@ func TestGenerateExtensionMetadata_HiddenCommands(t *testing.T) { rootCmd.AddCommand(visibleCmd) rootCmd.AddCommand(hiddenCmd) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) // Hidden commands should not be included assert.Len(t, metadata.Commands, 1) @@ -146,7 +145,7 @@ func TestGenerateExtensionMetadata_HiddenFlags(t *testing.T) { rootCmd.AddCommand(testCmd) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) // Hidden flags should not be included @@ -168,7 +167,7 @@ func TestGenerateExtensionMetadata_DeprecatedCommands(t *testing.T) { rootCmd.AddCommand(deprecatedCmd) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) assert.Equal(t, "use 'new-command' instead", metadata.Commands[0].Deprecated) @@ -188,7 +187,7 @@ func TestGenerateExtensionMetadata_Aliases(t *testing.T) { rootCmd.AddCommand(cmdWithAliases) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) assert.Equal(t, []string{"cmd", "c"}, metadata.Commands[0].Aliases) @@ -208,7 +207,7 @@ func TestGenerateExtensionMetadata_Examples(t *testing.T) { rootCmd.AddCommand(cmdWithExamples) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) require.Len(t, metadata.Commands[0].Examples, 1) @@ -234,7 +233,7 @@ func TestGenerateExtensionMetadata_FlagTypes(t *testing.T) { rootCmd.AddCommand(testCmd) - metadata := GenerateExtensionMetadata("1.0", "test.extension", "1.0.0", rootCmd) + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) flags := metadata.Commands[0].Flags diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 20da12ffccb..8f18e8b1372 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -861,14 +861,6 @@ func (m *Manager) fetchAndCacheMetadata( ) } - if metadata.Version != extension.Version { - log.Printf( - "Warning: metadata version '%s' does not match extension version '%s'", - metadata.Version, - extension.Version, - ) - } - // Write metadata to cache metadataJSON, err := json.MarshalIndent(metadata, "", " ") if err != nil { diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 673d17f11e4..e31aded66fe 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -983,7 +983,6 @@ func Test_FetchAndCacheMetadata(t *testing.T) { mockMetadata := ExtensionCommandMetadata{ SchemaVersion: "1.0", ID: "test.metadata.extension", - Version: "1.0.0", Commands: []Command{ { Name: []string{"test"}, @@ -1033,7 +1032,6 @@ func Test_FetchAndCacheMetadata(t *testing.T) { require.NoError(t, err) require.NotNil(t, loadedMetadata) require.Equal(t, mockMetadata.ID, loadedMetadata.ID) - require.Equal(t, mockMetadata.Version, loadedMetadata.Version) require.Len(t, loadedMetadata.Commands, 1) require.Equal(t, "test", loadedMetadata.Commands[0].Name[0]) }) diff --git a/cli/azd/pkg/extensions/metadata.go b/cli/azd/pkg/extensions/metadata.go index 3f16abdad05..199a9fa5048 100644 --- a/cli/azd/pkg/extensions/metadata.go +++ b/cli/azd/pkg/extensions/metadata.go @@ -13,8 +13,6 @@ type ExtensionCommandMetadata struct { SchemaVersion string `json:"schemaVersion"` // ID is the extension identifier matching extension.yaml ID string `json:"id"` - // Version is the extension version matching extension.yaml - Version string `json:"version"` // Commands is the list of root-level commands provided by the extension Commands []Command `json:"commands"` // Configuration describes extension configuration options (Phase 2) diff --git a/cli/azd/pkg/extensions/metadata_test.go b/cli/azd/pkg/extensions/metadata_test.go index 6ff423712ed..4adb9e03a6e 100644 --- a/cli/azd/pkg/extensions/metadata_test.go +++ b/cli/azd/pkg/extensions/metadata_test.go @@ -16,7 +16,6 @@ func TestExtensionCommandMetadata_Marshaling(t *testing.T) { metadata := &ExtensionCommandMetadata{ SchemaVersion: "1.0", ID: "microsoft.azd.demo", - Version: "1.0.0", Commands: []Command{ { Name: []string{"demo", "greet"}, @@ -65,7 +64,6 @@ func TestExtensionCommandMetadata_Marshaling(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(data), `"schemaVersion":"1.0"`) assert.Contains(t, string(data), `"id":"microsoft.azd.demo"`) - assert.Contains(t, string(data), `"version":"1.0.0"`) assert.Contains(t, string(data), `"name":["demo","greet"]`) } @@ -113,7 +111,6 @@ func TestExtensionMetadata_UnmarshalJSON(t *testing.T) { assert.Equal(t, "1.0", metadata.SchemaVersion) assert.Equal(t, "microsoft.azd.demo", metadata.ID) - assert.Equal(t, "1.0.0", metadata.Version) assert.Len(t, metadata.Commands, 1) cmd := metadata.Commands[0] @@ -138,7 +135,6 @@ func TestCommand_NestedSubcommands(t *testing.T) { metadata := ExtensionCommandMetadata{ SchemaVersion: "1.0", ID: "microsoft.azd.test", - Version: "1.0.0", Commands: []Command{ { Name: []string{"test"}, @@ -268,7 +264,6 @@ func TestConfigurationMetadata_Optional(t *testing.T) { metadata := ExtensionCommandMetadata{ SchemaVersion: "1.0", ID: "test", - Version: "1.0.0", Commands: []Command{}, } @@ -286,7 +281,6 @@ func TestConfigurationMetadata_Optional(t *testing.T) { metadata2 := ExtensionCommandMetadata{ SchemaVersion: "1.0", ID: "test", - Version: "1.0.0", Commands: []Command{}, Configuration: &ConfigurationMetadata{ Global: &jsonschema.Schema{ @@ -321,6 +315,5 @@ func TestExtensionMetadata_FutureSchemaVersion(t *testing.T) { // Should parse known fields successfully assert.Equal(t, "2.0", metadata.SchemaVersion) assert.Equal(t, "test", metadata.ID) - assert.Equal(t, "1.0.0", metadata.Version) assert.Empty(t, metadata.Commands) } From 04abaeea7005ce6286861dc73896084e72bada52 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 13 Jan 2026 14:00:59 -0800 Subject: [PATCH 07/11] Adds support for environment variables --- cli/azd/.vscode/cspell.yaml | 13 ++- .../internal/cmd/metadata.go | 38 +++++++- cli/azd/pkg/extensions/metadata.go | 14 +++ cli/azd/pkg/extensions/metadata_test.go | 90 +++++++++++++++++++ cli/azd/pkg/extensions/schema_validator.go | 3 +- 5 files changed, 151 insertions(+), 7 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 01588050330..5a067748cfc 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -251,7 +251,18 @@ overrides: - httptest - Logf - Getenv - - httptest + - httptest + - filename: pkg/extensions/metadata.go + words: + - invopop + - filename: pkg/extensions/schema_validator.go + words: + - invopop + - jsonschemav + - filename: pkg/project/dockerfile_builder.go + words: + - WORKDIR + - workdir ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go index b595155023d..653889e4f5b 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go @@ -30,7 +30,7 @@ func newMetadataCommand() *cobra.Command { rootCmd, ) - // Add custom configuration schemas + // Add custom configuration schemas and environment variables metadata.Configuration = generateConfigurationMetadata() // Output as JSON @@ -51,8 +51,38 @@ func newMetadataCommand() *cobra.Command { func generateConfigurationMetadata() *extensions.ConfigurationMetadata { // Generate schemas from Go types automatically return &extensions.ConfigurationMetadata{ - Global: jsonschema.Reflect(&config.CustomGlobalConfig{}), - Project: jsonschema.Reflect(&config.CustomProjectConfig{}), - Service: jsonschema.Reflect(&config.CustomServiceConfig{}), + Global: jsonschema.Reflect(&config.CustomGlobalConfig{}), + Project: jsonschema.Reflect(&config.CustomProjectConfig{}), + Service: jsonschema.Reflect(&config.CustomServiceConfig{}), + EnvironmentVariables: generateEnvironmentVariables(), + } +} + +// generateEnvironmentVariables documents environment variables used by the demo extension. +// This helps users understand what environment variables are available and how to use them. +func generateEnvironmentVariables() []extensions.EnvironmentVariable { + return []extensions.EnvironmentVariable{ + { + Name: "DEMO_API_KEY", + Description: "API key for external service integration. Used for authentication with external services.", + Example: "sk-abc123xyz456", + }, + { + Name: "DEMO_LOG_LEVEL", + Description: "Set logging verbosity level. Controls the amount of detail logged during operations.", + Default: "info", + Example: "debug", + }, + { + Name: "DEMO_TIMEOUT", + Description: "Default timeout in seconds. Use for longer timeouts on slow networks or large transfers.", + Default: "30", + Example: "60", + }, + { + Name: "DEMO_CACHE_DIR", + Description: "Custom directory path for extension cache files. Controls where temporary files are stored.", + Example: "/tmp/demo-cache", + }, } } diff --git a/cli/azd/pkg/extensions/metadata.go b/cli/azd/pkg/extensions/metadata.go index 199a9fa5048..213a8b8d8c4 100644 --- a/cli/azd/pkg/extensions/metadata.go +++ b/cli/azd/pkg/extensions/metadata.go @@ -89,6 +89,18 @@ type Flag struct { Deprecated string `json:"deprecated,omitempty"` } +// EnvironmentVariable represents an environment variable used or recognized by the extension +type EnvironmentVariable struct { + // Name is the environment variable name (e.g., "EXTENSION_API_KEY") + Name string `json:"name"` + // Description explains when and why to use this environment variable + Description string `json:"description"` + // Default is the default value used if the environment variable is not set + Default string `json:"default,omitempty"` + // Example provides an example value for documentation purposes + Example string `json:"example,omitempty"` +} + // ConfigurationMetadata describes extension configuration options (Phase 2). // Each field contains a JSON Schema (github.com/invopop/jsonschema) that defines // the structure and validation rules for extension configuration at different scopes. @@ -126,4 +138,6 @@ type ConfigurationMetadata struct { Project *jsonschema.Schema `json:"project,omitempty"` // Service contains JSON Schema for service-level configuration options Service *jsonschema.Schema `json:"service,omitempty"` + // EnvironmentVariables describes environment variables used by the extension + EnvironmentVariables []EnvironmentVariable `json:"environmentVariables,omitempty"` } diff --git a/cli/azd/pkg/extensions/metadata_test.go b/cli/azd/pkg/extensions/metadata_test.go index 4adb9e03a6e..856f523f7f0 100644 --- a/cli/azd/pkg/extensions/metadata_test.go +++ b/cli/azd/pkg/extensions/metadata_test.go @@ -317,3 +317,93 @@ func TestExtensionMetadata_FutureSchemaVersion(t *testing.T) { assert.Equal(t, "test", metadata.ID) assert.Empty(t, metadata.Commands) } + +func TestEnvironmentVariable_Marshaling(t *testing.T) { + envVars := []EnvironmentVariable{ + { + Name: "DEMO_API_KEY", + Description: "API key for external service authentication", + Example: "abc123xyz", + }, + { + Name: "DEMO_LOG_LEVEL", + Description: "Set the logging level for the extension", + Default: "info", + Example: "debug", + }, + } + + metadata := &ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "test.extension", + Commands: []Command{}, + Configuration: &ConfigurationMetadata{ + EnvironmentVariables: envVars, + }, + } + + data, err := json.Marshal(metadata) + require.NoError(t, err) + + jsonStr := string(data) + assert.Contains(t, jsonStr, "environmentVariables") + assert.Contains(t, jsonStr, "DEMO_API_KEY") + assert.Contains(t, jsonStr, "API key for external service authentication") + assert.Contains(t, jsonStr, "DEMO_LOG_LEVEL") + assert.Contains(t, jsonStr, `"default":"info"`) +} + +func TestEnvironmentVariable_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "schemaVersion": "1.0", + "id": "test.extension", + "commands": [], + "configuration": { + "environmentVariables": [ + { + "name": "TEST_API_KEY", + "description": "API key for testing", + "example": "test-key-123" + }, + { + "name": "TEST_TIMEOUT", + "description": "Operation timeout in seconds", + "default": "30", + "example": "60" + } + ] + } + }` + + var metadata ExtensionCommandMetadata + err := json.Unmarshal([]byte(jsonData), &metadata) + require.NoError(t, err) + + require.NotNil(t, metadata.Configuration) + require.Len(t, metadata.Configuration.EnvironmentVariables, 2) + + envVar1 := metadata.Configuration.EnvironmentVariables[0] + assert.Equal(t, "TEST_API_KEY", envVar1.Name) + assert.Equal(t, "API key for testing", envVar1.Description) + assert.Equal(t, "test-key-123", envVar1.Example) + assert.Empty(t, envVar1.Default) + + envVar2 := metadata.Configuration.EnvironmentVariables[1] + assert.Equal(t, "TEST_TIMEOUT", envVar2.Name) + assert.Equal(t, "Operation timeout in seconds", envVar2.Description) + assert.Equal(t, "30", envVar2.Default) + assert.Equal(t, "60", envVar2.Example) +} + +func TestEnvironmentVariable_Optional(t *testing.T) { + // Without environment variables + metadata := ExtensionCommandMetadata{ + SchemaVersion: "1.0", + ID: "test", + Commands: []Command{}, + } + + data, err := json.Marshal(metadata) + require.NoError(t, err) + assert.NotContains(t, string(data), "environmentVariables") +} diff --git a/cli/azd/pkg/extensions/schema_validator.go b/cli/azd/pkg/extensions/schema_validator.go index b13694d0f53..353fbedb14b 100644 --- a/cli/azd/pkg/extensions/schema_validator.go +++ b/cli/azd/pkg/extensions/schema_validator.go @@ -12,8 +12,7 @@ import ( ) // CompileSchema compiles a JSON Schema for validation. -// Accepts either *jsonschema.Schema (from invopop/jsonschema) or json.RawMessage. -// Returns a compiled schema from santhosh-tekuri/jsonschema/v6 for validation. +// Returns a compiled JSON Schema func CompileSchema(schema *jsonschema.Schema) (*jsonschemav6.Schema, error) { if schema == nil { return nil, fmt.Errorf("schema cannot be nil") From 763069b0832e44d964418172984870528e55cc7b Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 13 Jan 2026 15:20:46 -0800 Subject: [PATCH 08/11] Added additional unit tests --- .../internal/config/types.go | 22 ++++---- cli/azd/pkg/azdext/metadata_generator.go | 9 ---- cli/azd/pkg/extensions/manager.go | 2 +- cli/azd/pkg/extensions/manager_test.go | 52 +++++++++++++++++++ cli/azd/pkg/extensions/runner.go | 4 ++ .../pkg/extensions/schema_validator_test.go | 13 ++--- 6 files changed, 75 insertions(+), 27 deletions(-) diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go index 26723a18797..63eaa80dbcb 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go @@ -13,13 +13,13 @@ type CustomGlobalConfig struct { // DemoGlobalSettings contains the actual global configuration for the demo extension type DemoGlobalSettings struct { // API key for external services - APIKey string `json:"apiKey,omitempty" jsonschema:"description=API key for service integration"` - // Log level for demo extension - LogLevel string `json:"logLevel,omitempty" jsonschema:"description=Log level,enum=debug,enum=info,enum=warn,enum=error"` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=API key for service integration"` + // Log level: debug, info, warn, error + LogLevel string `json:"logLevel,omitempty" jsonschema:"enum=debug,enum=info,enum=warn,enum=error"` // Enable telemetry collection EnableTelemetry bool `json:"enableTelemetry,omitempty" jsonschema:"description=Enable telemetry,default=false"` - // Timeout for operations in seconds - Timeout int `json:"timeout,omitempty" jsonschema:"description=Operation timeout (sec),minimum=1,maximum=300,default=30"` + // Timeout for operations in seconds (1-300) + Timeout int `json:"timeout,omitempty" jsonschema:"minimum=1,maximum=300,default=30"` } // CustomProjectConfig defines project-level configuration for the demo extension @@ -27,17 +27,17 @@ type CustomProjectConfig struct { // Demo feature flags for project-level configuration EnableColors bool `json:"enableColors,omitempty" jsonschema:"description=Enable color output,default=true"` // Maximum number of items to display - MaxItems int `json:"maxItems,omitempty" jsonschema:"description=Max items,minimum=1,maximum=100,default=10"` + MaxItems int `json:"maxItems,omitempty" jsonschema:"description=Max items,minimum=1,maximum=100,default=10"` // Project labels for demo purposes - Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Custom project labels"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Custom project labels"` } // CustomServiceConfig defines service-level configuration for the demo extension type CustomServiceConfig struct { // Demo service endpoint configuration - Endpoint string `json:"endpoint" jsonschema:"required,description=Service endpoint URL,format=uri"` + Endpoint string `json:"endpoint" jsonschema:"required,description=Service endpoint URL,format=uri"` // Port for the demo service - Port int `json:"port,omitempty" jsonschema:"description=Service port,minimum=1,maximum=65535,default=8080"` + Port int `json:"port,omitempty" jsonschema:"description=Service port,minimum=1,maximum=65535,default=8080"` // Environment for demo deployment Environment string `json:"environment,omitempty" jsonschema:"enum=development,enum=staging,enum=production"` // Health check configuration @@ -46,7 +46,7 @@ type CustomServiceConfig struct { // HealthCheckConfig defines health check configuration type HealthCheckConfig struct { - Enabled bool `json:"enabled,omitempty" jsonschema:"description=Enable health checks,default=true"` - Path string `json:"path,omitempty" jsonschema:"description=Health check path,default=/health"` + Enabled bool `json:"enabled,omitempty" jsonschema:"description=Enable health checks,default=true"` + Path string `json:"path,omitempty" jsonschema:"description=Health check path,default=/health"` Interval int `json:"interval,omitempty" jsonschema:"description=Check interval (sec),minimum=5,maximum=300"` } diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go index 3c5bf751f5d..a6f93fd3657 100644 --- a/cli/azd/pkg/azdext/metadata_generator.go +++ b/cli/azd/pkg/azdext/metadata_generator.go @@ -24,10 +24,6 @@ func generateCommands(cmd *cobra.Command) []extensions.Command { var commands []extensions.Command for _, subCmd := range cmd.Commands() { - // Skip hidden commands - if subCmd.Hidden { - continue - } command := generateCommand(subCmd) commands = append(commands, command) } @@ -122,11 +118,6 @@ func generateFlags(cmd *cobra.Command) []extensions.Flag { var flags []extensions.Flag cmd.Flags().VisitAll(func(flag *pflag.Flag) { - // Skip hidden flags - if flag.Hidden { - return - } - flagMeta := extensions.Flag{ Name: flag.Name, Shorthand: flag.Shorthand, diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 8f18e8b1372..813a0978d0c 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -837,7 +837,7 @@ func (m *Manager) fetchAndCacheMetadata( Args: []string{metadataCommandName}, }) if err != nil { - if cmdCtx.Err() == context.DeadlineExceeded { + if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("metadata command timed out after %v", metadataTimeout) } return fmt.Errorf("metadata command failed: %w", err) diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index e31aded66fe..006f52a0a90 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -1074,4 +1074,56 @@ func Test_FetchAndCacheMetadata(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "metadata not found") }) + + t.Run("metadata command timeout does not fail operation", func(t *testing.T) { + timeoutExtension := &Extension{ + Id: "test.timeout.extension", + Namespace: "test", + DisplayName: "Test Timeout Extension", + Version: "1.0.0", + Path: relPath, + Capabilities: []CapabilityType{MetadataCapability}, + } + + // Create extension directory + timeoutExtDir := filepath.Join(tempDir, "extensions", timeoutExtension.Id) + err := os.MkdirAll(timeoutExtDir, os.ModePerm) + require.NoError(t, err) + + // Create a new mock context with isolated command runner + timeoutMockContext := mocks.NewMockContext(context.Background()) + + // Mock CommandRunner to simulate timeout for ANY metadata command + timeoutMockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + // Match any command that has "metadata" as an argument + for _, arg := range args.Args { + if arg == "metadata" { + return true + } + } + return false + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{}, context.DeadlineExceeded + }) + + // Create a new runner with the timeout mock context + timeoutLazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(timeoutMockContext.CommandRunner), nil + }) + + timeoutManager, err := NewManager(userConfigManager, sourceManager, timeoutLazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + // Fetch and cache metadata - should return error for timeout + err = timeoutManager.fetchAndCacheMetadata(*mockContext.Context, timeoutExtension) + require.Error(t, err, "Should return an error for timeout") + require.Contains(t, err.Error(), "timed out", "Error should mention timeout") + + // Metadata should not exist since command timed out + exists := timeoutManager.MetadataExists(timeoutExtension.Id) + require.False(t, exists, "Metadata should not exist when command times out") + + // This demonstrates that installation would still succeed despite timeout + // The actual Install() function logs this as a warning but doesn't fail + }) } diff --git a/cli/azd/pkg/extensions/runner.go b/cli/azd/pkg/extensions/runner.go index b3ec3ed2d2d..adfe11e7c3c 100644 --- a/cli/azd/pkg/extensions/runner.go +++ b/cli/azd/pkg/extensions/runner.go @@ -84,3 +84,7 @@ type ExtensionRunError struct { func (e *ExtensionRunError) Error() string { return fmt.Sprintf("extension '%s' run failed: %v", e.ExtensionId, e.Err) } + +func (e *ExtensionRunError) Unwrap() error { + return e.Err +} diff --git a/cli/azd/pkg/extensions/schema_validator_test.go b/cli/azd/pkg/extensions/schema_validator_test.go index 1b05c98cf8b..d667b8e55f4 100644 --- a/cli/azd/pkg/extensions/schema_validator_test.go +++ b/cli/azd/pkg/extensions/schema_validator_test.go @@ -210,23 +210,24 @@ func TestConfigurationMetadata_JSONMarshaling(t *testing.T) { func TestConfigurationMetadata_FromGoTypes(t *testing.T) { // Define configuration structures using Go types type CustomGlobalConfig struct { - APIKey string `json:"apiKey" jsonschema:"required,description=API key for authentication,minLength=10"` + APIKey string `json:"apiKey" jsonschema:"required,description=API key for authentication,minLength=10"` // Timeout in seconds (1-300, default 60) Timeout int `json:"timeout,omitempty" jsonschema:"minimum=1,maximum=300,default=60"` - Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging"` + Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging"` } type CustomProjectConfig struct { - ProjectName string `json:"projectName" jsonschema:"required,description=Name of the project"` + ProjectName string `json:"projectName" jsonschema:"required,description=Name of the project"` // Deployment environment: dev, staging, or prod Environment string `json:"environment,omitempty" jsonschema:"enum=dev,enum=staging,enum=prod"` - Features []string `json:"features,omitempty" jsonschema:"description=Enabled features"` + Features []string `json:"features,omitempty" jsonschema:"description=Enabled features"` } type CustomServiceConfig struct { - Port int `json:"port" jsonschema:"required,description=Service port,minimum=1,maximum=65535"` + // Service port (1-65535) + Port int `json:"port" jsonschema:"required,minimum=1,maximum=65535"` HostName string `json:"hostName,omitempty" jsonschema:"description=Service hostname"` - Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Service labels"` + Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Service labels"` } // Generate schemas from Go types - THIS IS THE EASIEST WAY! From 5e7c67d64f2f6f492c15068fe330e3ee93de5b36 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 14 Jan 2026 10:11:20 -0800 Subject: [PATCH 09/11] Fixes issue with empty command metadata. --- cli/azd/pkg/azdext/metadata_generator.go | 11 +++ cli/azd/pkg/azdext/metadata_generator_test.go | 70 +++++++++++++++++-- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go index a6f93fd3657..6657ad9ae2d 100644 --- a/cli/azd/pkg/azdext/metadata_generator.go +++ b/cli/azd/pkg/azdext/metadata_generator.go @@ -24,7 +24,18 @@ func generateCommands(cmd *cobra.Command) []extensions.Command { var commands []extensions.Command for _, subCmd := range cmd.Commands() { + // Skip commands with empty Use field (e.g., auto-generated help commands) + if subCmd.Use == "" { + continue + } + command := generateCommand(subCmd) + + // Skip commands that result in an empty name path + if len(command.Name) == 0 { + continue + } + commands = append(commands, command) } diff --git a/cli/azd/pkg/azdext/metadata_generator_test.go b/cli/azd/pkg/azdext/metadata_generator_test.go index 80fe8a93666..8e6e95f55c1 100644 --- a/cli/azd/pkg/azdext/metadata_generator_test.go +++ b/cli/azd/pkg/azdext/metadata_generator_test.go @@ -124,9 +124,16 @@ func TestGenerateExtensionMetadata_HiddenCommands(t *testing.T) { metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) - // Hidden commands should not be included - assert.Len(t, metadata.Commands, 1) - assert.Equal(t, []string{"visible"}, metadata.Commands[0].Name) + // Both commands should be included; hidden commands have Hidden=true + assert.Len(t, metadata.Commands, 2) + + visibleFound := findCommand(metadata.Commands, "visible") + require.NotNil(t, visibleFound) + assert.False(t, visibleFound.Hidden) + + hiddenFound := findCommand(metadata.Commands, "hidden") + require.NotNil(t, hiddenFound) + assert.True(t, hiddenFound.Hidden) } func TestGenerateExtensionMetadata_HiddenFlags(t *testing.T) { @@ -148,9 +155,16 @@ func TestGenerateExtensionMetadata_HiddenFlags(t *testing.T) { metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) require.Len(t, metadata.Commands, 1) - // Hidden flags should not be included - assert.Len(t, metadata.Commands[0].Flags, 1) - assert.Equal(t, "visible", metadata.Commands[0].Flags[0].Name) + // Both flags should be included; hidden flags have Hidden=true + assert.Len(t, metadata.Commands[0].Flags, 2) + + visibleFlag := findFlag(metadata.Commands[0].Flags, "visible") + require.NotNil(t, visibleFlag) + assert.False(t, visibleFlag.Hidden) + + hiddenFlag := findFlag(metadata.Commands[0].Flags, "hidden") + require.NotNil(t, hiddenFlag) + assert.True(t, hiddenFlag.Hidden) } func TestGenerateExtensionMetadata_DeprecatedCommands(t *testing.T) { @@ -268,3 +282,47 @@ func findFlag(flags []extensions.Flag, name string) *extensions.Flag { } return nil } + +// Helper function to find a command by name (first element of Name path) +func findCommand(commands []extensions.Command, name string) *extensions.Command { + for i := range commands { + if len(commands[i].Name) > 0 && commands[i].Name[0] == name { + return &commands[i] + } + } + return nil +} + +func TestGenerateExtensionMetadata_SkipsEmptyCommands(t *testing.T) { + rootCmd := &cobra.Command{ + Use: "test-ext", + Short: "Test extension", + } + + // Add a normal command + normalCmd := &cobra.Command{ + Use: "normal", + Short: "Normal command", + } + rootCmd.AddCommand(normalCmd) + + // Add a command with empty Use (simulating auto-generated help-like commands) + emptyCmd := &cobra.Command{ + Use: "", + Short: "Empty command", + Hidden: true, + } + rootCmd.AddCommand(emptyCmd) + + metadata := GenerateExtensionMetadata("1.0", "test.extension", rootCmd) + + // Only the normal command should be included; empty Use commands are skipped + assert.Len(t, metadata.Commands, 1) + assert.Equal(t, []string{"normal"}, metadata.Commands[0].Name) + + // Verify no command has a nil/empty name + for _, cmd := range metadata.Commands { + assert.NotNil(t, cmd.Name, "Command name should not be nil") + assert.NotEmpty(t, cmd.Name, "Command name should not be empty") + } +} From d54a17abcbb4e6aed43089282519d367dc1a767c Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 14 Jan 2026 12:56:34 -0800 Subject: [PATCH 10/11] Update cli/azd/pkg/extensions/metadata.go Co-authored-by: Wei Lim --- cli/azd/pkg/extensions/metadata.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/pkg/extensions/metadata.go b/cli/azd/pkg/extensions/metadata.go index 213a8b8d8c4..485a36bbeab 100644 --- a/cli/azd/pkg/extensions/metadata.go +++ b/cli/azd/pkg/extensions/metadata.go @@ -31,7 +31,7 @@ type Command struct { Usage string `json:"usage,omitempty"` // Examples contains example usages of the command Examples []CommandExample `json:"examples,omitempty"` - // Args defines the positional arguments accepted by the command + // Args defines the positional arguments accepted by the command, in the order it is received Args []Argument `json:"args,omitempty"` // Flags defines the flags/options accepted by the command Flags []Flag `json:"flags,omitempty"` From acf239ac7f3add2e0e69db9ca252420952539503 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 14 Jan 2026 12:59:24 -0800 Subject: [PATCH 11/11] Addresses PR feedback --- cli/azd/.vscode/cspell.yaml | 4 ++++ cli/azd/pkg/extensions/manager.go | 19 ++++++++----------- cli/azd/pkg/extensions/manager_test.go | 8 ++++---- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 5a067748cfc..7a937f40101 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -45,6 +45,7 @@ words: - jsonschema - rustc - figspec + - finetune - bubbletea - lipgloss - gopxl @@ -263,6 +264,9 @@ overrides: words: - WORKDIR - workdir + - filename: extensions/microsoft.azd.demo/internal/cmd/metadata.go + words: + - invopop ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 813a0978d0c..e7d9659f592 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -512,9 +512,11 @@ func (m *Manager) Install( // Fetch and cache metadata if extension supports it installedExtension := extensions[extension.Id] - if err := m.fetchAndCacheMetadata(ctx, installedExtension); err != nil { - // Log warning but don't fail installation - log.Printf("Warning: Failed to fetch extension metadata for '%s': %v\n", extension.Id, err) + if installedExtension.HasCapability(MetadataCapability) { + if err := m.fetchAndCacheMetadata(ctx, installedExtension); err != nil { + // Log warning but don't fail installation + log.Printf("Warning: Failed to fetch extension metadata for '%s': %v\n", extension.Id, err) + } } return selectedVersion, nil @@ -798,18 +800,13 @@ const ( metadataTimeout = 10 * time.Second ) -// fetchAndCacheMetadata fetches metadata from an extension and caches it to disk -// Returns nil error if metadata was successfully fetched and cached, or if extension doesn't support metadata -// Returns warning-level error if metadata fetch failed (installation should still succeed) +// fetchAndCacheMetadata fetches metadata from an extension and caches it to disk. +// Caller must verify that extension has MetadataCapability before calling. +// Returns nil error if metadata was successfully fetched and cached. func (m *Manager) fetchAndCacheMetadata( ctx context.Context, extension *Extension, ) error { - // Check if extension has metadata capability - if !extension.HasCapability(MetadataCapability) { - return nil // Extension doesn't support metadata - this is fine - } - userConfigDir, err := config.GetUserConfigDir() if err != nil { return fmt.Errorf("failed to get user config directory: %w", err) diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 006f52a0a90..519760e6098 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -1036,7 +1036,7 @@ func Test_FetchAndCacheMetadata(t *testing.T) { require.Equal(t, "test", loadedMetadata.Commands[0].Name[0]) }) - t.Run("skip metadata fetch for extension without metadata capability", func(t *testing.T) { + t.Run("caller should check capability before calling fetchAndCacheMetadata", func(t *testing.T) { extensionNoMetadata := &Extension{ Id: "test.no.metadata", Namespace: "test", @@ -1046,9 +1046,9 @@ func Test_FetchAndCacheMetadata(t *testing.T) { Capabilities: []CapabilityType{}, // No metadata capability } - // Should not error even though extension doesn't support metadata - err := manager.fetchAndCacheMetadata(*mockContext.Context, extensionNoMetadata) - require.NoError(t, err) + // Caller should check capability before calling + hasCapability := extensionNoMetadata.HasCapability(MetadataCapability) + require.False(t, hasCapability, "Extension should not have metadata capability") // Metadata should not exist exists := manager.MetadataExists(extensionNoMetadata.Id)