From 97ae9b012f3a081e7375785d95b2f008409066c2 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 3 Oct 2024 16:54:53 -0700 Subject: [PATCH 01/15] 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 0ad238d88e8..5f24a2f2eaf 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" @@ -356,6 +357,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 636054084976f0ab0f799129f459d74da2b220f0 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 3 Oct 2024 16:59:35 -0700 Subject: [PATCH 02/15] 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 ecbb99f4df2b4c0d0e6fc3318da993d9e0d99b13 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 4 Oct 2024 15:09:00 -0700 Subject: [PATCH 03/15] 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 5f24a2f2eaf..1d911e69e6a 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -127,6 +127,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 7c92cb81de020f811686d4db6826585cf6426a8b Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 4 Oct 2024 16:16:48 -0700 Subject: [PATCH 04/15] 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 49f284cdcba643f5300fa670f3b1791df2f1127b Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 4 Oct 2024 16:19:02 -0700 Subject: [PATCH 05/15] 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 ec80d3a294e..c264b4dc97c 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 d9c1e4cdd67496832bcbad9ccab5ece02f3f47b0 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 7 Oct 2024 13:28:18 -0700 Subject: [PATCH 06/15] 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 ( From 64f92f09d26c22dd13be1ec916234419af76e8b3 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 9 Oct 2024 12:42:00 -0700 Subject: [PATCH 07/15] Supress help for command groups that don't contain commands --- cli/azd/cmd/root.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 1d911e69e6a..f23af91f58c 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -440,6 +440,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 "))) From 70ff3a915700a71ec2b71145138de183c3ce4116 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 9 Oct 2024 12:56:54 -0700 Subject: [PATCH 08/15] Update usage snapshot --- cli/azd/cmd/testdata/TestUsage-azd.snap | 3 --- 1 file changed, 3 deletions(-) diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index c264b4dc97c..b10e8ce3fa9 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -27,9 +27,6 @@ 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 5744c6bbc09a85c791d7822f86738bd08b1129c3 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 14 Oct 2024 10:19:05 -0700 Subject: [PATCH 09/15] Force color --- cli/azd/cmd/extensions.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index fdd5ac417d4..c62cb6e53ba 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -122,6 +123,11 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error 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()...) From 77609ce991cacebb2f923fdd522706f60c9d0005 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 17 Oct 2024 13:18:54 -0700 Subject: [PATCH 10/15] don't bubble up extension errors --- cli/azd/cmd/extensions.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index c62cb6e53ba..48161994438 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "log" "os" "path/filepath" @@ -163,7 +164,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error _, err = a.commandRunner.Run(ctx, runArgs) if err != nil { - return nil, err + log.Printf("Failed to run extension %s: %v\n", extensionName, err) } return nil, nil From fd9daaf8a36a1e003846afadfd638e6fb9cad86b Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 18 Nov 2024 13:22:38 -0800 Subject: [PATCH 11/15] Updates how args are sent to extension command --- cli/azd/cmd/extensions.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 48161994438..3dbd6be3616 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -97,6 +97,8 @@ type extensionAction struct { commandRunner exec.CommandRunner lazyEnv *lazy.Lazy[*environment.Environment] extensionManager *extensions.Manager + cmd *cobra.Command + args []string } func newExtensionAction( @@ -104,17 +106,21 @@ func newExtensionAction( commandRunner exec.CommandRunner, lazyEnv *lazy.Lazy[*environment.Environment], extensionManager *extensions.Manager, + cmd *cobra.Command, + args []string, ) actions.Action { return &extensionAction{ console: console, commandRunner: commandRunner, lazyEnv: lazyEnv, extensionManager: extensionManager, + cmd: cmd, + args: args, } } func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error) { - extensionName := os.Args[1] + extensionName := a.cmd.Use extension, err := a.extensionManager.GetInstalled(extensionName) if err != nil { @@ -134,9 +140,6 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error 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) @@ -155,7 +158,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error } runArgs := exec. - NewRunArgs(extensionPath, allArgs...). + NewRunArgs(extensionPath, a.args...). WithCwd(cwd). WithEnv(allEnv). WithStdIn(a.console.Handles().Stdin). From 161ee795657e2375574fb87badbcdbd12367cec4 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 18 Nov 2024 13:24:53 -0800 Subject: [PATCH 12/15] Updates snapshots for extension commands --- cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap | 4 ++-- cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap | 4 ++-- cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap | 6 ++---- cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap | 6 +++--- cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap | 4 ++-- cli/azd/cmd/testdata/TestUsage-azd-extension.snap | 6 ++---- cli/azd/cmd/testdata/TestUsage-azd.snap | 3 +++ 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap index 8a1410b9fee..f0e4cff7127 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap @@ -5,13 +5,13 @@ 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. + --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 index 4e5dba60d2d..82346670871 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap @@ -5,13 +5,13 @@ 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. + --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 index 2a0940bdb11..39fcf5a04bb 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap @@ -4,13 +4,11 @@ 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. + --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-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap index ba41bf0de75..62b6519d2f5 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -5,13 +5,13 @@ 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. + --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 index d0e33a3b034..8571b43ecc1 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap @@ -6,13 +6,13 @@ Usage 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. + --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 index f20ca1fe920..911ac7b40f6 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap @@ -11,13 +11,11 @@ Available Commands 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. + --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. diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index b10e8ce3fa9..c83a6ed1867 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -27,6 +27,9 @@ Commands pipeline : Manage and configure your deployment pipelines. (Beta) show : Display information about your app and its resources. + Installed Extensions + ai : Manage AI model operations such as deploying AI models like GPT-4. + About, help and upgrade version : Print the version number of Azure Developer CLI. From 3a99a50c2a8eae599f7a8e4838b9ade10e89d62c Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 19 Nov 2024 13:39:11 -0800 Subject: [PATCH 13/15] Updates to validate registry signature and binary checksums --- cli/azd/cmd/extension.go | 8 +- cli/azd/pkg/cache/file_cache.go | 2 +- cli/azd/pkg/extensions/manager.go | 268 ++++++++++++++++++----------- cli/azd/pkg/extensions/registry.go | 48 ++++-- cli/azd/resources/public_key.pem | 9 + cli/azd/resources/resources.go | 3 + 6 files changed, 214 insertions(+), 124 deletions(-) create mode 100644 cli/azd/resources/public_key.pem diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 59801138f2f..a622d161ac6 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -219,7 +219,7 @@ type extensionShowItem struct { LatestVersion string InstalledVersion string Usage string - Examples []string + Examples []extensions.ExtensionExample } func (t *extensionShowItem) Display(writer io.Writer) error { @@ -241,7 +241,7 @@ func (t *extensionShowItem) Display(writer io.Writer) error { } for _, example := range t.Examples { - text = append(text, []string{"", "", example}) + text = append(text, []string{"", "", example.Usage}) } for _, line := range text { @@ -364,7 +364,7 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult a.console.Message(ctx, output.WithBold(" Examples:")) for _, example := range extensionVersion.Examples { - a.console.Message(ctx, " "+output.WithHighLightFormat(example)) + a.console.Message(ctx, " "+output.WithHighLightFormat(example.Usage)) } } @@ -575,7 +575,7 @@ func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult a.console.Message(ctx, output.WithBold(" Examples:")) for _, example := range extensionVersion.Examples { - a.console.Message(ctx, " "+output.WithHighLightFormat(example)) + a.console.Message(ctx, " "+output.WithHighLightFormat(example.Usage)) } } } diff --git a/cli/azd/pkg/cache/file_cache.go b/cli/azd/pkg/cache/file_cache.go index 6f9624a036b..4b51b6a2f4f 100644 --- a/cli/azd/pkg/cache/file_cache.go +++ b/cli/azd/pkg/cache/file_cache.go @@ -57,7 +57,7 @@ func (c *FileCache[T]) Resolve(ctx context.Context) (*T, error) { // Set sets the value in the cache. func (c *FileCache[T]) Set(value *T) error { c.value = value - jsonValue, err := json.Marshal(c.value) + jsonValue, err := json.MarshalIndent(c.value, "", " ") if err != nil { return fmt.Errorf("failed to marshal value: %w", err) } diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 1a83b872430..eb9391f7242 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -2,9 +2,18 @@ package extensions import ( "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" + "hash" "io" "log" "net/http" @@ -18,6 +27,7 @@ import ( 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" + "github.com/azure/azure-dev/cli/azd/resources" ) const ( @@ -36,7 +46,7 @@ type Manager struct { configManager config.UserConfigManager userConfig config.Config pipeline azruntime.Pipeline - registryCache *cache.FileCache[ExtensionRegistry] + registryCache *cache.FileCache[Registry] } // NewManager creates a new extension manager @@ -101,7 +111,7 @@ func (m *Manager) GetInstalled(name string) (*Extension, error) { } // GetFromRegistry retrieves an extension from the registry by name -func (m *Manager) GetFromRegistry(ctx context.Context, name string) (*RegistryExtension, error) { +func (m *Manager) GetFromRegistry(ctx context.Context, name string) (*ExtensionMetadata, error) { extensions, err := m.ListFromRegistry(ctx) if err != nil { return nil, err @@ -116,52 +126,23 @@ func (m *Manager) GetFromRegistry(ctx context.Context, name string) (*RegistryEx return nil, fmt.Errorf("%s %w", name, ErrRegistryExtensionNotFound) } -func (m *Manager) ListFromRegistry(ctx context.Context) ([]*RegistryExtension, error) { +func (m *Manager) ListFromRegistry(ctx context.Context) ([]*ExtensionMetadata, 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 { + if err := validateRegistry(registry); 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 + return registry.Extensions, 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) { +func (m *Manager) Install(ctx context.Context, name string, version string) (*ExtensionVersion, error) { installed, err := m.GetInstalled(name) if err == nil && installed != nil { return nil, fmt.Errorf("%s %w", name, ErrExtensionInstalled) @@ -174,7 +155,7 @@ func (m *Manager) Install(ctx context.Context, name string, version string) (*Re } // Step 2: Determine the version to install - var selectedVersion *RegistryExtensionVersion + var selectedVersion *ExtensionVersion if version == "" { // Default to the latest version (last in the slice) @@ -205,7 +186,7 @@ func (m *Manager) Install(ctx context.Context, name string, version string) (*Re } // Step 4: Download the binary to a temp location - tempFilePath, err := m.downloadBinary(ctx, binary.Url) + tempFilePath, err := m.downloadBinary(ctx, binary.URL) if err != nil { return nil, fmt.Errorf("failed to download binary: %w", err) } @@ -218,23 +199,28 @@ func (m *Manager) Install(ctx context.Context, name string, version string) (*Re return nil, fmt.Errorf("checksum validation failed: %w", err) } - // Step 6: Copy the binary to the user's home directory - homeDir, err := os.UserHomeDir() + userHomeDir, 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") + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get user config directory: %w", err) + } + + targetDir := filepath.Join(userConfigDir, "bin") if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { return nil, fmt.Errorf("failed to create target directory: %w", err) } + // Step 6: Copy the binary to the target directory 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) + relativeExtensionPath, err := filepath.Rel(userHomeDir, targetPath) if err != nil { return nil, fmt.Errorf("failed to get relative path: %w", err) } @@ -311,7 +297,7 @@ func (m *Manager) Uninstall(name string) error { // 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) { +func (m *Manager) Upgrade(ctx context.Context, name string, version string) (*ExtensionVersion, error) { if err := m.Uninstall(name); err != nil { return nil, fmt.Errorf("failed to uninstall extension: %w", err) } @@ -324,32 +310,72 @@ func (m *Manager) Upgrade(ctx context.Context, name string, version string) (*Re 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") +// loadRegistry retrieves a list of extensions from the registry +func (m *Manager) loadRegistry(ctx context.Context) (*Registry, error) { + // Fetch the registry JSON + 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) } - var binary Binary - var exists bool + // Unmarshal JSON into a map to extract the signature and registry content + var registry *Registry + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("failed to unmarshal raw registry JSON: %w", err) + } + + return registry, nil +} - platform := runtime.GOOS +func validateRegistry(registry *Registry) error { + // Extract the signature + signature := registry.Signature + registry.Signature = "" + + // Marshal the remaining registry content back to JSON + rawRegistry, err := json.MarshalIndent(registry, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal registry content: %w", err) + } - switch platform { - case "darwin": - binary, exists = version.Binaries["macos"] - case "linux": - binary, exists = version.Binaries["linux"] - case "windows": - binary, exists = version.Binaries["windows"] + // Validate the registry signature + if err := verifySignature(rawRegistry, signature); err != nil { + return fmt.Errorf("registry signature validation failed: %w", err) + } + + return nil +} + +// Helper function to find the binary for the current OS +func findBinaryForCurrentOS(version *ExtensionVersion) (*ExtensionBinary, error) { + if version.Binaries == nil { + return nil, fmt.Errorf("no binaries available for this version") } + binaryVersion := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + binary, exists := version.Binaries[binaryVersion] + if !exists { - return nil, fmt.Errorf("no binary available for platform: %s", platform) + return nil, fmt.Errorf("no binary available for platform: %s", binaryVersion) } - if binary.Url == "" { - return nil, fmt.Errorf("binary URL is missing for platform: %s", platform) + if binary.URL == "" { + return nil, fmt.Errorf("binary URL is missing for platform: %s", binaryVersion) } return &binary, nil @@ -398,48 +424,45 @@ func (m *Manager) downloadBinary(ctx context.Context, binaryUrl string) (string, } // 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 +func validateChecksum(filePath string, checksum ExtensionChecksum) error { + // 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) + } - // // 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 + // 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 @@ -463,3 +486,48 @@ func copyFile(src, dst string) error { return nil } + +// Verify verifies the given data and its Base64-encoded signature +func verifySignature(data []byte, signature string) error { + publicKey, err := loadPublicKey(resources.PublicKey) + if err != nil { + return fmt.Errorf("failed to load public key: %w", err) + } + + // Compute the SHA256 hash of the data + hash := sha256.Sum256(data) + + // Decode the Base64-encoded signature + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %w", err) + } + + // Verify the signature with the public key + err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hash[:], sigBytes) + if err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + + return nil +} + +// loadPublicKey loads an RSA public key from a PEM file +func loadPublicKey(data []byte) (*rsa.PublicKey, error) { + block, _ := pem.Decode(data) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("invalid public key PEM format") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + + rsaPubKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("public key is not RSA") + } + + return rsaPubKey, nil +} diff --git a/cli/azd/pkg/extensions/registry.go b/cli/azd/pkg/extensions/registry.go index e6a0b0be271..683029388ca 100644 --- a/cli/azd/pkg/extensions/registry.go +++ b/cli/azd/pkg/extensions/registry.go @@ -1,30 +1,40 @@ package extensions -type Checksum struct { - Algorithm string `json:"algorithm" yaml:"algorithm"` - Value string `json:"value" yaml:"value"` +type ExtensionExample struct { + Name string `json:"name"` + Description string `json:"description"` + Usage string `json:"usage"` } -type Binary struct { - Url string `json:"url" yaml:"url"` - Checksum *Checksum `json:"checksum" yaml:"checksum"` +// Registry represents the registry.json structure +type Registry struct { + Extensions []*ExtensionMetadata `json:"extensions"` + Signature string `json:"signature,omitempty"` } -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) +// Extension represents an extension in the registry +type ExtensionMetadata struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Versions []ExtensionVersion `json:"versions"` } -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"` +// ExtensionVersion represents a version of an extension +type ExtensionVersion struct { + Version string `json:"version"` + Usage string `json:"usage"` + Examples []ExtensionExample `json:"examples"` + Binaries map[string]ExtensionBinary `json:"binaries"` } -type ExtensionRegistry struct { - Extensions []*RegistryExtension `json:"extensions" yaml:"extensions"` - Signature string `json:"signature" yaml:"signature"` +// ExtensionBinary represents the binary information of an extension +type ExtensionBinary struct { + URL string `json:"url"` + Checksum ExtensionChecksum `json:"checksum"` +} + +type ExtensionChecksum struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` } diff --git a/cli/azd/resources/public_key.pem b/cli/azd/resources/public_key.pem new file mode 100644 index 00000000000..48ca2e1e853 --- /dev/null +++ b/cli/azd/resources/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwlZQn9xTUiFArftdcgmb +I1y5PfAFmY13m0FjTp8TKTnIkS2dnjyuHR0RqC14Z74KN5ZSIZJ/a2NrFceThR0N +YmkwLyH1Bdf/v3IrOtS0L7MrXimPDIuS5ox2I+RkVOB/ZjaoAp9If+b3Pp1s/dtO +PWF36VWotimAtPhqs2tk/AQR3BzsPlwLFa5dUF9qJoC47fbB7hsgxJJUI+6l8Ahe +Fy7FqYYJdOyIA+jrUxHxGTUqtLheEafOenpSyhu/JHnokR2Gi01NoiFLgNor9JOj +chHhkgXQcGl8LQ9wQQFuhYBk9HyuEO7U1CvBwUmcaEWcH3dj+DPpPpv8HXTagDBt +rQIDAQAB +-----END PUBLIC KEY----- diff --git a/cli/azd/resources/resources.go b/cli/azd/resources/resources.go index 687428d0c09..176d259d61a 100644 --- a/cli/azd/resources/resources.go +++ b/cli/azd/resources/resources.go @@ -30,3 +30,6 @@ var AiPythonApp embed.FS //go:embed pipeline/* var PipelineFiles embed.FS + +//go:embed public_key.pem +var PublicKey []byte From 83c04b585ec8a8406e5a819be6930f313e37e5d5 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 19 Nov 2024 14:14:18 -0800 Subject: [PATCH 14/15] Update usage snapshots & registry link --- cli/azd/cmd/testdata/TestUsage-azd.snap | 3 --- cli/azd/pkg/extensions/manager.go | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index c83a6ed1867..b10e8ce3fa9 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -27,9 +27,6 @@ Commands pipeline : Manage and configure your deployment pipelines. (Beta) show : Display information about your app and its resources. - Installed Extensions - ai : Manage AI model operations such as deploying AI models like GPT-4. - About, help and upgrade version : Print the version number of Azure Developer CLI. diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index eb9391f7242..02de0929677 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -32,7 +32,7 @@ import ( const ( registryCacheFilePath = "registry.cache" - extensionRegistryUrl = "https://raw.githubusercontent.com/wbreza/azd-extensions/refs/heads/main/registry/registry.json" + extensionRegistryUrl = "https://aka.ms/azd/extensions/registry" ) var ( From 1091ab0c9a24db8321d9493c889196fa617749ed Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 19 Nov 2024 15:17:05 -0800 Subject: [PATCH 15/15] Adds alpha feature for extensions --- cli/azd/cmd/root.go | 28 ++++++++++++------- .../TestUsage-azd-extension-install.snap | 19 ------------- .../TestUsage-azd-extension-list.snap | 19 ------------- .../TestUsage-azd-extension-show.snap | 16 ----------- .../TestUsage-azd-extension-uninstall.snap | 19 ------------- .../TestUsage-azd-extension-upgrade.snap | 20 ------------- .../cmd/testdata/TestUsage-azd-extension.snap | 25 ----------------- cli/azd/cmd/testdata/TestUsage-azd.snap | 1 - cli/azd/pkg/extensions/extensions.go | 3 ++ cli/azd/resources/alpha_features.yaml | 2 ++ 10 files changed, 23 insertions(+), 129 deletions(-) delete mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap delete mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap delete mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap delete mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap delete mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap delete mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension.snap diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index f23af91f58c..3959be1e889 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -15,6 +15,7 @@ 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" @@ -127,7 +128,6 @@ func NewRootCmd( templatesActions(root) authActions(root) hooksActions(root) - extensionActions(root) root.Add("version", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ @@ -351,6 +351,23 @@ func NewRootCmd( ioc.RegisterNamedInstance(rootContainer, "root-cmd", rootCmd) registerCommonDependencies(rootContainer) + // Conditionally register the 'extension' commands if the feature is enabled + var alphaFeatureManager *alpha.FeatureManager + if err := rootContainer.Resolve(&alphaFeatureManager); err == nil { + if alphaFeatureManager.IsEnabled(extensions.FeatureExtensions) { + extensionActions(root) + + 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) + } + } + } + // 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 @@ -358,15 +375,6 @@ 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/cmd/testdata/TestUsage-azd-extension-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap deleted file mode 100644 index f0e4cff7127..00000000000 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap +++ /dev/null @@ -1,19 +0,0 @@ - -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 deleted file mode 100644 index 82346670871..00000000000 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap +++ /dev/null @@ -1,19 +0,0 @@ - -List available extensions. - -Usage - azd extension list [--installed] [flags] - -Flags - --installed : List 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 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 deleted file mode 100644 index 39fcf5a04bb..00000000000 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap +++ /dev/null @@ -1,16 +0,0 @@ - -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-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap deleted file mode 100644 index 62b6519d2f5..00000000000 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ /dev/null @@ -1,19 +0,0 @@ - -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 deleted file mode 100644 index 8571b43ecc1..00000000000 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap +++ /dev/null @@ -1,20 +0,0 @@ - -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 deleted file mode 100644 index 911ac7b40f6..00000000000 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap +++ /dev/null @@ -1,25 +0,0 @@ - -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. - -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/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index b10e8ce3fa9..ec80d3a294e 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -8,7 +8,6 @@ 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) diff --git a/cli/azd/pkg/extensions/extensions.go b/cli/azd/pkg/extensions/extensions.go index b0e5440da9b..1ce1643c84f 100644 --- a/cli/azd/pkg/extensions/extensions.go +++ b/cli/azd/pkg/extensions/extensions.go @@ -1,9 +1,12 @@ package extensions import ( + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/ioc" ) +var FeatureExtensions = alpha.MustFeatureKey("extensions") + func Initialize(serviceLocator *ioc.NestedContainer) (map[string]*Extension, error) { var manager *Manager if err := serviceLocator.Resolve(&manager); err != nil { diff --git a/cli/azd/resources/alpha_features.yaml b/cli/azd/resources/alpha_features.yaml index d7d4f0390f0..80dc03f18e4 100644 --- a/cli/azd/resources/alpha_features.yaml +++ b/cli/azd/resources/alpha_features.yaml @@ -14,3 +14,5 @@ description: "Enables Azure deployment stacks for ARM/Bicep based deployments." - id: compose description: "Enable simplified app-centric modeling. Run `azd add` to add Azure components to your project." +- id: extensions + description: "Enables the use of `azd` extension packages."