From 437bd88d853c2ff4646bbcae8393918e216e5bd8 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 3 Oct 2024 16:54:53 -0700 Subject: [PATCH 1/6] wip: extensions poc --- cli/azd/cmd/actions/action_descriptor.go | 13 +- cli/azd/cmd/container.go | 4 + cli/azd/cmd/extensions.go | 161 +++++++++++++++++++++++ cli/azd/cmd/root.go | 10 ++ cli/azd/pkg/extensions/extension.go | 10 ++ cli/azd/pkg/extensions/extensions.go | 19 +++ cli/azd/pkg/extensions/manager.go | 55 ++++++++ 7 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 cli/azd/cmd/extensions.go create mode 100644 cli/azd/pkg/extensions/extension.go create mode 100644 cli/azd/pkg/extensions/extensions.go create mode 100644 cli/azd/pkg/extensions/manager.go diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index 267cdcabb05..d9f6ad83bb9 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -130,16 +130,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 = "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 6b7facee0c7..d0d005e72ad 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -37,6 +37,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" @@ -787,6 +788,9 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }) container.MustRegisterSingleton(workflow.NewRunner) + // Extensions + container.MustRegisterSingleton(extensions.NewManager) + // 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/extensions.go b/cli/azd/cmd/extensions.go new file mode 100644 index 00000000000..a3daeb818c5 --- /dev/null +++ b/cli/azd/cmd/extensions.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "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/spf13/cobra" +) + +func bindExtensions( + serviceLocator ioc.ServiceLocator, + root *actions.ActionDescriptor, + extensions map[string]*extensions.Extension, +) error { + for key, extension := range extensions { + if extension.Name == "" { + extension.Name = key + } + + if err := bindExtension(serviceLocator, root, extension); err != nil { + return err + } + } + + return nil +} + +func bindExtension( + serviceLocator ioc.ServiceLocator, + root *actions.ActionDescriptor, + extension *extensions.Extension, +) error { + cmd := &cobra.Command{ + Use: extension.Name, + Short: extension.Description, + Long: extension.Description, + DisableFlagParsing: true, + } + + cmd.SetHelpFunc(func(c *cobra.Command, s []string) { + serviceLocator.Invoke(invokeExtensionHelp) + }) + + root.Add(extension.Name, &actions.ActionDescriptorOptions{ + Command: cmd, + ActionResolver: newExtensionAction, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupExtensions, + }, + }) + + return nil +} + +func invokeExtensionHelp(console input.Console, commandRunner exec.CommandRunner, extensionManager *extensions.Manager) { + extensionName := os.Args[1] + extension, err := extensionManager.Get(extensionName) + 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 +} + +func newExtensionAction( + console input.Console, + commandRunner exec.CommandRunner, + lazyEnv *lazy.Lazy[*environment.Environment], + extensionManager *extensions.Manager, +) actions.Action { + return &extensionAction{ + console: console, + commandRunner: commandRunner, + lazyEnv: lazyEnv, + extensionManager: extensionManager, + } +} + +func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error) { + extensionName := os.Args[1] + + extension, err := a.extensionManager.Get(extensionName) + if err != nil { + return nil, fmt.Errorf("failed to get extension %s: %w", extensionName, err) + } + + allEnv := []string{} + allEnv = append(allEnv, os.Environ()...) + + env, err := a.lazyEnv.GetValue() + if err == nil && env != nil { + allEnv = append(allEnv, env.Environ()...) + } + + allArgs := []string{} + allArgs = append(allArgs, os.Args[2:]...) + + 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) + } + + runArgs := exec. + NewRunArgs(extensionPath, allArgs...). + 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 { + return nil, err + } + + return nil, nil +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 7b0c9f0b501..52c779e3325 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -16,6 +16,7 @@ import ( // Importing for infrastructure provider plugin registrations "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" @@ -351,6 +352,15 @@ func NewRootCmd( panic(err) } + installedExtensions, err := extensions.Initialize(rootContainer) + if err != nil { + log.Printf("Failed to initialize extensions: %v", err) + } + + if err := bindExtensions(rootContainer, root, installedExtensions); err != nil { + log.Printf("Failed to bind extensions: %v", err) + } + // Compose the hierarchy of action descriptions into cobra commands var cobraBuilder *CobraBuilder if err := rootContainer.Resolve(&cobraBuilder); err != nil { diff --git a/cli/azd/pkg/extensions/extension.go b/cli/azd/pkg/extensions/extension.go new file mode 100644 index 00000000000..5b179b71c72 --- /dev/null +++ b/cli/azd/pkg/extensions/extension.go @@ -0,0 +1,10 @@ +package extensions + +type Extension struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Version string `json:"version"` + Usage string `json:"usage"` + Path string `json:"path"` +} diff --git a/cli/azd/pkg/extensions/extensions.go b/cli/azd/pkg/extensions/extensions.go new file mode 100644 index 00000000000..29939992c6e --- /dev/null +++ b/cli/azd/pkg/extensions/extensions.go @@ -0,0 +1,19 @@ +package extensions + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/ioc" +) + +func Initialize(serviceLocator *ioc.NestedContainer) (map[string]*Extension, error) { + var manager *Manager + if err := serviceLocator.Resolve(&manager); err != nil { + return nil, err + } + + extensions, err := manager.Initialize() + if err != nil { + return nil, err + } + + return extensions, nil +} diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go new file mode 100644 index 00000000000..05578f120af --- /dev/null +++ b/cli/azd/pkg/extensions/manager.go @@ -0,0 +1,55 @@ +package extensions + +import ( + "errors" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/config" +) + +var ( + ErrNotFound = errors.New("extension not found") +) + +type Manager struct { + configManager config.UserConfigManager + userConfig config.Config + extensions map[string]*Extension +} + +func NewManager(configManager config.UserConfigManager) *Manager { + return &Manager{ + configManager: configManager, + } +} + +func (m *Manager) Initialize() (map[string]*Extension, error) { + userConfig, err := m.configManager.Load() + if err != nil { + return nil, err + } + + m.userConfig = userConfig + + var extensions map[string]*Extension + ok, err := m.userConfig.GetSection("extensions", &extensions) + if err != nil { + return nil, fmt.Errorf("failed to get extensions section: %w", err) + } + + if !ok { + return nil, nil + } + + m.extensions = extensions + + return extensions, nil +} + +func (m *Manager) Get(name string) (*Extension, error) { + if extension, has := m.extensions[name]; has { + return extension, nil + } + + return nil, fmt.Errorf("%s %w", name, ErrNotFound) +} From bb0a5e0ee558b487ef7a710c1cd2965f9a6685a6 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 3 Oct 2024 16:59:35 -0700 Subject: [PATCH 2/6] Updates --- cli/azd/cmd/actions/action_descriptor.go | 2 +- cli/azd/cmd/extensions.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index d9f6ad83bb9..bb00387f64b 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -135,7 +135,7 @@ const ( CmdGroupManage RootLevelHelpOption = "Manage Azure resources and app deployments" CmdGroupMonitor RootLevelHelpOption = "Monitor, test and release your app" CmdGroupAbout RootLevelHelpOption = "About, help and upgrade" - CmdGroupExtensions RootLevelHelpOption = "Extensions" + CmdGroupExtensions RootLevelHelpOption = "Installed Extensions" ) func GetGroupAnnotations() []RootLevelHelpOption { diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index a3daeb818c5..86beb6743b8 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" ) +// bindExtensions binds the extensions to the root command func bindExtensions( serviceLocator ioc.ServiceLocator, root *actions.ActionDescriptor, @@ -34,6 +35,7 @@ func bindExtensions( return nil } +// bindExtension binds the extension to the root command func bindExtension( serviceLocator ioc.ServiceLocator, root *actions.ActionDescriptor, @@ -61,6 +63,7 @@ func bindExtension( return nil } +// invokeExtensionHelp invokes the help for the extension func invokeExtensionHelp(console input.Console, commandRunner exec.CommandRunner, extensionManager *extensions.Manager) { extensionName := os.Args[1] extension, err := extensionManager.Get(extensionName) From ec0e923abb75bad8e1fd086f7635d1f34d42b3d6 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 4 Oct 2024 15:09:00 -0700 Subject: [PATCH 3/6] Adds azd extension commands --- cli/azd/cmd/extension.go | 526 +++++++++++++++++++++++++++ cli/azd/cmd/extensions.go | 6 +- cli/azd/cmd/root.go | 1 + cli/azd/pkg/cache/file_cache.go | 98 +++++ cli/azd/pkg/extensions/extensions.go | 7 +- cli/azd/pkg/extensions/manager.go | 434 +++++++++++++++++++++- cli/azd/pkg/extensions/registry.go | 30 ++ 7 files changed, 1086 insertions(+), 16 deletions(-) create mode 100644 cli/azd/cmd/extension.go create mode 100644 cli/azd/pkg/cache/file_cache.go create mode 100644 cli/azd/pkg/extensions/registry.go diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go new file mode 100644 index 00000000000..f97db1f3483 --- /dev/null +++ b/cli/azd/cmd/extension.go @@ -0,0 +1,526 @@ +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/internal" + "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", + Short: "Manage azd extensions.", + }, + 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: "Install an extension.", + Args: cobra.ExactArgs(1), + }, + ActionResolver: newExtensionInstallAction, + FlagsResolver: newExtensionInstallFlags, + }) + + // azd extension uninstall + group.Add("uninstall", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "uninstall ", + Short: "Uninstall an extension.", + Args: cobra.ExactArgs(1), + }, + ActionResolver: newExtensionUninstallAction, + }) + + // azd extension upgrade + group.Add("upgrade", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "upgrade ", + Short: "Upgrade an installed extension.", + Args: cobra.MaximumNArgs(1), + }, + ActionResolver: newExtensionUpgradeAction, + FlagsResolver: newExtensionUpgradeFlags, + }) + + return group +} + +type extensionListFlags struct { + installed bool +} + +func newExtensionListFlags(cmd *cobra.Command) *extensionListFlags { + flags := &extensionListFlags{} + cmd.Flags().BoolVar(&flags.installed, "installed", false, "List installed extensions") + + return flags +} + +// azd extension list [--installed] +type extensionListAction struct { + flags *extensionListFlags + formatter output.Formatter + writer io.Writer + extensionManager *extensions.Manager +} + +func newExtensionListAction( + flags *extensionListFlags, + formatter output.Formatter, + writer io.Writer, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionListAction{ + flags: flags, + formatter: formatter, + writer: writer, + extensionManager: extensionManager, + } +} + +type extensionListItem struct { + Name string + Description string + Version string + Installed bool +} + +func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, error) { + registryExtensions, err := a.extensionManager.ListFromRegistry(ctx) + 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.Name] + 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{ + Name: extension.Name, + Version: version, + Description: extension.DisplayName, + Installed: installedExtensions[extension.Name] != nil, + }) + } + + var formatErr error + + if a.formatter.Kind() == output.TableFormat { + columns := []output.Column{ + { + Heading: "Name", + ValueTemplate: `{{.Name}}`, + }, + { + Heading: "Description", + ValueTemplate: "{{.Description}}", + }, + { + 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 []string +} + +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}) + } + + 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) { + extensionName := a.args[0] + registryExtension, err := a.extensionManager.GetFromRegistry(ctx, extensionName) + 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.Name, + Description: registryExtension.DisplayName, + LatestVersion: latestVersion.Version, + Usage: latestVersion.Usage, + Examples: latestVersion.Examples, + InstalledVersion: "N/A", + } + + installedExtension, err := a.extensionManager.GetInstalled(extensionName) + 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", + }) + + extensionName := a.args[0] + + stepMessage := fmt.Sprintf("Installing extension %s", extensionName) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + extensionVersion, err := a.extensionManager.Install(ctx, extensionName, a.flags.version) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + + if errors.Is(err, extensions.ErrExtensionInstalled) { + return nil, &internal.ErrorWithSuggestion{ + Err: err, + Suggestion: fmt.Sprint("Run 'azd extension upgrade ", extensionName, "' to upgrade the extension."), + } + } + + return nil, fmt.Errorf("failed to install extension: %w", err) + } + + stepMessage += fmt.Sprintf(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + + lines := []string{ + fmt.Sprintf("Usage: %s", extensionVersion.Usage), + "\nExamples:", + } + + lines = append(lines, extensionVersion.Examples...) + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extension installed successfully", + FollowUp: strings.Join(lines, "\n"), + }, + }, nil +} + +// azd extension uninstall +type extensionUninstallAction struct { + args []string + console input.Console + extensionManager *extensions.Manager +} + +func newExtensionUninstallAction( + args []string, + console input.Console, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionUninstallAction{ + args: args, + console: console, + extensionManager: extensionManager, + } +} + +func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Uninstall an azd extension (azd extension uninstall)", + TitleNote: "Uninstalls the specified extension from the local machine", + }) + + extensionName := a.args[0] + stepMessage := fmt.Sprintf("Uninstalling extension %s", extensionName) + + installed, err := a.extensionManager.GetInstalled(extensionName) + 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(extensionName); 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 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) { + extensionName := "" + if len(a.args) > 0 { + extensionName = a.args[0] + } + + if extensionName != "" && a.flags.all { + return nil, fmt.Errorf("cannot specify both an extension name and --all flag") + } + + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Upgrade azd extensions (azd extension upgrade)", + TitleNote: "Upgrades the specified extensions on the local machine", + }) + + if extensionName != "" { + stepMessage := fmt.Sprintf("Upgrading extension %s", extensionName) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + extensionVersion, err := a.extensionManager.Upgrade(ctx, extensionName, a.flags.version) + if err != nil { + return nil, fmt.Errorf("failed to upgrade extension: %w", err) + } + + stepMessage += fmt.Sprintf(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + + lines := []string{ + fmt.Sprintf("%s %s", output.WithBold("Usage: "), extensionVersion.Usage), + output.WithBold("\nExamples:"), + } + + for _, example := range extensionVersion.Examples { + lines = append(lines, " "+output.WithHighLightFormat(example)) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extension upgraded successfully", + FollowUp: strings.Join(lines, "\n"), + }, + }, nil + } else { + installed, err := a.extensionManager.ListInstalled() + if err != nil { + return nil, fmt.Errorf("failed to list installed extensions: %w", err) + } + + for name := range installed { + stepMessage := fmt.Sprintf("Upgrading extension %s", name) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + extensionVersion, err := a.extensionManager.Upgrade(ctx, name, "") + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to upgrade extension %s: %w", name, err) + } + + stepMessage += fmt.Sprintf(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "All extensions upgraded successfully", + }, + }, nil + } +} diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 86beb6743b8..fdd5ac417d4 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -49,7 +49,7 @@ func bindExtension( } cmd.SetHelpFunc(func(c *cobra.Command, s []string) { - serviceLocator.Invoke(invokeExtensionHelp) + _ = serviceLocator.Invoke(invokeExtensionHelp) }) root.Add(extension.Name, &actions.ActionDescriptorOptions{ @@ -66,7 +66,7 @@ func bindExtension( // invokeExtensionHelp invokes the help for the extension func invokeExtensionHelp(console input.Console, commandRunner exec.CommandRunner, extensionManager *extensions.Manager) { extensionName := os.Args[1] - extension, err := extensionManager.Get(extensionName) + extension, err := extensionManager.GetInstalled(extensionName) if err != nil { fmt.Println("Failed running help") } @@ -114,7 +114,7 @@ func newExtensionAction( func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error) { extensionName := os.Args[1] - extension, err := a.extensionManager.Get(extensionName) + extension, err := a.extensionManager.GetInstalled(extensionName) if err != nil { return nil, fmt.Errorf("failed to get extension %s: %w", extensionName, err) } diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 52c779e3325..878f906e69c 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -126,6 +126,7 @@ func NewRootCmd( templatesActions(root) authActions(root) hooksActions(root) + extensionActions(root) root.Add("version", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ diff --git a/cli/azd/pkg/cache/file_cache.go b/cli/azd/pkg/cache/file_cache.go new file mode 100644 index 00000000000..6f9624a036b --- /dev/null +++ b/cli/azd/pkg/cache/file_cache.go @@ -0,0 +1,98 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/osutil" +) + +// CacheResolver is a function that resolves the cache value. +type CacheResolver[T any] func(ctx context.Context) (*T, error) + +// FileCache is a cache that stores the value in a file otherwise resolves it. +type FileCache[T any] struct { + filePath string + resolver CacheResolver[T] + cacheDuration time.Duration + value *T +} + +// NewFileCache creates a new file cache. +func NewFileCache[T any](cacheFilePath string, cacheDuration time.Duration, resolver CacheResolver[T]) *FileCache[T] { + return &FileCache[T]{ + filePath: cacheFilePath, + resolver: resolver, + cacheDuration: cacheDuration, + } +} + +// Resolve returns the value from the cache or resolves it. +func (c *FileCache[T]) Resolve(ctx context.Context) (*T, error) { + if c.isValid() { + if c.value == nil { + if err := c.loadFromFile(); err == nil { + return c.value, nil + } + } + return c.value, nil + } + + value, err := c.resolver(ctx) + if err != nil { + return nil, fmt.Errorf("failed to resolve data: %w", err) + } + + if err := c.Set(value); err != nil { + return nil, fmt.Errorf("failed to set cache: %w", err) + } + + return c.value, nil +} + +// Set sets the value in the cache. +func (c *FileCache[T]) Set(value *T) error { + c.value = value + jsonValue, err := json.Marshal(c.value) + if err != nil { + return fmt.Errorf("failed to marshal value: %w", err) + } + + if err := os.WriteFile(c.filePath, jsonValue, osutil.PermissionFile); err != nil { + return fmt.Errorf("failed to write cache: %w", err) + } + + return nil +} + +// isValid checks if the cache is valid. +func (c *FileCache[T]) isValid() bool { + val, has := os.LookupEnv("AZD_NO_CACHE") + if has { + noCache, err := strconv.ParseBool(val) + if err == nil && noCache { + return false + } + } + + info, err := os.Stat(c.filePath) + if os.IsNotExist(err) { + return false + } + + return time.Since(info.ModTime()) < c.cacheDuration +} + +// loadFromFile loads the cache from the file. +func (c *FileCache[T]) loadFromFile() error { + data, err := os.ReadFile(c.filePath) + if err != nil { + return err + } + + return json.Unmarshal(data, &c.value) +} diff --git a/cli/azd/pkg/extensions/extensions.go b/cli/azd/pkg/extensions/extensions.go index 29939992c6e..b0e5440da9b 100644 --- a/cli/azd/pkg/extensions/extensions.go +++ b/cli/azd/pkg/extensions/extensions.go @@ -10,7 +10,12 @@ func Initialize(serviceLocator *ioc.NestedContainer) (map[string]*Extension, err return nil, err } - extensions, err := manager.Initialize() + err := manager.Initialize() + if err != nil { + return nil, err + } + + extensions, err := manager.ListInstalled() if err != nil { return nil, err } diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 05578f120af..ecc2e02b9d7 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -1,55 +1,465 @@ package extensions import ( + "context" + "encoding/json" "errors" "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + "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/cache" "github.com/azure/azure-dev/cli/azd/pkg/config" ) +const ( + registryCacheFilePath = "registry.cache" + extensionRegistryUrl = "https://raw.githubusercontent.com/wbreza/azd-extensions/refs/heads/main/registry.json" +) + var ( - ErrNotFound = errors.New("extension not found") + ErrInstalledExtensionNotFound = errors.New("extension not found") + ErrRegistryExtensionNotFound = errors.New("extension not found in registry") + ErrExtensionInstalled = errors.New("extension already installed") + registryCacheDuration = 24 * time.Hour ) type Manager struct { configManager config.UserConfigManager userConfig config.Config - extensions map[string]*Extension + pipeline azruntime.Pipeline + registryCache *cache.FileCache[ExtensionRegistry] } -func NewManager(configManager config.UserConfigManager) *Manager { +// NewManager creates a new extension manager +func NewManager(configManager config.UserConfigManager, transport policy.Transporter) *Manager { + pipeline := azruntime.NewPipeline("azd-extensions", "1.0.0", azruntime.PipelineOptions{}, &policy.ClientOptions{ + Transport: transport, + }) + return &Manager{ configManager: configManager, + pipeline: pipeline, } } -func (m *Manager) Initialize() (map[string]*Extension, error) { +// Initialize the extension manager +func (m *Manager) Initialize() error { userConfig, err := m.configManager.Load() if err != nil { - return nil, err + return err } + configDir, err := config.GetUserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config directory: %w", err) + } + + registryCachePath := filepath.Join(configDir, registryCacheFilePath) + m.registryCache = cache.NewFileCache(registryCachePath, registryCacheDuration, m.loadRegistry) m.userConfig = userConfig + return nil +} + +// ListInstalled retrieves a list of installed extensions +func (m *Manager) ListInstalled() (map[string]*Extension, error) { var extensions map[string]*Extension + ok, err := m.userConfig.GetSection("extensions", &extensions) if err != nil { return nil, fmt.Errorf("failed to get extensions section: %w", err) } - if !ok { - return nil, nil + if !ok || extensions == nil { + return map[string]*Extension{}, nil } - m.extensions = extensions - return extensions, nil } -func (m *Manager) Get(name string) (*Extension, error) { - if extension, has := m.extensions[name]; has { +// GetInstalled retrieves an installed extension by name +func (m *Manager) GetInstalled(name string) (*Extension, error) { + extensions, err := m.ListInstalled() + if err != nil { + return nil, err + } + + if extension, has := extensions[name]; has { return extension, nil } - return nil, fmt.Errorf("%s %w", name, ErrNotFound) + return nil, fmt.Errorf("%s %w", name, ErrInstalledExtensionNotFound) +} + +// GetFromRegistry retrieves an extension from the registry by name +func (m *Manager) GetFromRegistry(ctx context.Context, name string) (*RegistryExtension, error) { + extensions, err := m.ListFromRegistry(ctx) + if err != nil { + return nil, err + } + + for _, extension := range extensions { + if strings.EqualFold(extension.Name, name) { + return extension, nil + } + } + + return nil, fmt.Errorf("%s %w", name, ErrRegistryExtensionNotFound) +} + +func (m *Manager) ListFromRegistry(ctx context.Context) ([]*RegistryExtension, error) { + registry, err := m.registryCache.Resolve(ctx) + if err != nil { + return nil, err + } + + return registry.Extensions, nil +} + +// loadRegistry retrieves a list of extensions from the registry +func (m *Manager) loadRegistry(ctx context.Context) (*ExtensionRegistry, error) { + req, err := azruntime.NewRequest(ctx, http.MethodGet, extensionRegistryUrl) + if err != nil { + return nil, err + } + + resp, err := m.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed for template source '%s', %w", extensionRegistryUrl, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, azruntime.NewResponseError(resp) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal JSON into ExtensionRegistry struct + var registry *ExtensionRegistry + err = json.Unmarshal(body, ®istry) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + // Return the registry + return registry, nil +} + +// Install an extension by name and optional version +// If no version is provided, the latest version is installed +// Latest version is determined by the last element in the Versions slice +func (m *Manager) Install(ctx context.Context, name string, version string) (*RegistryExtensionVersion, error) { + installed, err := m.GetInstalled(name) + if err == nil && installed != nil { + return nil, fmt.Errorf("%s %w", name, ErrExtensionInstalled) + } + + // Step 1: Find the extension by name + extension, err := m.GetFromRegistry(ctx, name) + if err != nil { + return nil, err + } + + // Step 2: Determine the version to install + var selectedVersion *RegistryExtensionVersion + + if version == "" { + // Default to the latest version (last in the slice) + versions := extension.Versions + if len(versions) == 0 { + return nil, fmt.Errorf("no versions available for extension: %s", name) + } + + selectedVersion = &versions[len(versions)-1] + } else { + // Find the specific version + for _, v := range extension.Versions { + if v.Version == version { + selectedVersion = &v + break + } + } + + if selectedVersion == nil { + return nil, fmt.Errorf("version %s not found for extension: %s", version, name) + } + } + + // Step 3: Find the binary for the current OS + binary, err := findBinaryForCurrentOS(selectedVersion) + if err != nil { + return nil, fmt.Errorf("failed to find binary for current OS: %w", err) + } + + // Step 4: Download the binary to a temp location + tempFilePath, err := m.downloadBinary(ctx, binary.Url) + if err != nil { + return nil, fmt.Errorf("failed to download binary: %w", err) + } + + // Clean up the temp file after all scenarios + defer os.Remove(tempFilePath) + + // Step 5: Validate the checksum if provided + if err := validateChecksum(tempFilePath, binary.Checksum); err != nil { + return nil, fmt.Errorf("checksum validation failed: %w", err) + } + + // Step 6: Copy the binary to the user's home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user's home directory: %w", err) + } + + targetDir := filepath.Join(homeDir, ".azd", "bin") + if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { + return nil, fmt.Errorf("failed to create target directory: %w", err) + } + + targetPath := filepath.Join(targetDir, filepath.Base(tempFilePath)) + if err := copyFile(tempFilePath, targetPath); err != nil { + return nil, fmt.Errorf("failed to copy binary to target location: %w", err) + } + + relativeExtensionPath, err := filepath.Rel(homeDir, targetPath) + if err != nil { + return nil, fmt.Errorf("failed to get relative path: %w", err) + } + + // Step 7: Update the user config with the installed extension + extensions, err := m.ListInstalled() + if err != nil { + return nil, fmt.Errorf("failed to list installed extensions: %w", err) + } + + extensions[name] = &Extension{ + Name: name, + DisplayName: extension.DisplayName, + Description: extension.Description, + Version: selectedVersion.Version, + Usage: selectedVersion.Usage, + Path: relativeExtensionPath, + } + + if err := m.userConfig.Set("extensions", extensions); err != nil { + return nil, fmt.Errorf("failed to set extensions section: %w", err) + } + + if err := m.configManager.Save(m.userConfig); err != nil { + return nil, fmt.Errorf("failed to save user config: %w", err) + } + + log.Printf("Extension '%s' (version %s) installed successfully to %s\n", name, selectedVersion.Version, targetPath) + return selectedVersion, nil +} + +// Uninstall an extension by name +func (m *Manager) Uninstall(name string) error { + // Get the installed extension + extension, err := m.GetInstalled(name) + if err != nil { + return fmt.Errorf("failed to get installed extension: %w", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user's home directory: %w", err) + } + + // Remove the extension binary when it exists + extensionPath := filepath.Join(homeDir, extension.Path) + _, err = os.Stat(extensionPath) + if err == nil { + if err := os.Remove(extensionPath); err != nil { + return fmt.Errorf("failed to remove extension: %w", err) + } + } + + // Update the user config + extensions, err := m.ListInstalled() + if err != nil { + return fmt.Errorf("failed to list installed extensions: %w", err) + } + + delete(extensions, name) + + if err := m.userConfig.Set("extensions", extensions); err != nil { + return fmt.Errorf("failed to set extensions section: %w", err) + } + + if err := m.configManager.Save(m.userConfig); err != nil { + return fmt.Errorf("failed to save user config: %w", err) + } + + log.Printf("Extension '%s' uninstalled successfully\n", name) + return nil +} + +// Upgrade upgrades the extension to the specified version +// This is a convenience method that uninstalls the existing extension and installs the new version +// If the version is not specified, the latest version is installed +func (m *Manager) Upgrade(ctx context.Context, name string, version string) (*RegistryExtensionVersion, error) { + if err := m.Uninstall(name); err != nil { + return nil, fmt.Errorf("failed to uninstall extension: %w", err) + } + + extensionVersion, err := m.Install(ctx, name, version) + if err != nil { + return nil, fmt.Errorf("failed to install extension: %w", err) + } + + return extensionVersion, nil +} + +// Helper function to find the binary for the current OS +func findBinaryForCurrentOS(version *RegistryExtensionVersion) (*Binary, error) { + if version.Binaries == nil { + return nil, fmt.Errorf("no binaries available for this version") + } + + var binary Binary + var exists bool + + platform := runtime.GOOS + + switch platform { + case "darwin": + binary, exists = version.Binaries["macos"] + case "linux": + binary, exists = version.Binaries["linux"] + case "windows": + binary, exists = version.Binaries["windows"] + } + + if !exists { + return nil, fmt.Errorf("no binary available for platform: %s", platform) + } + + if binary.Url == "" { + return nil, fmt.Errorf("binary URL is missing for platform: %s", platform) + } + + return &binary, nil +} + +// downloadFile downloads a file from the given URL and saves it to a temporary directory using the filename from the URL. +func (m *Manager) downloadBinary(ctx context.Context, binaryUrl string) (string, error) { + req, err := azruntime.NewRequest(ctx, http.MethodGet, binaryUrl) + if err != nil { + return "", err + } + + // Perform HTTP GET request + resp, err := m.pipeline.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + // Check for successful response + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download file, status code: %d", resp.StatusCode) + } + + // Extract the filename from the URL + filename := filepath.Base(binaryUrl) + + // Create a temporary file in the system's temp directory with the same filename + tempDir := os.TempDir() + tempFilePath := filepath.Join(tempDir, filename) + + // Create the file at the desired location + tempFile, err := os.Create(tempFilePath) + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer tempFile.Close() + + // Write the response body to the file + _, err = io.Copy(tempFile, resp.Body) + if err != nil { + return "", fmt.Errorf("failed to write to temporary file: %w", err) + } + + return tempFilePath, nil +} + +// validateChecksum validates the file at the given path against the expected checksum using the specified algorithm. +func validateChecksum(filePath string, checksum *Checksum) error { + // TODO: Checksum optional for POC + return nil + + // // Check if checksum or required fields are nil + // if checksum.Algorithm == "" || checksum.Value == "" { + // return fmt.Errorf("invalid checksum data: algorithm and value must be specified") + // } + + // var hashAlgo hash.Hash + + // // Select the hashing algorithm based on the input + // switch checksum.Algorithm { + // case "sha256": + // hashAlgo = sha256.New() + // case "sha512": + // hashAlgo = sha512.New() + // default: + // return fmt.Errorf("unsupported checksum algorithm: %s", checksum.Algorithm) + // } + + // // Open the file for reading + // file, err := os.Open(filePath) + // if err != nil { + // return fmt.Errorf("failed to open file for checksum validation: %w", err) + // } + // defer file.Close() + + // // Compute the checksum + // if _, err := io.Copy(hashAlgo, file); err != nil { + // return fmt.Errorf("failed to compute checksum: %w", err) + // } + + // // Convert the computed checksum to a hexadecimal string + // computedChecksum := hex.EncodeToString(hashAlgo.Sum(nil)) + + // // Compare the computed checksum with the expected checksum + // if computedChecksum != checksum.Value { + // return fmt.Errorf("checksum mismatch: expected %s, got %s", checksum.Value, computedChecksum) + // } + + // return nil +} + +// Helper function to copy a file to the target directory +func copyFile(src, dst string) error { + input, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer input.Close() + + output, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer output.Close() + + _, err = io.Copy(output, input) + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + return nil } diff --git a/cli/azd/pkg/extensions/registry.go b/cli/azd/pkg/extensions/registry.go new file mode 100644 index 00000000000..e6a0b0be271 --- /dev/null +++ b/cli/azd/pkg/extensions/registry.go @@ -0,0 +1,30 @@ +package extensions + +type Checksum struct { + Algorithm string `json:"algorithm" yaml:"algorithm"` + Value string `json:"value" yaml:"value"` +} + +type Binary struct { + Url string `json:"url" yaml:"url"` + Checksum *Checksum `json:"checksum" yaml:"checksum"` +} + +type RegistryExtensionVersion struct { + Version string `json:"version" yaml:"version"` + Usage string `json:"usage" yaml:"usage"` + Examples []string `json:"examples" yaml:"examples"` + Binaries map[string]Binary `json:"binaries" yaml:"binaries"` // Key: platform (windows, linux, macos) +} + +type RegistryExtension struct { + Name string `json:"name" yaml:"name"` + DisplayName string `json:"displayName" yaml:"displayName"` + Description string `json:"description" yaml:"description"` + Versions []RegistryExtensionVersion `json:"versions" yaml:"versions"` +} + +type ExtensionRegistry struct { + Extensions []*RegistryExtension `json:"extensions" yaml:"extensions"` + Signature string `json:"signature" yaml:"signature"` +} From db41c4ad36dbd01fed1e5f531a070ea7b34e3eb1 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 4 Oct 2024 16:16:48 -0700 Subject: [PATCH 4/6] Updates to support multiple args --- cli/azd/cmd/extension.go | 238 ++++++++++++++++++++++++--------------- 1 file changed, 150 insertions(+), 88 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index f97db1f3483..59801138f2f 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -2,14 +2,12 @@ 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/internal" "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" @@ -21,8 +19,9 @@ import ( func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { group := root.Add("extension", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ - Use: "extension", - Short: "Manage azd extensions.", + Use: "extension", + Aliases: []string{"ext"}, + Short: "Manage azd extensions.", }, GroupingOptions: actions.CommandGroupOptions{ RootLevelHelp: actions.CmdGroupConfig, @@ -57,8 +56,7 @@ func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor group.Add("install", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ Use: "install ", - Short: "Install an extension.", - Args: cobra.ExactArgs(1), + Short: "Installs specified extensions.", }, ActionResolver: newExtensionInstallAction, FlagsResolver: newExtensionInstallFlags, @@ -68,18 +66,17 @@ func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor group.Add("uninstall", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ Use: "uninstall ", - Short: "Uninstall an extension.", - Args: cobra.ExactArgs(1), + Short: "Uninstall specified extensions.", }, ActionResolver: newExtensionUninstallAction, + FlagsResolver: newExtensionUninstallFlags, }) // azd extension upgrade group.Add("upgrade", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ Use: "upgrade ", - Short: "Upgrade an installed extension.", - Args: cobra.MaximumNArgs(1), + Short: "Upgrade specified extensions.", }, ActionResolver: newExtensionUpgradeAction, FlagsResolver: newExtensionUpgradeFlags, @@ -330,92 +327,143 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult TitleNote: "Installs the specified extension onto the local machine", }) - extensionName := a.args[0] + extensionNames := a.args + if len(extensionNames) == 0 { + return nil, fmt.Errorf("must specify an extension name") + } - stepMessage := fmt.Sprintf("Installing extension %s", extensionName) - a.console.ShowSpinner(ctx, stepMessage, input.Step) + if len(extensionNames) > 1 && a.flags.version != "" { + return nil, fmt.Errorf("cannot specify --version flag when using multiple extensions") + } - extensionVersion, err := a.extensionManager.Install(ctx, extensionName, a.flags.version) - if err != nil { - a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + for index, extensionName := range extensionNames { + if index > 0 { + a.console.Message(ctx, "") + } - if errors.Is(err, extensions.ErrExtensionInstalled) { - return nil, &internal.ErrorWithSuggestion{ - Err: err, - Suggestion: fmt.Sprint("Run 'azd extension upgrade ", extensionName, "' to upgrade the extension."), - } + stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionName)) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + installed, err := a.extensionManager.GetInstalled(extensionName) + if err == nil { + stepMessage += output.WithGrayFormat(" (version %s already installed)", installed.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepSkipped) + continue } - return nil, fmt.Errorf("failed to install extension: %w", err) - } + extensionVersion, err := a.extensionManager.Install(ctx, extensionName, a.flags.version) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to install extension: %w", err) + } - stepMessage += fmt.Sprintf(" (%s)", extensionVersion.Version) - a.console.StopSpinner(ctx, stepMessage, input.StepDone) + stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) - lines := []string{ - fmt.Sprintf("Usage: %s", extensionVersion.Usage), - "\nExamples:", - } + a.console.Message(ctx, fmt.Sprintf(" %s %s", output.WithBold("Usage: "), extensionVersion.Usage)) + a.console.Message(ctx, output.WithBold(" Examples:")) - lines = append(lines, extensionVersion.Examples...) + for _, example := range extensionVersion.Examples { + a.console.Message(ctx, " "+output.WithHighLightFormat(example)) + } + } return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Extension installed successfully", - FollowUp: strings.Join(lines, "\n"), + 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", }) - extensionName := a.args[0] - stepMessage := fmt.Sprintf("Uninstalling extension %s", extensionName) + extensionNames := 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) + } - installed, err := a.extensionManager.GetInstalled(extensionName) - if err != nil { - a.console.ShowSpinner(ctx, stepMessage, input.Step) - a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + extensionNames = make([]string, 0, len(installed)) + for name := range installed { + extensionNames = append(extensionNames, name) + } + } - return nil, fmt.Errorf("failed to get installed extension: %w", err) + if len(extensionNames) == 0 { + return nil, fmt.Errorf("no extensions to uninstall") } - stepMessage += fmt.Sprintf(" (%s)", installed.Version) - a.console.ShowSpinner(ctx, stepMessage, input.Step) + for _, extensionName := range extensionNames { + stepMessage := fmt.Sprintf("Uninstalling %s extension", output.WithHighLightFormat(extensionName)) - if err := a.extensionManager.Uninstall(extensionName); err != nil { - a.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return nil, fmt.Errorf("failed to uninstall extension: %w", err) - } + installed, err := a.extensionManager.GetInstalled(extensionName) + if err != nil { + a.console.ShowSpinner(ctx, stepMessage, input.Step) + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) - a.console.StopSpinner(ctx, stepMessage, input.StepDone) + 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(extensionName); 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 uninstalled successfully", + Header: "Extension(s) uninstalled successfully", }, }, nil } @@ -456,13 +504,16 @@ func newExtensionUpgradeAction( } func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, error) { - extensionName := "" - if len(a.args) > 0 { - extensionName = a.args[0] + if len(a.args) > 0 && a.flags.all { + return nil, fmt.Errorf("cannot specify both an extension name and --all flag") } - if extensionName != "" && 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{ @@ -470,57 +521,68 @@ func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult TitleNote: "Upgrades the specified extensions on the local machine", }) - if extensionName != "" { - stepMessage := fmt.Sprintf("Upgrading extension %s", extensionName) - a.console.ShowSpinner(ctx, stepMessage, input.Step) - - extensionVersion, err := a.extensionManager.Upgrade(ctx, extensionName, a.flags.version) + extensionNames := a.args + if a.flags.all { + installed, err := a.extensionManager.ListInstalled() if err != nil { - return nil, fmt.Errorf("failed to upgrade extension: %w", err) + return nil, fmt.Errorf("failed to list installed extensions: %w", err) } - stepMessage += fmt.Sprintf(" (%s)", extensionVersion.Version) - a.console.StopSpinner(ctx, stepMessage, input.StepDone) - - lines := []string{ - fmt.Sprintf("%s %s", output.WithBold("Usage: "), extensionVersion.Usage), - output.WithBold("\nExamples:"), + extensionNames = make([]string, 0, len(installed)) + for name := range installed { + extensionNames = append(extensionNames, name) } + } - for _, example := range extensionVersion.Examples { - lines = append(lines, " "+output.WithHighLightFormat(example)) + if len(extensionNames) == 0 { + return nil, fmt.Errorf("no extensions to upgrade") + } + + for index, extensionName := range extensionNames { + if index > 0 { + a.console.Message(ctx, "") } - return &actions.ActionResult{ - Message: &actions.ResultMessage{ - Header: "Extension upgraded successfully", - FollowUp: strings.Join(lines, "\n"), - }, - }, nil - } else { - installed, err := a.extensionManager.ListInstalled() + stepMessage := fmt.Sprintf("Upgrading %s extension", output.WithHighLightFormat(extensionName)) + a.console.ShowSpinner(ctx, stepMessage, input.Step) + + installed, err := a.extensionManager.GetInstalled(extensionName) if err != nil { - return nil, fmt.Errorf("failed to list installed extensions: %w", err) + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to get installed extension: %w", err) } - for name := range installed { - stepMessage := fmt.Sprintf("Upgrading extension %s", name) - a.console.ShowSpinner(ctx, stepMessage, input.Step) + extension, err := a.extensionManager.GetFromRegistry(ctx, extensionName) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to get extension %s: %w", extensionName, err) + } - extensionVersion, err := a.extensionManager.Upgrade(ctx, name, "") + 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, extensionName, a.flags.version) if err != nil { - a.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return nil, fmt.Errorf("failed to upgrade extension %s: %w", name, err) + return nil, fmt.Errorf("failed to upgrade extension: %w", err) } - stepMessage += fmt.Sprintf(" (%s)", extensionVersion.Version) + stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) a.console.StopSpinner(ctx, stepMessage, input.StepDone) - } - return &actions.ActionResult{ - Message: &actions.ResultMessage{ - Header: "All extensions upgraded successfully", - }, - }, nil + 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)) + } + } } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extensions upgraded successfully", + }, + }, nil } From 4d742595eacdd45e3ba2f93eab274bb8fc2a6f8a Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 4 Oct 2024 16:19:02 -0700 Subject: [PATCH 5/6] Updates usage snapshots --- .../TestUsage-azd-extension-install.snap | 19 +++++++++++++ .../TestUsage-azd-extension-list.snap | 19 +++++++++++++ .../TestUsage-azd-extension-show.snap | 18 +++++++++++++ .../TestUsage-azd-extension-uninstall.snap | 19 +++++++++++++ .../TestUsage-azd-extension-upgrade.snap | 20 ++++++++++++++ .../cmd/testdata/TestUsage-azd-extension.snap | 27 +++++++++++++++++++ cli/azd/cmd/testdata/TestUsage-azd-test.snap | 18 +++++++++++++ cli/azd/cmd/testdata/TestUsage-azd.snap | 4 +++ 8 files changed, 144 insertions(+) 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-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/cmd/testdata/TestUsage-azd-test.snap 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..8a1410b9fee --- /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 + --docs : Opens the documentation for azd extension install in your web browser. + -h, --help : Gets help for install. + -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. + --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..4e5dba60d2d --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap @@ -0,0 +1,19 @@ + +List available extensions. + +Usage + azd extension list [--installed] [flags] + +Flags + --docs : Opens the documentation for azd extension list in your web browser. + -h, --help : Gets help for list. + --installed : List installed extensions + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --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..2a0940bdb11 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap @@ -0,0 +1,18 @@ + +Show details for a specific extension. + +Usage + azd extension show [flags] + +Flags + --docs : Opens the documentation for azd extension show in your web browser. + -h, --help : Gets help for show. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --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-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap new file mode 100644 index 00000000000..ba41bf0de75 --- /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 + --docs : Opens the documentation for azd extension uninstall in your web browser. + -h, --help : Gets help for uninstall. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --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..d0e33a3b034 --- /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 + --docs : Opens the documentation for azd extension upgrade in your web browser. + -h, --help : Gets help for upgrade. + -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. + --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..f20ca1fe920 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap @@ -0,0 +1,27 @@ + +Manage azd extensions. + +Usage + azd extension [command] + +Available Commands + install : Installs specified extensions. + list : List available extensions. + show : Show details for a specific extension. + uninstall : Uninstall specified extensions. + upgrade : Upgrade specified extensions. + +Flags + --docs : Opens the documentation for azd extension in your web browser. + -h, --help : Gets help for extension. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --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/cmd/testdata/TestUsage-azd-test.snap b/cli/azd/cmd/testdata/TestUsage-azd-test.snap new file mode 100644 index 00000000000..10da26f68dc --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-test.snap @@ -0,0 +1,18 @@ + +Tools and commands for testing azd projects. + +Usage + azd test [flags] + +Flags + --docs : Opens the documentation for azd test in your web browser. + -h, --help : Gets help for test. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --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.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index b7d6761e339..817b94f330b 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -8,6 +8,7 @@ Commands Configure and develop your app auth : Authenticate with Azure. config : Manage azd configurations (ex: default Azure subscription, location). + extension : Manage azd extensions. hooks : Develop, test and run hooks for an application. (Beta) init : Initialize a new application. restore : Restores the application's dependencies. (Beta) @@ -26,6 +27,9 @@ Commands pipeline : Manage and configure your deployment pipelines. (Beta) show : Display information about your app and its resources. + Installed Extensions + + About, help and upgrade version : Print the version number of Azure Developer CLI. From f42b8d457f75f6f4306c061f40d276a5152e28de Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 7 Oct 2024 13:28:18 -0700 Subject: [PATCH 6/6] Update path to registry --- cli/azd/pkg/extensions/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index ecc2e02b9d7..1a83b872430 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -22,7 +22,7 @@ import ( const ( registryCacheFilePath = "registry.cache" - extensionRegistryUrl = "https://raw.githubusercontent.com/wbreza/azd-extensions/refs/heads/main/registry.json" + extensionRegistryUrl = "https://raw.githubusercontent.com/wbreza/azd-extensions/refs/heads/main/registry/registry.json" ) var (