diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index 267cdcabb05..bb00387f64b 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 = "Installed Extensions" ) func GetGroupAnnotations() []RootLevelHelpOption { return []RootLevelHelpOption{ - CmdGroupConfig, CmdGroupManage, CmdGroupMonitor, CmdGroupAbout, + CmdGroupConfig, CmdGroupManage, CmdGroupMonitor, CmdGroupExtensions, CmdGroupAbout, } } diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 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/extension.go b/cli/azd/cmd/extension.go new file mode 100644 index 00000000000..59801138f2f --- /dev/null +++ b/cli/azd/cmd/extension.go @@ -0,0 +1,588 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/spf13/cobra" +) + +// Register extension commands +func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { + group := root.Add("extension", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "extension", + Aliases: []string{"ext"}, + Short: "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: "Installs specified extensions.", + }, + ActionResolver: newExtensionInstallAction, + FlagsResolver: newExtensionInstallFlags, + }) + + // azd extension uninstall + group.Add("uninstall", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "uninstall ", + Short: "Uninstall specified extensions.", + }, + ActionResolver: newExtensionUninstallAction, + FlagsResolver: newExtensionUninstallFlags, + }) + + // azd extension upgrade + group.Add("upgrade", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "upgrade ", + Short: "Upgrade specified extensions.", + }, + ActionResolver: newExtensionUpgradeAction, + FlagsResolver: newExtensionUpgradeFlags, + }) + + 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", + }) + + extensionNames := a.args + if len(extensionNames) == 0 { + return nil, fmt.Errorf("must specify an extension name") + } + + if len(extensionNames) > 1 && a.flags.version != "" { + return nil, fmt.Errorf("cannot specify --version flag when using multiple extensions") + } + + for index, extensionName := range extensionNames { + if index > 0 { + a.console.Message(ctx, "") + } + + 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 + } + + 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 += output.WithGrayFormat(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + + a.console.Message(ctx, fmt.Sprintf(" %s %s", output.WithBold("Usage: "), extensionVersion.Usage)) + a.console.Message(ctx, output.WithBold(" Examples:")) + + for _, example := range extensionVersion.Examples { + a.console.Message(ctx, " "+output.WithHighLightFormat(example)) + } + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extension(s) installed successfully", + }, + }, nil +} + +// azd extension uninstall +type extensionUninstallFlags struct { + all bool +} + +func newExtensionUninstallFlags(cmd *cobra.Command) *extensionUninstallFlags { + flags := &extensionUninstallFlags{} + cmd.Flags().BoolVar(&flags.all, "all", false, "Uninstall all installed extensions") + + return flags +} + +type extensionUninstallAction struct { + args []string + flags *extensionUninstallFlags + console input.Console + extensionManager *extensions.Manager +} + +func newExtensionUninstallAction( + args []string, + flags *extensionUninstallFlags, + console input.Console, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionUninstallAction{ + args: args, + flags: flags, + console: console, + extensionManager: extensionManager, + } +} + +func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if len(a.args) > 0 && a.flags.all { + return nil, fmt.Errorf("cannot specify both an extension name and --all flag") + } + + if len(a.args) == 0 && !a.flags.all { + return nil, fmt.Errorf("must specify an extension name or use --all flag") + } + + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Uninstall an azd extension (azd extension uninstall)", + TitleNote: "Uninstalls the specified extension from the local machine", + }) + + 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) + } + + extensionNames = make([]string, 0, len(installed)) + for name := range installed { + extensionNames = append(extensionNames, name) + } + } + + if len(extensionNames) == 0 { + return nil, fmt.Errorf("no extensions to uninstall") + } + + for _, extensionName := range extensionNames { + stepMessage := fmt.Sprintf("Uninstalling %s extension", output.WithHighLightFormat(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(s) uninstalled successfully", + }, + }, nil +} + +type extensionUpgradeFlags struct { + version string + all bool +} + +func newExtensionUpgradeFlags(cmd *cobra.Command) *extensionUpgradeFlags { + flags := &extensionUpgradeFlags{} + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "The version of the extension to upgrade to") + cmd.Flags().BoolVar(&flags.all, "all", false, "Upgrade all installed extensions") + + return flags +} + +// azd extension upgrade +type extensionUpgradeAction struct { + args []string + flags *extensionUpgradeFlags + console input.Console + extensionManager *extensions.Manager +} + +func newExtensionUpgradeAction( + args []string, + flags *extensionUpgradeFlags, + console input.Console, + extensionManager *extensions.Manager, +) actions.Action { + return &extensionUpgradeAction{ + args: args, + flags: flags, + console: console, + extensionManager: extensionManager, + } +} + +func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if len(a.args) > 0 && a.flags.all { + return nil, fmt.Errorf("cannot specify both an extension name and --all flag") + } + + if len(a.args) > 1 && a.flags.version != "" { + return nil, fmt.Errorf("cannot specify --version flag when using multiple extensions") + } + + if len(a.args) == 0 && !a.flags.all { + return nil, fmt.Errorf("must specify an extension name or use --all flag") + } + + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Upgrade azd extensions (azd extension upgrade)", + TitleNote: "Upgrades the specified extensions on the local machine", + }) + + 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) + } + + extensionNames = make([]string, 0, len(installed)) + for name := range installed { + extensionNames = append(extensionNames, name) + } + } + + if len(extensionNames) == 0 { + return nil, fmt.Errorf("no extensions to upgrade") + } + + for index, extensionName := range extensionNames { + if index > 0 { + a.console.Message(ctx, "") + } + + 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 { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to get installed extension: %w", err) + } + + 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) + } + + 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 { + return nil, fmt.Errorf("failed to upgrade extension: %w", err) + } + + stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) + a.console.StopSpinner(ctx, stepMessage, input.StepDone) + + a.console.Message(ctx, fmt.Sprintf(" %s %s", output.WithBold("Usage: "), extensionVersion.Usage)) + a.console.Message(ctx, output.WithBold(" Examples:")) + + for _, example := range extensionVersion.Examples { + a.console.Message(ctx, " "+output.WithHighLightFormat(example)) + } + } + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Extensions upgraded successfully", + }, + }, nil +} diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go new file mode 100644 index 00000000000..fdd5ac417d4 --- /dev/null +++ b/cli/azd/cmd/extensions.go @@ -0,0 +1,164 @@ +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" +) + +// bindExtensions binds the extensions to the root command +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 +} + +// bindExtension binds the extension to the root command +func bindExtension( + serviceLocator ioc.ServiceLocator, + root *actions.ActionDescriptor, + extension *extensions.Extension, +) error { + cmd := &cobra.Command{ + Use: extension.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 +} + +// 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.GetInstalled(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.GetInstalled(extensionName) + if err != nil { + return nil, fmt.Errorf("failed to get extension %s: %w", extensionName, err) + } + + allEnv := []string{} + allEnv = append(allEnv, os.Environ()...) + + env, err := a.lazyEnv.GetValue() + if err == nil && env != nil { + allEnv = append(allEnv, env.Environ()...) + } + + allArgs := []string{} + allArgs = append(allArgs, os.Args[2:]...) + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current working directory: %w", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + extensionPath := filepath.Join(homeDir, extension.Path) + + _, err = os.Stat(extensionPath) + if err != nil { + return nil, fmt.Errorf("extension path was not found: %s: %w", extensionPath, err) + } + + runArgs := exec. + NewRunArgs(extensionPath, allArgs...). + WithCwd(cwd). + WithEnv(allEnv). + WithStdIn(a.console.Handles().Stdin). + WithStdOut(a.console.Handles().Stdout). + WithStdErr(a.console.Handles().Stderr) + + _, err = a.commandRunner.Run(ctx, runArgs) + if err != nil { + return nil, err + } + + return nil, nil +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 7b0c9f0b501..878f906e69c 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" @@ -125,6 +126,7 @@ func NewRootCmd( templatesActions(root) authActions(root) hooksActions(root) + extensionActions(root) root.Add("version", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ @@ -351,6 +353,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/cmd/testdata/TestUsage-azd-extension-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap new file mode 100644 index 00000000000..8a1410b9fee --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-install.snap @@ -0,0 +1,19 @@ + +Installs specified extensions. + +Usage + azd extension install [flags] + +Flags + --docs : Opens the documentation for azd extension install in your web browser. + -h, --help : Gets help for install. + -v, --version string : The version of the extension to install + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap new file mode 100644 index 00000000000..4e5dba60d2d --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-list.snap @@ -0,0 +1,19 @@ + +List available extensions. + +Usage + azd extension list [--installed] [flags] + +Flags + --docs : Opens the documentation for azd extension list in your web browser. + -h, --help : Gets help for list. + --installed : List installed extensions + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap new file mode 100644 index 00000000000..2a0940bdb11 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-show.snap @@ -0,0 +1,18 @@ + +Show details for a specific extension. + +Usage + azd extension show [flags] + +Flags + --docs : Opens the documentation for azd extension show in your web browser. + -h, --help : Gets help for show. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap new file mode 100644 index 00000000000..ba41bf0de75 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -0,0 +1,19 @@ + +Uninstall specified extensions. + +Usage + azd extension uninstall [flags] + +Flags + --all : Uninstall all installed extensions + --docs : Opens the documentation for azd extension uninstall in your web browser. + -h, --help : Gets help for uninstall. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap new file mode 100644 index 00000000000..d0e33a3b034 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-upgrade.snap @@ -0,0 +1,20 @@ + +Upgrade specified extensions. + +Usage + azd extension upgrade [flags] + +Flags + --all : Upgrade all installed extensions + --docs : Opens the documentation for azd extension upgrade in your web browser. + -h, --help : Gets help for upgrade. + -v, --version string : The version of the extension to upgrade to + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap new file mode 100644 index 00000000000..f20ca1fe920 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap @@ -0,0 +1,27 @@ + +Manage azd extensions. + +Usage + azd extension [command] + +Available Commands + install : Installs specified extensions. + list : List available extensions. + show : Show details for a specific extension. + uninstall : Uninstall specified extensions. + upgrade : Upgrade specified extensions. + +Flags + --docs : Opens the documentation for azd extension in your web browser. + -h, --help : Gets help for extension. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Use azd extension [command] --help to view examples and more information about a specific command. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-test.snap b/cli/azd/cmd/testdata/TestUsage-azd-test.snap new file mode 100644 index 00000000000..10da26f68dc --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-test.snap @@ -0,0 +1,18 @@ + +Tools and commands for testing azd projects. + +Usage + azd test [flags] + +Flags + --docs : Opens the documentation for azd test in your web browser. + -h, --help : Gets help for test. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index b7d6761e339..817b94f330b 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -8,6 +8,7 @@ Commands Configure and develop your app auth : Authenticate with Azure. config : Manage azd configurations (ex: default Azure subscription, location). + extension : Manage azd extensions. hooks : Develop, test and run hooks for an application. (Beta) init : Initialize a new application. restore : Restores the application's dependencies. (Beta) @@ -26,6 +27,9 @@ Commands pipeline : Manage and configure your deployment pipelines. (Beta) show : Display information about your app and its resources. + Installed Extensions + + About, help and upgrade version : Print the version number of Azure Developer CLI. 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/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..b0e5440da9b --- /dev/null +++ b/cli/azd/pkg/extensions/extensions.go @@ -0,0 +1,24 @@ +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 + } + + err := manager.Initialize() + if err != nil { + return nil, err + } + + extensions, err := manager.ListInstalled() + 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..1a83b872430 --- /dev/null +++ b/cli/azd/pkg/extensions/manager.go @@ -0,0 +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/registry.json" +) + +var ( + ErrInstalledExtensionNotFound = errors.New("extension not found") + ErrRegistryExtensionNotFound = errors.New("extension not found in registry") + ErrExtensionInstalled = errors.New("extension already installed") + registryCacheDuration = 24 * time.Hour +) + +type Manager struct { + configManager config.UserConfigManager + userConfig config.Config + pipeline azruntime.Pipeline + registryCache *cache.FileCache[ExtensionRegistry] +} + +// 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, + } +} + +// Initialize the extension manager +func (m *Manager) Initialize() error { + userConfig, err := m.configManager.Load() + if err != nil { + 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 || extensions == nil { + return map[string]*Extension{}, nil + } + + return extensions, nil +} + +// 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, 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"` +}