diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 01588050330..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 @@ -251,7 +252,21 @@ 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 + - filename: extensions/microsoft.azd.demo/internal/cmd/metadata.go + words: + - invopop ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index e32c390cfa5..cc143dd6847 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -874,8 +874,15 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // Extensions container.MustRegisterSingleton(extensions.NewManager) - container.MustRegisterSingleton(extensions.NewRunner) container.MustRegisterSingleton(extensions.NewSourceManager) + 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 + }) + }) // gRPC Server container.MustRegisterScoped(grpcserver.NewServer) 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..653889e4f5b --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/metadata.go @@ -0,0 +1,88 @@ +// 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 + rootCmd, + ) + + // Add custom configuration schemas and environment variables + 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{ + 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/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..63eaa80dbcb --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/config/types.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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 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 (1-300) + Timeout int `json:"timeout,omitempty" jsonschema:"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 + 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"` + // 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:"enum=development,enum=staging,enum=production"` + // 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"` +} diff --git a/cli/azd/pkg/azdext/metadata_generator.go b/cli/azd/pkg/azdext/metadata_generator.go new file mode 100644 index 00000000000..6657ad9ae2d --- /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 string, root *cobra.Command) *extensions.ExtensionCommandMetadata { + return &extensions.ExtensionCommandMetadata{ + SchemaVersion: schemaVersion, + ID: id, + 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 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) + } + + 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) { + 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..8e6e95f55c1 --- /dev/null +++ b/cli/azd/pkg/azdext/metadata_generator_test.go @@ -0,0 +1,328 @@ +// 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", rootCmd) + + require.NotNil(t, metadata) + assert.Equal(t, "1.0", metadata.SchemaVersion) + assert.Equal(t, "test.extension", metadata.ID) + 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", 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", rootCmd) + + // 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) { + 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", rootCmd) + + require.Len(t, metadata.Commands, 1) + // 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) { + 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", 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", 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", 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", 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 +} + +// 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") + } +} diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 065c7e5f7f4..e7d9659f592 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" ) @@ -142,16 +145,19 @@ type Manager struct { 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() @@ -167,6 +173,7 @@ func NewManager( userConfig: userConfig, configManager: configManager, sourceManager: sourceManager, + lazyRunner: lazyRunner, pipeline: pipeline, }, nil } @@ -503,6 +510,15 @@ func (m *Manager) Install( targetPath, ) + // Fetch and cache metadata if extension supports it + installedExtension := extensions[extension.Id] + 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 } @@ -777,3 +793,141 @@ 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. +// 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 { + 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 errors.Is(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, + ) + } + + // 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..519760e6098 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,187 @@ 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"), 0600) + 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", + 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.Len(t, loadedMetadata.Commands, 1) + require.Equal(t, "test", loadedMetadata.Commands[0].Name[0]) + }) + + t.Run("caller should check capability before calling fetchAndCacheMetadata", 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 + } + + // 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) + 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") + }) + + 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/metadata.go b/cli/azd/pkg/extensions/metadata.go new file mode 100644 index 00000000000..485a36bbeab --- /dev/null +++ b/cli/azd/pkg/extensions/metadata.go @@ -0,0 +1,143 @@ +// 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"` + // 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, 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"` + // 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"` +} + +// 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. +// +// 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"` + // 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 new file mode 100644 index 00000000000..856f523f7f0 --- /dev/null +++ b/cli/azd/pkg/extensions/metadata_test.go @@ -0,0 +1,409 @@ +// 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", + 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), `"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.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", + 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", + 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", + 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.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/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/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.go b/cli/azd/pkg/extensions/schema_validator.go new file mode 100644 index 00000000000..353fbedb14b --- /dev/null +++ b/cli/azd/pkg/extensions/schema_validator.go @@ -0,0 +1,60 @@ +// 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. +// Returns a compiled JSON Schema +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..d667b8e55f4 --- /dev/null +++ b/cli/azd/pkg/extensions/schema_validator_test.go @@ -0,0 +1,298 @@ +// 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 { + // 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"` + } + + // 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") +}