From 00e71f151965d9889b9d271c3d74231494ac3583 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 6 Feb 2025 12:30:58 -0800 Subject: [PATCH] Extensions: Adds `extensions` command group (#4766) Adds extensions command group --- cli/azd/cmd/actions/action_descriptor.go | 13 +- cli/azd/cmd/container.go | 26 + cli/azd/cmd/extension.go | 835 ++++++++++++++++++ cli/azd/cmd/extensions.go | 195 ++++ cli/azd/cmd/root.go | 29 + .../TestUsage-azd-extension-install.snap | 19 + .../TestUsage-azd-extension-list.snap | 21 + .../TestUsage-azd-extension-show.snap | 16 + .../TestUsage-azd-extension-source-add.snap | 21 + .../TestUsage-azd-extension-source-list.snap | 16 + ...TestUsage-azd-extension-source-remove.snap | 16 + .../TestUsage-azd-extension-source.snap | 23 + .../TestUsage-azd-extension-uninstall.snap | 19 + .../TestUsage-azd-extension-upgrade.snap | 20 + .../cmd/testdata/TestUsage-azd-extension.snap | 26 + cli/azd/extensions/registry.json | 4 + cli/azd/extensions/registry.schema.json | 202 +++++ cli/azd/pkg/azd/default.go | 25 + cli/azd/pkg/extensions/manager.go | 26 +- cli/azd/pkg/extensions/manager_test.go | 3 +- 20 files changed, 1532 insertions(+), 23 deletions(-) create mode 100644 cli/azd/cmd/extension.go create mode 100644 cli/azd/cmd/extensions.go create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-source-add.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-source-list.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-source-remove.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension.snap create mode 100644 cli/azd/extensions/registry.json create mode 100644 cli/azd/extensions/registry.schema.json diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index 3c7ed0b6c5b..f4611a2d1b1 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -133,16 +133,17 @@ type ActionHelpOptions struct { type RootLevelHelpOption string const ( - CmdGroupNone RootLevelHelpOption = "" - CmdGroupConfig RootLevelHelpOption = "Configure and develop your app" - CmdGroupManage RootLevelHelpOption = "Manage Azure resources and app deployments" - CmdGroupMonitor RootLevelHelpOption = "Monitor, test and release your app" - CmdGroupAbout RootLevelHelpOption = "About, help and upgrade" + CmdGroupNone RootLevelHelpOption = "" + CmdGroupConfig RootLevelHelpOption = "Configure and develop your app" + CmdGroupManage RootLevelHelpOption = "Manage Azure resources and app deployments" + CmdGroupMonitor RootLevelHelpOption = "Monitor, test and release your app" + CmdGroupAbout RootLevelHelpOption = "About, help and upgrade" + CmdGroupExtensions RootLevelHelpOption = "Installed Extensions" ) func GetGroupAnnotations() []RootLevelHelpOption { return []RootLevelHelpOption{ - CmdGroupConfig, CmdGroupManage, CmdGroupMonitor, CmdGroupAbout, + CmdGroupConfig, CmdGroupManage, CmdGroupMonitor, CmdGroupExtensions, CmdGroupAbout, } } diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 9cad5fd979e..eaedaa105bd 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -22,6 +22,7 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/middleware" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/cmd" + "github.com/azure/azure-dev/cli/azd/internal/grpcserver" "github.com/azure/azure-dev/cli/azd/internal/repository" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/ai" @@ -40,6 +41,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/helm" "github.com/azure/azure-dev/cli/azd/pkg/httputil" "github.com/azure/azure-dev/cli/azd/pkg/infra" @@ -789,6 +791,30 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }) container.MustRegisterSingleton(workflow.NewRunner) + container.MustRegisterScoped(func(authManager *auth.Manager) prompt.AuthManager { + return authManager + }) + container.MustRegisterSingleton(func(subscriptionService *account.SubscriptionsService) prompt.SubscriptionService { + return subscriptionService + }) + container.MustRegisterSingleton(func(resourceService *azapi.ResourceService) prompt.ResourceService { + return resourceService + }) + + container.MustRegisterScoped(prompt.NewPromptService) + + // Extensions + container.MustRegisterSingleton(extensions.NewManager) + container.MustRegisterSingleton(extensions.NewSourceManager) + + // gRPC Server + container.MustRegisterScoped(grpcserver.NewServer) + container.MustRegisterScoped(grpcserver.NewProjectService) + container.MustRegisterScoped(grpcserver.NewEnvironmentService) + container.MustRegisterScoped(grpcserver.NewPromptService) + container.MustRegisterScoped(grpcserver.NewDeploymentService) + container.MustRegisterSingleton(grpcserver.NewUserConfigService) + // Required for nested actions called from composite actions like 'up' registerAction[*cmd.ProvisionAction](container, "azd-provision-action") registerAction[*downAction](container, "azd-down-action") diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go new file mode 100644 index 00000000000..b74b5b419ba --- /dev/null +++ b/cli/azd/cmd/extension.go @@ -0,0 +1,835 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/spf13/cobra" +) + +// Register extension commands +func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { + group := root.Add("extension", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "extension", + Aliases: []string{"ext"}, + Short: fmt.Sprintf("Manage azd extensions. %s", output.WithWarningFormat("(Alpha)")), + }, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupConfig, + }, + }) + + // azd extension list [--installed] + group.Add("list", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "list [--installed]", + Short: "List available extensions.", + }, + OutputFormats: []output.Format{output.JsonFormat, output.TableFormat}, + DefaultFormat: output.TableFormat, + ActionResolver: newExtensionListAction, + FlagsResolver: newExtensionListFlags, + }) + + // azd extension show + group.Add("show", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "show ", + Short: "Show details for a specific extension.", + Args: cobra.ExactArgs(1), + }, + OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, + DefaultFormat: output.NoneFormat, + ActionResolver: newExtensionShowAction, + }) + + // azd extension install + group.Add("install", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "install ", + Short: "Installs specified extensions.", + }, + ActionResolver: newExtensionInstallAction, + FlagsResolver: newExtensionInstallFlags, + }) + + // azd extension uninstall + group.Add("uninstall", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "uninstall ", + Short: "Uninstall specified extensions.", + }, + ActionResolver: newExtensionUninstallAction, + FlagsResolver: newExtensionUninstallFlags, + }) + + // azd extension upgrade + group.Add("upgrade", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "upgrade ", + Short: "Upgrade specified extensions.", + }, + ActionResolver: newExtensionUpgradeAction, + FlagsResolver: newExtensionUpgradeFlags, + }) + + sourceGroup := group.Add("source", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "source", + Short: "View and manage extension sources", + }, + }) + + sourceGroup.Add("list", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "list", + Short: "List extension sources", + }, + OutputFormats: []output.Format{output.JsonFormat, output.TableFormat}, + DefaultFormat: output.TableFormat, + ActionResolver: newExtensionSourceListAction, + }) + + sourceGroup.Add("add", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "add", + Short: "Add an extension source with the specified name", + }, + ActionResolver: newExtensionSourceAddAction, + FlagsResolver: newExtensionSourceAddFlags, + OutputFormats: []output.Format{output.NoneFormat}, + DefaultFormat: output.NoneFormat, + }) + + sourceGroup.Add("remove", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "remove ", + Short: "Remove an extension source with the specified name", + }, + ActionResolver: newExtensionSourceRemoveAction, + OutputFormats: []output.Format{output.NoneFormat}, + DefaultFormat: output.NoneFormat, + }) + + return group +} + +type extensionListFlags struct { + installed bool + source string + tags []string +} + +func newExtensionListFlags(cmd *cobra.Command) *extensionListFlags { + flags := &extensionListFlags{} + cmd.Flags().BoolVar(&flags.installed, "installed", false, "List installed extensions") + cmd.Flags().StringVar(&flags.source, "source", "", "Filter extensions by source") + cmd.Flags().StringSliceVar(&flags.tags, "tags", nil, "Filter extensions by tags") + + return flags +} + +// azd extension list [--installed] +type extensionListAction struct { + flags *extensionListFlags + formatter output.Formatter + writer io.Writer + sourceManager *extensions.SourceManager + extensionManager *extensions.Manager +} + +func newExtensionListAction( + flags *extensionListFlags, + formatter output.Formatter, + writer io.Writer, + sourceManager *extensions.SourceManager, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionListAction{ + flags: flags, + formatter: formatter, + writer: writer, + sourceManager: sourceManager, + extensionManager: extensionManager, + } +} + +type extensionListItem struct { + Id string + Name string + Namespace string + Version string + Installed bool +} + +func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, error) { + options := &extensions.ListOptions{ + Source: a.flags.source, + Tags: a.flags.tags, + } + + if options.Source != "" { + if _, err := a.sourceManager.Get(ctx, options.Source); err != nil { + return nil, fmt.Errorf("extension source '%s' not found: %w", options.Source, err) + } + } + + registryExtensions, err := a.extensionManager.ListFromRegistry(ctx, options) + if err != nil { + return nil, fmt.Errorf("failed listing extensions from registry: %w", err) + } + + installedExtensions, err := a.extensionManager.ListInstalled() + if err != nil { + return nil, fmt.Errorf("failed listing installed extensions: %w", err) + } + + extensionRows := []extensionListItem{} + + for _, extension := range registryExtensions { + installedExtension, installed := installedExtensions[extension.Id] + if a.flags.installed && !installed { + continue + } + + var version string + if installed { + version = installedExtension.Version + } else { + version = extension.Versions[len(extension.Versions)-1].Version + } + + extensionRows = append(extensionRows, extensionListItem{ + Id: extension.Id, + Name: extension.DisplayName, + Namespace: extension.Namespace, + Version: version, + Installed: installedExtensions[extension.Id] != nil, + }) + } + + var formatErr error + + if a.formatter.Kind() == output.TableFormat { + columns := []output.Column{ + { + Heading: "Id", + ValueTemplate: "{{.Id}}", + }, + { + Heading: "Name", + ValueTemplate: "{{.Name}}", + }, + { + Heading: "Version", + ValueTemplate: `{{.Version}}`, + }, + { + Heading: "Installed", + ValueTemplate: `{{.Installed}}`, + }, + } + + formatErr = a.formatter.Format(extensionRows, a.writer, output.TableFormatterOptions{ + Columns: columns, + }) + } else { + formatErr = a.formatter.Format(extensionRows, a.writer, nil) + } + + return nil, formatErr +} + +// azd extension show +type extensionShowAction struct { + args []string + formatter output.Formatter + writer io.Writer + extensionManager *extensions.Manager +} + +func newExtensionShowAction( + args []string, + formatter output.Formatter, + writer io.Writer, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionShowAction{ + args: args, + formatter: formatter, + writer: writer, + extensionManager: extensionManager, + } +} + +type extensionShowItem struct { + Name string + Description string + LatestVersion string + InstalledVersion string + Usage string + Examples []extensions.ExtensionExample +} + +func (t *extensionShowItem) Display(writer io.Writer) error { + tabs := tabwriter.NewWriter( + writer, + 0, + output.TableTabSize, + 1, + output.TablePadCharacter, + output.TableFlags) + text := [][]string{ + {"Name", ":", t.Name}, + {"Description", ":", t.Description}, + {"Latest Version", ":", t.LatestVersion}, + {"Installed Version", ":", t.InstalledVersion}, + {"", "", ""}, + {"Usage", ":", t.Usage}, + {"Examples", ":", ""}, + } + + for _, example := range t.Examples { + text = append(text, []string{"", "", example.Usage}) + } + + for _, line := range text { + _, err := tabs.Write([]byte(strings.Join(line, "\t") + "\n")) + if err != nil { + return err + } + } + + return tabs.Flush() +} + +func (a *extensionShowAction) Run(ctx context.Context) (*actions.ActionResult, error) { + extensionId := a.args[0] + registryExtension, err := a.extensionManager.GetFromRegistry(ctx, extensionId) + if err != nil { + return nil, fmt.Errorf("failed to get extension details: %w", err) + } + + latestVersion := registryExtension.Versions[len(registryExtension.Versions)-1] + + extensionDetails := extensionShowItem{ + Name: registryExtension.Id, + Description: registryExtension.DisplayName, + LatestVersion: latestVersion.Version, + Usage: latestVersion.Usage, + Examples: latestVersion.Examples, + InstalledVersion: "N/A", + } + + installedExtension, err := a.extensionManager.GetInstalled( + extensions.GetInstalledOptions{Id: extensionId}, + ) + if err == nil { + extensionDetails.InstalledVersion = installedExtension.Version + } + + var formatErr error + + if a.formatter.Kind() == output.NoneFormat { + formatErr = extensionDetails.Display(a.writer) + } else { + formatErr = a.formatter.Format(extensionDetails, a.writer, nil) + } + + return nil, formatErr +} + +type extensionInstallFlags struct { + version string +} + +func newExtensionInstallFlags(cmd *cobra.Command) *extensionInstallFlags { + flags := &extensionInstallFlags{} + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "The version of the extension to install") + + return flags +} + +// azd extension install +type extensionInstallAction struct { + args []string + flags *extensionInstallFlags + console input.Console + extensionManager *extensions.Manager +} + +func newExtensionInstallAction( + args []string, + flags *extensionInstallFlags, + console input.Console, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionInstallAction{ + args: args, + flags: flags, + console: console, + extensionManager: extensionManager, + } +} + +func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Install an azd extension (azd extension install)", + TitleNote: "Installs the specified extension onto the local machine", + }) + + extensionIds := a.args + if len(extensionIds) == 0 { + return nil, fmt.Errorf("must specify an extension name") + } + + if len(extensionIds) > 1 && a.flags.version != "" { + return nil, fmt.Errorf("cannot specify --version flag when using multiple extensions") + } + + for index, extensionId := range extensionIds { + if index > 0 { + a.console.Message(ctx, "") + } + + stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionId)) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + installed, err := a.extensionManager.GetInstalled(extensions.GetInstalledOptions{ + Id: extensionId, + }) + if err == nil { + stepMessage += output.WithGrayFormat(" (version %s already installed)", installed.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepSkipped) + continue + } + + extensionVersion, err := a.extensionManager.Install(ctx, extensionId, a.flags.version) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to install extension: %w", err) + } + + stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + + a.console.Message(ctx, fmt.Sprintf(" %s %s", output.WithBold("Usage: "), extensionVersion.Usage)) + a.console.Message(ctx, output.WithBold(" Examples:")) + + for _, example := range extensionVersion.Examples { + a.console.Message(ctx, " "+output.WithHighLightFormat(example.Usage)) + } + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extension(s) installed successfully", + }, + }, nil +} + +// azd extension uninstall +type extensionUninstallFlags struct { + all bool +} + +func newExtensionUninstallFlags(cmd *cobra.Command) *extensionUninstallFlags { + flags := &extensionUninstallFlags{} + cmd.Flags().BoolVar(&flags.all, "all", false, "Uninstall all installed extensions") + + return flags +} + +type extensionUninstallAction struct { + args []string + flags *extensionUninstallFlags + console input.Console + extensionManager *extensions.Manager +} + +func newExtensionUninstallAction( + args []string, + flags *extensionUninstallFlags, + console input.Console, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionUninstallAction{ + args: args, + flags: flags, + console: console, + extensionManager: extensionManager, + } +} + +func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if len(a.args) > 0 && a.flags.all { + return nil, fmt.Errorf("cannot specify both an extension name and --all flag") + } + + if len(a.args) == 0 && !a.flags.all { + return nil, fmt.Errorf("must specify an extension name or use --all flag") + } + + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Uninstall an azd extension (azd extension uninstall)", + TitleNote: "Uninstalls the specified extension from the local machine", + }) + + extensionIds := a.args + if a.flags.all { + installed, err := a.extensionManager.ListInstalled() + if err != nil { + return nil, fmt.Errorf("failed to list installed extensions: %w", err) + } + + extensionIds = make([]string, 0, len(installed)) + for name := range installed { + extensionIds = append(extensionIds, name) + } + } + + if len(extensionIds) == 0 { + return nil, fmt.Errorf("no extensions to uninstall") + } + + for _, extensionId := range extensionIds { + stepMessage := fmt.Sprintf("Uninstalling %s extension", output.WithHighLightFormat(extensionId)) + + installed, err := a.extensionManager.GetInstalled(extensions.GetInstalledOptions{ + Id: extensionId, + }) + if err != nil { + a.console.ShowSpinner(ctx, stepMessage, input.Step) + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + + return nil, fmt.Errorf("failed to get installed extension: %w", err) + } + + stepMessage += fmt.Sprintf(" (%s)", installed.Version) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + if err := a.extensionManager.Uninstall(extensionId); err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to uninstall extension: %w", err) + } + + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extension(s) uninstalled successfully", + }, + }, nil +} + +type extensionUpgradeFlags struct { + version string + all bool +} + +func newExtensionUpgradeFlags(cmd *cobra.Command) *extensionUpgradeFlags { + flags := &extensionUpgradeFlags{} + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "The version of the extension to upgrade to") + cmd.Flags().BoolVar(&flags.all, "all", false, "Upgrade all installed extensions") + + return flags +} + +// azd extension upgrade +type extensionUpgradeAction struct { + args []string + flags *extensionUpgradeFlags + console input.Console + extensionManager *extensions.Manager +} + +func newExtensionUpgradeAction( + args []string, + flags *extensionUpgradeFlags, + console input.Console, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionUpgradeAction{ + args: args, + flags: flags, + console: console, + extensionManager: extensionManager, + } +} + +func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if len(a.args) > 0 && a.flags.all { + return nil, fmt.Errorf("cannot specify both an extension name and --all flag") + } + + if len(a.args) > 1 && a.flags.version != "" { + return nil, fmt.Errorf("cannot specify --version flag when using multiple extensions") + } + + if len(a.args) == 0 && !a.flags.all { + return nil, fmt.Errorf("must specify an extension name or use --all flag") + } + + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Upgrade azd extensions (azd extension upgrade)", + TitleNote: "Upgrades the specified extensions on the local machine", + }) + + extensionIds := a.args + if a.flags.all { + installed, err := a.extensionManager.ListInstalled() + if err != nil { + return nil, fmt.Errorf("failed to list installed extensions: %w", err) + } + + extensionIds = make([]string, 0, len(installed)) + for name := range installed { + extensionIds = append(extensionIds, name) + } + } + + if len(extensionIds) == 0 { + return nil, fmt.Errorf("no extensions to upgrade") + } + + for index, extensionId := range extensionIds { + if index > 0 { + a.console.Message(ctx, "") + } + + stepMessage := fmt.Sprintf("Upgrading %s extension", output.WithHighLightFormat(extensionId)) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + installed, err := a.extensionManager.GetInstalled(extensions.GetInstalledOptions{ + Id: extensionId, + }) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to get installed extension: %w", err) + } + + extension, err := a.extensionManager.GetFromRegistry(ctx, extensionId) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to get extension %s: %w", extensionId, err) + } + + latestVersion := extension.Versions[len(extension.Versions)-1] + if latestVersion.Version == installed.Version { + stepMessage += output.WithGrayFormat(" (No upgrade available)") + a.console.StopSpinner(ctx, stepMessage, input.StepSkipped) + } else { + extensionVersion, err := a.extensionManager.Upgrade(ctx, extensionId, a.flags.version) + if err != nil { + return nil, fmt.Errorf("failed to upgrade extension: %w", err) + } + + stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + + a.console.Message(ctx, fmt.Sprintf(" %s %s", output.WithBold("Usage: "), extensionVersion.Usage)) + a.console.Message(ctx, output.WithBold(" Examples:")) + + for _, example := range extensionVersion.Examples { + a.console.Message(ctx, " "+output.WithHighLightFormat(example.Usage)) + } + } + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extensions upgraded successfully", + }, + }, nil +} + +type extensionSourceListAction struct { + formatter output.Formatter + writer io.Writer + sourceManager *extensions.SourceManager +} + +func newExtensionSourceListAction( + formatter output.Formatter, + writer io.Writer, + sourceManager *extensions.SourceManager, +) actions.Action { + return &extensionSourceListAction{ + formatter: formatter, + writer: writer, + sourceManager: sourceManager, + } +} + +func (a *extensionSourceListAction) Run(ctx context.Context) (*actions.ActionResult, error) { + sourceConfigs, err := a.sourceManager.List(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list extension sources: %w", err) + } + + if a.formatter.Kind() == output.TableFormat { + columns := []output.Column{ + { + Heading: "Name", + ValueTemplate: "{{.Name}}", + }, + { + Heading: "Type", + ValueTemplate: "{{.Type}}", + }, + { + Heading: "Location", + ValueTemplate: "{{.Location}}", + }, + } + + err = a.formatter.Format(sourceConfigs, a.writer, output.TableFormatterOptions{ + Columns: columns, + }) + } else { + err = a.formatter.Format(sourceConfigs, a.writer, nil) + } + + return nil, err +} + +type extensionSourceAddFlags struct { + name string + location string + kind string +} + +func newExtensionSourceAddFlags(cmd *cobra.Command) *extensionSourceAddFlags { + flags := &extensionSourceAddFlags{} + cmd.Flags().StringVar(&flags.name, "name", "", "The name of the extension source") + cmd.Flags().StringVar(&flags.location, "location", "", "The location of the extension source") + cmd.Flags().StringVar(&flags.kind, "king", "", "The type of the extension source") + + return flags +} + +type extensionSourceAddAction struct { + flags *extensionSourceAddFlags + console input.Console + sourceManager *extensions.SourceManager + args []string +} + +func newExtensionSourceAddAction( + flags *extensionSourceAddFlags, + console input.Console, + sourceManager *extensions.SourceManager, + args []string, +) actions.Action { + return &extensionSourceAddAction{ + flags: flags, + console: console, + sourceManager: sourceManager, + args: args, + } +} + +func (a *extensionSourceAddAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Add extension source (azd extension source add)", + }) + + var name = strings.ToLower(a.args[0]) + + spinnerMessage := "Validating extension source" + a.console.ShowSpinner(ctx, spinnerMessage, input.Step) + + sourceConfig := &extensions.SourceConfig{ + Type: extensions.SourceKind(a.flags.kind), + Location: a.flags.location, + Name: a.flags.name, + } + + // Validate the custom source config + _, err := a.sourceManager.CreateSource(ctx, sourceConfig) + a.console.StopSpinner(ctx, spinnerMessage, input.GetStepResultFormat(err)) + if err != nil { + if errors.Is(err, extensions.ErrSourceTypeInvalid) { + return nil, fmt.Errorf( + "extension source type '%s' is not supported. Supported types are %s", + a.flags.kind, + ux.ListAsText([]string{"'file'", "'url'"}), + ) + } + + return nil, fmt.Errorf("extension source validation failed: %w", err) + } + + spinnerMessage = "Saving extension source" + a.console.ShowSpinner(ctx, spinnerMessage, input.Step) + + err = a.sourceManager.Add(ctx, name, sourceConfig) + a.console.StopSpinner(ctx, spinnerMessage, input.GetStepResultFormat(err)) + if err != nil { + return nil, fmt.Errorf("failed adding extension source: %w", err) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: fmt.Sprintf("Added azd extension source %s", name), + FollowUp: "Run `azd extension list` to see the available set of azd extensions.", + }, + }, nil +} + +type extensionSourceRemoveAction struct { + sourceManager *extensions.SourceManager + console input.Console + args []string +} + +func newExtensionSourceRemoveAction( + sourceManager *extensions.SourceManager, + console input.Console, + args []string, +) actions.Action { + return &extensionSourceRemoveAction{ + sourceManager: sourceManager, + console: console, + args: args, + } +} + +func (a *extensionSourceRemoveAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Remove extension source (azd extension source remove)", + }) + + var key = strings.ToLower(a.args[0]) + spinnerMessage := fmt.Sprintf("Removing extension source (%s)", key) + a.console.ShowSpinner(ctx, spinnerMessage, input.Step) + err := a.sourceManager.Remove(ctx, key) + a.console.StopSpinner(ctx, spinnerMessage, input.GetStepResultFormat(err)) + if err != nil { + return nil, fmt.Errorf("failed removing extension source: %w", err) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: fmt.Sprintf("Removed azd extension source %s", key), + FollowUp: fmt.Sprintf( + "Add more extension sources by running %s", + output.WithHighLightFormat("azd extension source add "), + ), + }, + }, nil +} diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go new file mode 100644 index 00000000000..4289733fd5c --- /dev/null +++ b/cli/azd/cmd/extensions.go @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal/grpcserver" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// bindExtensions binds the extensions to the root command +func bindExtensions( + serviceLocator ioc.ServiceLocator, + root *actions.ActionDescriptor, + extensions map[string]*extensions.Extension, +) error { + for _, extension := range extensions { + if err := bindExtension(serviceLocator, root, extension); err != nil { + return err + } + } + + return nil +} + +// bindExtension binds the extension to the root command +func bindExtension( + serviceLocator ioc.ServiceLocator, + root *actions.ActionDescriptor, + extension *extensions.Extension, +) error { + cmd := &cobra.Command{ + Use: extension.Namespace, + Short: extension.Description, + Long: extension.Description, + DisableFlagParsing: true, + } + + cmd.SetHelpFunc(func(c *cobra.Command, s []string) { + _ = serviceLocator.Invoke(invokeExtensionHelp) + }) + + root.Add(extension.Namespace, &actions.ActionDescriptorOptions{ + Command: cmd, + ActionResolver: newExtensionAction, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupExtensions, + }, + }) + + return nil +} + +// invokeExtensionHelp invokes the help for the extension +func invokeExtensionHelp(console input.Console, commandRunner exec.CommandRunner, extensionManager *extensions.Manager) { + extensionNamespace := os.Args[1] + extension, err := extensionManager.GetInstalled(extensions.GetInstalledOptions{ + Namespace: extensionNamespace, + }) + if err != nil { + fmt.Println("Failed running help") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Println("Failed running help") + } + + extensionPath := filepath.Join(homeDir, extension.Path) + + runArgs := exec. + NewRunArgs(extensionPath, os.Args[2:]...). + WithStdIn(console.Handles().Stdin). + WithStdOut(console.Handles().Stdout). + WithStdErr(console.Handles().Stderr) + + _, err = commandRunner.Run(context.Background(), runArgs) + if err != nil { + fmt.Println("Failed running help") + } +} + +type extensionAction struct { + console input.Console + commandRunner exec.CommandRunner + lazyEnv *lazy.Lazy[*environment.Environment] + extensionManager *extensions.Manager + azdServer *grpcserver.Server + cmd *cobra.Command + args []string +} + +func newExtensionAction( + console input.Console, + commandRunner exec.CommandRunner, + lazyEnv *lazy.Lazy[*environment.Environment], + extensionManager *extensions.Manager, + cmd *cobra.Command, + azdServer *grpcserver.Server, + args []string, +) actions.Action { + return &extensionAction{ + console: console, + commandRunner: commandRunner, + lazyEnv: lazyEnv, + extensionManager: extensionManager, + azdServer: azdServer, + cmd: cmd, + args: args, + } +} + +func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error) { + extensionNamespace := a.cmd.Use + + extension, err := a.extensionManager.GetInstalled(extensions.GetInstalledOptions{ + Namespace: extensionNamespace, + }) + if err != nil { + return nil, fmt.Errorf("failed to get extension %s: %w", extensionNamespace, err) + } + + allEnv := []string{} + allEnv = append(allEnv, os.Environ()...) + + forceColor := !color.NoColor + if forceColor { + allEnv = append(allEnv, "FORCE_COLOR=1") + } + + env, err := a.lazyEnv.GetValue() + if err == nil && env != nil { + allEnv = append(allEnv, env.Environ()...) + } + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current working directory: %w", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + extensionPath := filepath.Join(homeDir, extension.Path) + + _, err = os.Stat(extensionPath) + if err != nil { + return nil, fmt.Errorf("extension path was not found: %s: %w", extensionPath, err) + } + + serverInfo, err := a.azdServer.Start() + if err != nil { + return nil, fmt.Errorf("failed to start gRPC server: %w", err) + } + + allEnv = append(allEnv, + fmt.Sprintf("AZD_SERVER=%s", serverInfo.Address), + fmt.Sprintf("AZD_ACCESS_TOKEN=%s", serverInfo.AccessToken), + ) + + runArgs := exec. + NewRunArgs(extensionPath, a.args...). + WithCwd(cwd). + WithEnv(allEnv). + WithStdIn(a.console.Handles().Stdin). + WithStdOut(a.console.Handles().Stdout). + WithStdErr(a.console.Handles().Stderr) + + _, err = a.commandRunner.Run(ctx, runArgs) + if err != nil { + log.Printf("Failed to run extension %s: %v\n", extensionNamespace, err) + } + + if err = a.azdServer.Stop(); err != nil { + log.Printf("Failed to stop gRPC server: %v\n", err) + } + + return nil, nil +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 0ad238d88e8..26d8ee5720a 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -15,7 +15,9 @@ import ( // Importing for infrastructure provider plugin registrations + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/azd" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/platform" @@ -349,6 +351,28 @@ func NewRootCmd( ioc.RegisterNamedInstance(rootContainer, "root-cmd", rootCmd) registerCommonDependencies(rootContainer) + // Conditionally register the 'extension' commands if the feature is enabled + err := rootContainer.Invoke(func(alphaFeatureManager *alpha.FeatureManager, extensionManager *extensions.Manager) error { + if alphaFeatureManager.IsEnabled(extensions.FeatureExtensions) { + extensionActions(root) + + installedExtensions, err := extensionManager.ListInstalled() + if err != nil { + return fmt.Errorf("Failed to get installed extensions: %w", err) + } + + if err := bindExtensions(rootContainer, root, installedExtensions); err != nil { + return fmt.Errorf("Failed to bind extensions: %w", err) + } + } + + return nil + }) + + if err != nil { + panic(err) + } + // Initialize the platform specific components for the IoC container // Only container resolution errors will return an error // Invalid configurations will fall back to default platform @@ -429,6 +453,11 @@ func getCmdRootHelpCommands(cmd *cobra.Command) (result string) { var paragraph []string for _, title := range groups { + groupCommands := commandGroups[string(title)] + if len(groupCommands) == 0 { + continue + } + paragraph = append(paragraph, fmt.Sprintf(" %s\n %s\n", output.WithBold("%s", string(title)), strings.Join(commandGroups[string(title)], "\n "))) diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap new file mode 100644 index 00000000000..f0e4cff7127 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap @@ -0,0 +1,19 @@ + +Installs specified extensions. + +Usage + azd extension install [flags] + +Flags + -v, --version string : The version of the extension to install + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension install in your web browser. + -h, --help : Gets help for install. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap new file mode 100644 index 00000000000..a5d9b3ca8e0 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap @@ -0,0 +1,21 @@ + +List available extensions. + +Usage + azd extension list [--installed] [flags] + +Flags + --installed : List installed extensions + --source string : Filter extensions by source + --tags strings : Filter extensions by tags + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension list in your web browser. + -h, --help : Gets help for list. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap new file mode 100644 index 00000000000..39fcf5a04bb --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap @@ -0,0 +1,16 @@ + +Show details for a specific extension. + +Usage + azd extension show [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension show in your web browser. + -h, --help : Gets help for show. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-source-add.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-add.snap new file mode 100644 index 00000000000..7d3fa2dca43 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-add.snap @@ -0,0 +1,21 @@ + +Add an extension source with the specified name + +Usage + azd extension source add [flags] + +Flags + --king string : The type of the extension source + --location string : The location of the extension source + --name string : The name of the extension source + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension source add in your web browser. + -h, --help : Gets help for add. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-source-list.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-list.snap new file mode 100644 index 00000000000..debc28d90d8 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-list.snap @@ -0,0 +1,16 @@ + +List extension sources + +Usage + azd extension source list [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension source list in your web browser. + -h, --help : Gets help for list. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-source-remove.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-remove.snap new file mode 100644 index 00000000000..2496df6c488 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-remove.snap @@ -0,0 +1,16 @@ + +Remove an extension source with the specified name + +Usage + azd extension source remove [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension source remove in your web browser. + -h, --help : Gets help for remove. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap new file mode 100644 index 00000000000..004f1b55b7c --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap @@ -0,0 +1,23 @@ + +View and manage extension sources + +Usage + azd extension source [command] + +Available Commands + add : Add an extension source with the specified name + list : List extension sources + remove : Remove an extension source with the specified name + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension source in your web browser. + -h, --help : Gets help for source. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Use azd extension source [command] --help to view examples and more information about a specific command. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap new file mode 100644 index 00000000000..62b6519d2f5 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -0,0 +1,19 @@ + +Uninstall specified extensions. + +Usage + azd extension uninstall [flags] + +Flags + --all : Uninstall all installed extensions + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension uninstall in your web browser. + -h, --help : Gets help for uninstall. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap new file mode 100644 index 00000000000..8571b43ecc1 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap @@ -0,0 +1,20 @@ + +Upgrade specified extensions. + +Usage + azd extension upgrade [flags] + +Flags + --all : Upgrade all installed extensions + -v, --version string : The version of the extension to upgrade to + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension upgrade in your web browser. + -h, --help : Gets help for upgrade. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap new file mode 100644 index 00000000000..c5682bd8526 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap @@ -0,0 +1,26 @@ + +Manage azd extensions. (Alpha) + +Usage + azd extension [command] + +Available Commands + install : Installs specified extensions. + list : List available extensions. + show : Show details for a specific extension. + source : View and manage extension sources + uninstall : Uninstall specified extensions. + upgrade : Upgrade specified extensions. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension in your web browser. + -h, --help : Gets help for extension. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Use azd extension [command] --help to view examples and more information about a specific command. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json new file mode 100644 index 00000000000..1f74a1e5ff2 --- /dev/null +++ b/cli/azd/extensions/registry.json @@ -0,0 +1,4 @@ +{ + "$schema": "registry.schema.json", + "extensions": [] +} \ No newline at end of file diff --git a/cli/azd/extensions/registry.schema.json b/cli/azd/extensions/registry.schema.json new file mode 100644 index 00000000000..9d2e96e6de3 --- /dev/null +++ b/cli/azd/extensions/registry.schema.json @@ -0,0 +1,202 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AZD Extensions Schema", + "description": "Schema defining the structure of AZD extensions, including versions, artifacts, and dependencies.", + "type": "object", + "definitions": { + "Extension": { + "type": "object", + "title": "Extension", + "description": "Defines an extension that can have multiple versions and associated metadata.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the extension. Must be unique across all extensions.", + "pattern": "^[a-z0-9-.]+$" + }, + "namespace": { + "type": "string", + "description": "Namespace for organizing extensions. Required for proper classification." + }, + "displayName": { + "type": "string", + "description": "Human-readable name of the extension." + }, + "description": { + "type": "string", + "description": "Detailed description of the extension." + }, + "versions": { + "type": "array", + "minItems": 1, + "description": "List of versions available for this extension.", + "items": { + "$ref": "#/definitions/Version" + } + }, + "tags": { + "type": "array", + "description": "Tags categorizing the extension.", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "namespace", + "displayName", + "description", + "versions" + ] + }, + "Version": { + "type": "object", + "title": "Version", + "description": "Defines a specific version of an extension, including artifacts and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "Version number following semantic versioning.", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "usage": { + "type": "string", + "description": "Usage instructions for this version." + }, + "examples": { + "type": "array", + "minItems": 1, + "description": "Examples of usage commands.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the example." + }, + "description": { + "type": "string", + "description": "Description of what the example does." + }, + "usage": { + "type": "string", + "description": "Command to execute the example." + } + }, + "required": [ + "name", + "description", + "usage" + ] + } + }, + "artifacts": { + "type": "object", + "description": "Collection of artifacts where each key is a unique identifier for the artifact.", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/Artifact" + } + }, + "dependencies": { + "type": "array", + "description": "List of dependencies required by this version.", + "items": { + "$ref": "#/definitions/Dependency" + }, + "minItems": 1 + } + }, + "required": [ + "version", + "usage", + "examples" + ], + "anyOf": [ + { + "required": [ + "artifacts" + ] + }, + { + "required": [ + "dependencies" + ] + } + ] + }, + "Artifact": { + "type": "object", + "title": "Artifact", + "description": "Defines a downloadable artifact for an extension version.", + "properties": { + "checksum": { + "type": "object", + "description": "Checksum for verifying artifact integrity.", + "properties": { + "algorithm": { + "type": "string", + "description": "Checksum algorithm used." + }, + "value": { + "type": "string", + "description": "Checksum value for verification." + } + }, + "required": [ + "algorithm", + "value" + ] + }, + "entryPoint": { + "type": "string", + "description": "Executable entry point for the artifact." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Download URL for the artifact." + } + }, + "required": [ + "url" + ] + }, + "Dependency": { + "type": "object", + "title": "Dependency", + "description": "Defines a dependency required by an extension version.", + "properties": { + "id": { + "type": "string", + "description": "ID of the dependency extension." + }, + "version": { + "type": "string", + "description": "Required version of the dependency. Must follow semantic versioning.", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + } + }, + "required": [ + "id", + "version" + ] + } + }, + "properties": { + "extensions": { + "$comment": "Each extension must have a unique 'id' within the array.", + "type": "array", + "title": "Extensions", + "description": "List of all available extensions.", + "items": { + "$ref": "#/definitions/Extension" + } + }, + "signature": { + "type": "string", + "description": "Optional signature for verifying schema integrity." + } + } +} \ No newline at end of file diff --git a/cli/azd/pkg/azd/default.go b/cli/azd/pkg/azd/default.go index 1cbadfc3092..5beee6b0efc 100644 --- a/cli/azd/pkg/azd/default.go +++ b/cli/azd/pkg/azd/default.go @@ -15,6 +15,7 @@ import ( infraBicep "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/bicep" infraTerraform "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/terraform" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/sqldb" @@ -51,6 +52,30 @@ func (p *DefaultPlatform) ConfigureContainer(container *ioc.NestedContainer) err container.MustRegisterSingleton(terraform.NewCli) container.MustRegisterSingleton(bicep.NewCli) + container.MustRegisterTransient(func() *lazy.Lazy[*infraBicep.BicepProvider] { + return lazy.NewLazy(func() (*infraBicep.BicepProvider, error) { + var provider provisioning.Provider + if err := container.ResolveNamed(string(provisioning.Bicep), &provider); err != nil { + return nil, err + } + + bicepProvider, ok := provider.(*infraBicep.BicepProvider) + if !ok { + return nil, fmt.Errorf("unexpected provider type: %T", provider) + } + + return bicepProvider, nil + }) + }) + + container.MustRegisterTransient( + func(lazyBicepProvider *lazy.Lazy[*infraBicep.BicepProvider], + ) (*infraBicep.BicepProvider, error) { + return lazyBicepProvider.GetValue() + }) + + container.MustRegisterTransient(infraBicep.NewBicepProvider) + // Provisioning Providers provisionProviderMap := map[provisioning.ProviderKind]any{ provisioning.Bicep: infraBicep.NewBicepProvider, diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 770129cf1eb..58eab5cf5af 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -22,6 +22,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" azruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/rzip" ) @@ -36,7 +37,8 @@ var ( ErrInstalledExtensionNotFound = errors.New("extension not found") ErrRegistryExtensionNotFound = errors.New("extension not found in registry") ErrExtensionInstalled = errors.New("extension already installed") - //registryCacheDuration = 24 * time.Hour + + FeatureExtensions = alpha.MustFeatureKey("extensions") ) type ListOptions struct { @@ -61,28 +63,22 @@ func NewManager( configManager config.UserConfigManager, sourceManager *SourceManager, transport policy.Transporter, -) *Manager { +) (*Manager, error) { + userConfig, err := configManager.Load() + if err != nil { + return nil, err + } + pipeline := azruntime.NewPipeline("azd-extensions", "1.0.0", azruntime.PipelineOptions{}, &policy.ClientOptions{ Transport: transport, }) return &Manager{ + userConfig: userConfig, configManager: configManager, sourceManager: sourceManager, pipeline: pipeline, - } -} - -// Initialize the extension manager -func (m *Manager) Initialize() error { - userConfig, err := m.configManager.Load() - if err != nil { - return err - } - - m.userConfig = userConfig - - return nil + }, nil } // ListInstalled retrieves a list of installed extensions diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index c65634f4fba..620b2bece52 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -145,8 +145,7 @@ func Test_List_Install_Uninstall_Flow(t *testing.T) { userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) sourceManager := NewSourceManager(mockContext.Container, userConfigManager, http.DefaultClient) - manager := NewManager(userConfigManager, sourceManager, http.DefaultClient) - err := manager.Initialize() + manager, err := NewManager(userConfigManager, sourceManager, http.DefaultClient) require.NoError(t, err) // List installed extensions (expect 0)