diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b3f09902..3a407821 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -59,6 +59,8 @@ jobs: - name: Run Go tests 🔬 run: go test -p 1 -cover -covermode atomic -coverprofile=profile.cov -v ./... + env: + GITHUB_AUTH_TOKEN: ${{ secrets.INTEGRATION }} - name: Report coverage to coveralls 📈 uses: shogo82148/actions-goveralls@v1 diff --git a/.gitignore b/.gitignore index 0e597b2f..78b3074d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ dist/ libtensorflow* # Test generated files -cmd/test_plugins.yaml.bak +cmd/test_*.yaml.bak +cmd/test_*.yaml diff --git a/.golangci.yaml b/.golangci.yaml index 6095aa3e..1437f382 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -43,9 +43,12 @@ linters-settings: - "github.com/getsentry/sentry-go" - "github.com/rs/zerolog" - "github.com/grpc-ecosystem/grpc-gateway" + - "google.golang.org/grpc" + - "google.golang.org/protobuf" - "github.com/knadh/koanf" - "github.com/panjf2000/gnet/v2" - "github.com/spf13/cobra" + - "github.com/spf13/cast" - "github.com/invopop/jsonschema" - "github.com/santhosh-tekuri/jsonschema/v5" - "github.com/NYTimes/gziphandler" @@ -60,6 +63,11 @@ linters-settings: - "github.com/google/go-cmp" - "github.com/google/go-github/v53/github" - "github.com/codingsince1985/checksum" + - "golang.org/x/exp/maps" + - "golang.org/x/exp/slices" + - "gopkg.in/yaml.v3" + - "github.com/zenizh/go-capturer" + - "gopkg.in/natefinch/lumberjack.v2" test: files: - $test diff --git a/cmd/cmd_helpers_test.go b/cmd/cmd_helpers_test.go index 8e5657c2..50ff9427 100644 --- a/cmd/cmd_helpers_test.go +++ b/cmd/cmd_helpers_test.go @@ -2,16 +2,12 @@ package cmd import ( "bytes" + "os" + "path/filepath" "github.com/spf13/cobra" ) -var ( - globalTestConfigFile = "./test_global.yaml" - globalTLSTestConfigFile = "./testdata/gatewayd_tls.yaml" - pluginTestConfigFile = "./test_plugins.yaml" -) - // executeCommandC executes a cobra command and returns the command, output, and error. // Taken from https://github.com/spf13/cobra/blob/0c72800b8dba637092b57a955ecee75949e79a73/command_test.go#L48. func executeCommandC(root *cobra.Command, args ...string) (string, error) { @@ -24,3 +20,18 @@ func executeCommandC(root *cobra.Command, args ...string) (string, error) { return buf.String(), err } + +// mustPullPlugin pulls the gatewayd-plugin-cache plugin and returns the path to the archive. +func mustPullPlugin() (string, error) { + pluginURL := "github.com/gatewayd-io/gatewayd-plugin-cache@v0.2.10" + fileName := "./gatewayd-plugin-cache-linux-amd64-v0.2.10.tar.gz" + + if _, err := os.Stat(fileName); os.IsNotExist(err) { + _, err := executeCommandC(rootCmd, "plugin", "install", "--pull-only", pluginURL) + if err != nil { + return "", err + } + } + + return filepath.Abs(fileName) //nolint:wrapcheck +} diff --git a/cmd/config_init_test.go b/cmd/config_init_test.go index 351acf07..79e073c0 100644 --- a/cmd/config_init_test.go +++ b/cmd/config_init_test.go @@ -10,6 +10,7 @@ import ( ) func Test_configInitCmd(t *testing.T) { + globalTestConfigFile := "./test_global_configInitCmd.yaml" // Test configInitCmd. output, err := executeCommandC(rootCmd, "config", "init", "-c", globalTestConfigFile) require.NoError(t, err, "configInitCmd should not return an error") @@ -21,7 +22,7 @@ func Test_configInitCmd(t *testing.T) { assert.FileExists(t, globalTestConfigFile, "configInitCmd should create a config file") // Test configInitCmd with the --force flag to overwrite the config file. - output, err = executeCommandC(rootCmd, "config", "init", "--force") + output, err = executeCommandC(rootCmd, "config", "init", "--force", "-c", globalTestConfigFile) require.NoError(t, err, "configInitCmd should not return an error") assert.Equal(t, fmt.Sprintf("Config file '%s' was overwritten successfully.", globalTestConfigFile), diff --git a/cmd/config_lint_test.go b/cmd/config_lint_test.go index 32e2a7ab..19030df7 100644 --- a/cmd/config_lint_test.go +++ b/cmd/config_lint_test.go @@ -10,6 +10,7 @@ import ( ) func Test_configLintCmd(t *testing.T) { + globalTestConfigFile := "./test_global_configLintCmd.yaml" // Test configInitCmd. output, err := executeCommandC(rootCmd, "config", "init", "-c", globalTestConfigFile) require.NoError(t, err, "configInitCmd should not return an error") diff --git a/cmd/plugin_init_test.go b/cmd/plugin_init_test.go index 5adef60a..2d9abb32 100644 --- a/cmd/plugin_init_test.go +++ b/cmd/plugin_init_test.go @@ -10,6 +10,7 @@ import ( ) func Test_pluginInitCmd(t *testing.T) { + pluginTestConfigFile := "./test_plugins_pluginInitCmd.yaml" // Test plugin init command. output, err := executeCommandC(rootCmd, "plugin", "init", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin init command should not have returned an error") diff --git a/cmd/plugin_install.go b/cmd/plugin_install.go index 19e1bf50..1c33751e 100644 --- a/cmd/plugin_install.go +++ b/cmd/plugin_install.go @@ -1,24 +1,24 @@ package cmd import ( - "context" "fmt" - "log" "os" - "path/filepath" - "regexp" - "runtime" - "slices" "strings" - "github.com/codingsince1985/checksum" "github.com/gatewayd-io/gatewayd/config" "github.com/getsentry/sentry-go" - "github.com/google/go-github/v53/github" + "github.com/spf13/cast" "github.com/spf13/cobra" + "golang.org/x/exp/maps" yamlv3 "gopkg.in/yaml.v3" ) +type ( + Location string + Source string + Extension string +) + const ( NumParts int = 2 LatestVersion string = "latest" @@ -26,8 +26,13 @@ const ( DefaultPluginConfigFilename string = "./gatewayd_plugin.yaml" GitHubURLPrefix string = "github.com/" GitHubURLRegex string = `^github.com\/[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-]+@(?:latest|v(=|>=|<=|=>|=<|>|<|!=|~|~>|\^)?(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$` //nolint:lll - ExtWindows string = ".zip" - ExtOthers string = ".tar.gz" + LocationArgs Location = "args" + LocationConfig Location = "config" + SourceUnknown Source = "unknown" + SourceFile Source = "file" + SourceGitHub Source = "github" + ExtensionZip Extension = ".zip" + ExtensionTarGz Extension = ".tar.gz" ) var ( @@ -37,17 +42,16 @@ var ( update bool backupConfig bool noPrompt bool + pluginName string + overwriteConfig bool ) // pluginInstallCmd represents the plugin install command. var pluginInstallCmd = &cobra.Command{ Use: "install", Short: "Install a plugin from a local archive or a GitHub repository", - Example: " gatewayd plugin install github.com/gatewayd-io/gatewayd-plugin-cache@latest", + Example: " gatewayd plugin install ", //nolint:lll Run: func(cmd *cobra.Command, args []string) { - // This is a list of files that will be deleted after the plugin is installed. - toBeDeleted := []string{} - // Enable Sentry. if enableSentry { // Initialize Sentry. @@ -67,392 +71,113 @@ var pluginInstallCmd = &cobra.Command{ defer sentry.Recover() } - // Validate the number of arguments. - if len(args) < 1 { - cmd.Println( - "Invalid URL. Use the following format: github.com/account/repository@version") - return - } - - var releaseID int64 - var downloadURL string - var pluginFilename string - var pluginName string - var err error - var checksumsFilename string - var client *github.Client - var account string - - // Strip scheme from the plugin URL. - args[0] = strings.TrimPrefix(args[0], "http://") - args[0] = strings.TrimPrefix(args[0], "https://") - - if !strings.HasPrefix(args[0], GitHubURLPrefix) { - // Pull the plugin from a local archive. - pluginFilename = filepath.Clean(args[0]) - if _, err := os.Stat(pluginFilename); os.IsNotExist(err) { - cmd.Println("The plugin file could not be found") - return - } - } - - // Validate the URL. - validGitHubURL := regexp.MustCompile(GitHubURLRegex) - if !validGitHubURL.MatchString(args[0]) { - cmd.Println( - "Invalid URL. Use the following format: github.com/account/repository@version") - return - } - - // Get the plugin version. - pluginVersion := LatestVersion - splittedURL := strings.Split(args[0], "@") - // If the version is not specified, use the latest version. - if len(splittedURL) < NumParts { - cmd.Println("Version not specified. Using latest version") - } - if len(splittedURL) >= NumParts { - pluginVersion = splittedURL[1] - } - - // Get the plugin account and repository. - accountRepo := strings.Split(strings.TrimPrefix(splittedURL[0], GitHubURLPrefix), "/") - if len(accountRepo) != NumParts { - cmd.Println( - "Invalid URL. Use the following format: github.com/account/repository@version") - return - } - account = accountRepo[0] - pluginName = accountRepo[1] - if account == "" || pluginName == "" { - cmd.Println( - "Invalid URL. Use the following format: github.com/account/repository@version") - return - } - - // Get the release artifact from GitHub. - client = github.NewClient(nil) - var release *github.RepositoryRelease - - if pluginVersion == LatestVersion || pluginVersion == "" { - // Get the latest release. - release, _, err = client.Repositories.GetLatestRelease( - context.Background(), account, pluginName) - } else if strings.HasPrefix(pluginVersion, "v") { - // Get an specific release. - release, _, err = client.Repositories.GetReleaseByTag( - context.Background(), account, pluginName, pluginVersion) - } - - if err != nil { - cmd.Println("The plugin could not be found: ", err.Error()) - return - } - - if release == nil { - cmd.Println("The plugin could not be found in the release assets") - return - } - - // Get the archive extension. - archiveExt := ExtOthers - if runtime.GOOS == "windows" { - archiveExt = ExtWindows - } - - // Find and download the plugin binary from the release assets. - pluginFilename, downloadURL, releaseID = findAsset(release, func(name string) bool { - return strings.Contains(name, runtime.GOOS) && - strings.Contains(name, runtime.GOARCH) && - strings.Contains(name, archiveExt) - }) - - var filePath string - if downloadURL != "" && releaseID != 0 { - cmd.Println("Downloading", downloadURL) - filePath, err = downloadFile(client, account, pluginName, releaseID, pluginFilename) - toBeDeleted = append(toBeDeleted, filePath) + switch detectInstallLocation(args) { + case LocationArgs: + // Install the plugin from the CLI argument. + cmd.Println("Installing plugin from CLI argument") + installPlugin(cmd, args[0]) + case LocationConfig: + // Read the gatewayd_plugins.yaml file. + pluginsConfig, err := os.ReadFile(pluginConfigFile) if err != nil { - cmd.Println("Download failed: ", err) - if cleanup { - deleteFiles(toBeDeleted) - } + cmd.Println(err) return } - cmd.Println("Download completed successfully") - } else { - cmd.Println("The plugin file could not be found in the release assets") - return - } - // Find and download the checksums.txt from the release assets. - checksumsFilename, downloadURL, releaseID = findAsset(release, func(name string) bool { - return strings.Contains(name, "checksums.txt") - }) - if checksumsFilename != "" && downloadURL != "" && releaseID != 0 { - cmd.Println("Downloading", downloadURL) - filePath, err = downloadFile(client, account, pluginName, releaseID, checksumsFilename) - toBeDeleted = append(toBeDeleted, filePath) - if err != nil { - cmd.Println("Download failed: ", err) - if cleanup { - deleteFiles(toBeDeleted) - } + // Get the registered plugins from the plugins configuration file. + var localPluginsConfig map[string]interface{} + if err := yamlv3.Unmarshal(pluginsConfig, &localPluginsConfig); err != nil { + cmd.Println("Failed to unmarshal the plugins configuration file: ", err) return } - cmd.Println("Download completed successfully") - } else { - cmd.Println("The checksum file could not be found in the release assets") - return - } - - // Read the checksums text file. - checksums, err := os.ReadFile(checksumsFilename) - if err != nil { - cmd.Println("There was an error reading the checksums file: ", err) - return - } + pluginsList := cast.ToSlice(localPluginsConfig["plugins"]) - // Get the checksum for the plugin binary. - sum, err := checksum.SHA256sum(pluginFilename) - if err != nil { - cmd.Println("There was an error calculating the checksum: ", err) - return - } + // Get the list of plugin download URLs. + pluginURLs := map[string]string{} + existingPluginURLs := map[string]string{} + for _, plugin := range pluginsList { + // Get the plugin instance. + pluginInstance := cast.ToStringMapString(plugin) - // Verify the checksums. - checksumLines := strings.Split(string(checksums), "\n") - for _, line := range checksumLines { - if strings.Contains(line, pluginFilename) { - checksum := strings.Split(line, " ")[0] - if checksum != sum { - cmd.Println("Checksum verification failed") + // Append the plugin URL to the list of plugin URLs. + name := cast.ToString(pluginInstance["name"]) + url := cast.ToString(pluginInstance["url"]) + if url == "" { + cmd.Println("Plugin URL or file path not found in the plugins configuration file for", name) return } - cmd.Println("Checksum verification passed") - break - } - } - - if pullOnly { - cmd.Println("Plugin binary downloaded to", pluginFilename) - // Only the checksums file will be deleted if the --pull-only flag is set. - if err := os.Remove(checksumsFilename); err != nil { - cmd.Println("There was an error deleting the file: ", err) - } - return - } - - // Create a new gatewayd_plugins.yaml file if it doesn't exist. - if _, err := os.Stat(pluginConfigFile); os.IsNotExist(err) { - generateConfig(cmd, Plugins, pluginConfigFile, false) - } else { - // If the config file exists, we should prompt the user to backup - // the plugins configuration file. - if !backupConfig && !noPrompt { - cmd.Print("Do you want to backup the plugins configuration file? [Y/n] ") - var backupOption string - _, err := fmt.Scanln(&backupOption) - if err == nil && (backupOption == "y" || backupOption == "Y") { - backupConfig = true + // Check if duplicate plugin names exist in the plugins configuration file. + if _, ok := pluginURLs[name]; ok { + cmd.Println("Duplicate plugin name found in the plugins configuration file:", name) + return } - } - } - - // Read the gatewayd_plugins.yaml file. - pluginsConfig, err := os.ReadFile(pluginConfigFile) - if err != nil { - log.Println(err) - return - } - // Get the registered plugins from the plugins configuration file. - var localPluginsConfig map[string]interface{} - if err := yamlv3.Unmarshal(pluginsConfig, &localPluginsConfig); err != nil { - log.Println("Failed to unmarshal the plugins configuration file: ", err) - return - } - pluginsList, ok := localPluginsConfig["plugins"].([]interface{}) //nolint:varnamelen - if !ok { - log.Println("There was an error reading the plugins file from disk") - return - } - - // Check if the plugin is already installed. - for _, plugin := range pluginsList { - // User already chosen to update the plugin using the --update CLI flag. - if update { - break + // Update list of plugin URLs based on + // whether the plugin is already installed or not. + localPath := cast.ToString(pluginInstance["localPath"]) + if _, err := os.Stat(localPath); err == nil { + existingPluginURLs[name] = url + } else { + pluginURLs[name] = url + } } - if pluginInstance, ok := plugin.(map[string]interface{}); ok { - if pluginInstance["name"] == pluginName { - // Show a list of options to the user. - cmd.Println("Plugin is already installed.") - if !noPrompt { - cmd.Print("Do you want to update the plugin? [y/N] ") + // Check if the plugin is already installed and prompt the user to confirm the update. + if len(existingPluginURLs) > 0 { + pluginNames := strings.Join(maps.Keys[map[string]string](existingPluginURLs), ", ") + cmd.Printf("The following plugins are already installed: %s\n", pluginNames) - var updateOption string - _, err := fmt.Scanln(&updateOption) - if err == nil && (updateOption == "y" || updateOption == "Y") { - break - } + if noPrompt { + if !update { + cmd.Println("Use the --update flag to update the plugins") + cmd.Println("Aborting...") + return } - cmd.Println("Aborting...") - if cleanup { - deleteFiles(toBeDeleted) + // Merge the existing plugin URLs with the plugin URLs. + for name, url := range existingPluginURLs { + pluginURLs[name] = url + } + } else { + cmd.Print("Do you want to update the existing plugins? [y/N] ") + var response string + _, err := fmt.Scanln(&response) + if err == nil && strings.ToLower(response) == "y" { + // Set the update flag to true, so that the installPlugin function + // can update the existing plugins and doesn't ask for user input again. + update = true + + // Merge the existing plugin URLs with the plugin URLs. + for name, url := range existingPluginURLs { + pluginURLs[name] = url + } + } else { + cmd.Println("Existing plugins will not be updated") } - return } } - } - - // Check if the user wants to take a backup of the plugins configuration file. - if backupConfig { - backupFilename := fmt.Sprintf("%s.bak", pluginConfigFile) - if err := os.WriteFile(backupFilename, pluginsConfig, FilePermissions); err != nil { - cmd.Println("There was an error backing up the plugins configuration file: ", err) - } - cmd.Println("Backup completed successfully") - } - - // Extract the archive. - var filenames []string - if runtime.GOOS == "windows" { - filenames, err = extractZip(pluginFilename, pluginOutputDir) - } else { - filenames, err = extractTarGz(pluginFilename, pluginOutputDir) - } - if err != nil { - cmd.Println("There was an error extracting the plugin archive: ", err) - if cleanup { - deleteFiles(toBeDeleted) - } - return - } - - // Delete all the files except the extracted plugin binary, - // which will be deleted from the list further down. - toBeDeleted = append(toBeDeleted, filenames...) - - // Find the extracted plugin binary. - localPath := "" - pluginFileSum := "" - for _, filename := range filenames { - if strings.Contains(filename, pluginName) { - cmd.Println("Plugin binary extracted to", filename) - - // Remove the plugin binary from the list of files to be deleted. - toBeDeleted = slices.DeleteFunc[[]string, string](toBeDeleted, func(s string) bool { - return s == filename - }) - - localPath = filename - // Get the checksum for the extracted plugin binary. - // TODO: Should we verify the checksum using the checksum.txt file instead? - pluginFileSum, err = checksum.SHA256sum(filename) - if err != nil { - cmd.Println("There was an error calculating the checksum: ", err) - return + // Validate the plugin URLs. + if len(args) == 0 && len(pluginURLs) == 0 { + if len(existingPluginURLs) > 0 && !update { + cmd.Println("Use the --update flag to update the plugins") + } else { + cmd.Println( + "No plugin URLs or file path found in the plugins configuration file or CLI argument") + cmd.Println("Aborting...") } - break - } - } - - var contents string - if strings.HasPrefix(args[0], GitHubURLPrefix) { - // Get the list of files in the repository. - var repoContents *github.RepositoryContent - repoContents, _, _, err = client.Repositories.GetContents( - context.Background(), account, pluginName, DefaultPluginConfigFilename, nil) - if err != nil { - cmd.Println( - "There was an error getting the default plugins configuration file: ", err) return } - // Get the contents of the file. - contents, err = repoContents.GetContent() - if err != nil { - cmd.Println( - "There was an error getting the default plugins configuration file: ", err) - return - } - } else { - // Get the contents of the file. - contentsBytes, err := os.ReadFile( - filepath.Join(pluginOutputDir, DefaultPluginConfigFilename)) - if err != nil { - cmd.Println( - "There was an error getting the default plugins configuration file: ", err) - return - } - contents = string(contentsBytes) - } - - // Get the plugin configuration from the downloaded plugins configuration file. - var downloadedPluginConfig map[string]interface{} - if err := yamlv3.Unmarshal([]byte(contents), &downloadedPluginConfig); err != nil { - cmd.Println("Failed to unmarshal the downloaded plugins configuration file: ", err) - return - } - defaultPluginConfig, ok := downloadedPluginConfig["plugins"].([]interface{}) - if !ok { - cmd.Println("There was an error reading the plugins file from the repository") - return - } - // Get the plugin configuration. - pluginConfig, ok := defaultPluginConfig[0].(map[string]interface{}) - if !ok { - cmd.Println("There was an error reading the default plugin configuration") - return - } - - // Update the plugin's local path and checksum. - pluginConfig["localPath"] = localPath - pluginConfig["checksum"] = pluginFileSum - // Add the plugin config to the list of plugin configs. - added := false - for idx, plugin := range pluginsList { - if pluginInstance, ok := plugin.(map[string]interface{}); ok { - if pluginInstance["name"] == pluginName { - pluginsList[idx] = pluginConfig - added = true - break - } + // Install all the plugins from the plugins configuration file. + cmd.Println("Installing plugins from plugins configuration file") + for _, pluginURL := range pluginURLs { + installPlugin(cmd, pluginURL) } + default: + cmd.Println("Invalid plugin URL or file path") } - if !added { - pluginsList = append(pluginsList, pluginConfig) - } - - // Merge the result back into the config map. - localPluginsConfig["plugins"] = pluginsList - - // Marshal the map into YAML. - updatedPlugins, err := yamlv3.Marshal(localPluginsConfig) - if err != nil { - cmd.Println("There was an error marshalling the plugins configuration: ", err) - return - } - - // Write the YAML to the plugins config file. - if err = os.WriteFile(pluginConfigFile, updatedPlugins, FilePermissions); err != nil { - cmd.Println("There was an error writing the plugins configuration file: ", err) - return - } - - // Delete the downloaded and extracted files, except the plugin binary, - // if the --cleanup flag is set. - if cleanup { - deleteFiles(toBeDeleted) - } - - // TODO: Add a rollback mechanism. - cmd.Println("Plugin installed successfully") }, } @@ -476,6 +201,10 @@ func init() { &update, "update", false, "Update the plugin if it already exists") pluginInstallCmd.Flags().BoolVar( &backupConfig, "backup", false, "Backup the plugins configuration file before installing the plugin") + pluginInstallCmd.Flags().StringVarP( + &pluginName, "name", "n", "", "Name of the plugin (only for installing from archive files)") + pluginInstallCmd.Flags().BoolVar( + &overwriteConfig, "overwrite-config", true, "Overwrite the existing plugins configuration file (overrides --update, only used for installing from the plugins configuration file)") //nolint:lll pluginInstallCmd.Flags().BoolVar( &enableSentry, "sentry", true, "Enable Sentry") // Already exists in run.go } diff --git a/cmd/plugin_install_test.go b/cmd/plugin_install_test.go index 5c960fc7..ff5c1444 100644 --- a/cmd/plugin_install_test.go +++ b/cmd/plugin_install_test.go @@ -10,6 +10,8 @@ import ( ) func Test_pluginInstallCmd(t *testing.T) { + pluginTestConfigFile := "./test_plugins_pluginInstallCmd.yaml" + // Create a test plugin config file. output, err := executeCommandC(rootCmd, "plugin", "init", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin init should not return an error") @@ -19,14 +21,51 @@ func Test_pluginInstallCmd(t *testing.T) { "plugin init command should have returned the correct output") assert.FileExists(t, pluginTestConfigFile, "plugin init command should have created a config file") + // Pull the plugin archive and install it. + pluginArchivePath, err = mustPullPlugin() + require.NoError(t, err, "mustPullPlugin should not return an error") + assert.FileExists(t, pluginArchivePath, "mustPullPlugin should have downloaded the plugin archive") + // Test plugin install command. output, err = executeCommandC( + rootCmd, "plugin", "install", "-p", pluginTestConfigFile, + "--update", "--backup", "--name", "gatewayd-plugin-cache", pluginArchivePath) + require.NoError(t, err, "plugin install should not return an error") + assert.Equal(t, output, "Installing plugin from CLI argument\nBackup completed successfully\nPlugin binary extracted to plugins/gatewayd-plugin-cache\nPlugin installed successfully\n") //nolint:lll + + // See if the plugin was actually installed. + output, err = executeCommandC(rootCmd, "plugin", "list", "-p", pluginTestConfigFile) + require.NoError(t, err, "plugin list should not return an error") + assert.Contains(t, output, "Name: gatewayd-plugin-cache") + + // Clean up. + assert.FileExists(t, "plugins/gatewayd-plugin-cache") + assert.FileExists(t, fmt.Sprintf("%s.bak", pluginTestConfigFile)) + assert.NoFileExists(t, "gatewayd-plugin-cache-linux-amd64-v0.2.4.tar.gz") + assert.NoFileExists(t, "checksums.txt") + assert.NoFileExists(t, "plugins/LICENSE") + assert.NoFileExists(t, "plugins/README.md") + assert.NoFileExists(t, "plugins/checksum.txt") + assert.NoFileExists(t, "plugins/gatewayd_plugin.yaml") + + require.NoError(t, os.RemoveAll("plugins/")) + require.NoError(t, os.Remove(pluginTestConfigFile)) + require.NoError(t, os.Remove(fmt.Sprintf("%s.bak", pluginTestConfigFile))) +} + +func Test_pluginInstallCmdAutomatedNoOverwrite(t *testing.T) { + pluginTestConfigFile := "./testdata/gatewayd_plugins.yaml" + + // Reset the global variable. + pullOnly = false + + // Test plugin install command. + output, err := executeCommandC( rootCmd, "plugin", "install", - "github.com/gatewayd-io/gatewayd-plugin-cache@v0.2.4", - "-p", pluginTestConfigFile, "--update", "--backup") + "-p", pluginTestConfigFile, "--update", "--backup", "--overwrite-config=false") require.NoError(t, err, "plugin install should not return an error") - assert.Contains(t, output, "Downloading https://github.com/gatewayd-io/gatewayd-plugin-cache/releases/download/v0.2.4/gatewayd-plugin-cache-linux-amd64-v0.2.4.tar.gz") //nolint:lll - assert.Contains(t, output, "Downloading https://github.com/gatewayd-io/gatewayd-plugin-cache/releases/download/v0.2.4/checksums.txt") //nolint:lll + assert.Contains(t, output, "/gatewayd-plugin-cache-linux-amd64-") + assert.Contains(t, output, "/checksums.txt") assert.Contains(t, output, "Download completed successfully") assert.Contains(t, output, "Checksum verification passed") assert.Contains(t, output, "Plugin binary extracted to plugins/gatewayd-plugin-cache") @@ -40,14 +79,11 @@ func Test_pluginInstallCmd(t *testing.T) { // Clean up. assert.FileExists(t, "plugins/gatewayd-plugin-cache") assert.FileExists(t, fmt.Sprintf("%s.bak", pluginTestConfigFile)) - assert.NoFileExists(t, "gatewayd-plugin-cache-linux-amd64-v0.2.4.tar.gz") - assert.NoFileExists(t, "checksums.txt") assert.NoFileExists(t, "plugins/LICENSE") assert.NoFileExists(t, "plugins/README.md") assert.NoFileExists(t, "plugins/checksum.txt") assert.NoFileExists(t, "plugins/gatewayd_plugin.yaml") require.NoError(t, os.RemoveAll("plugins/")) - require.NoError(t, os.Remove(pluginTestConfigFile)) require.NoError(t, os.Remove(fmt.Sprintf("%s.bak", pluginTestConfigFile))) } diff --git a/cmd/plugin_list_test.go b/cmd/plugin_list_test.go index 7615d9ff..6298052d 100644 --- a/cmd/plugin_list_test.go +++ b/cmd/plugin_list_test.go @@ -10,6 +10,7 @@ import ( ) func Test_pluginListCmd(t *testing.T) { + pluginTestConfigFile := "./test_plugins_pluginListCmd.yaml" // Test plugin list command. output, err := executeCommandC(rootCmd, "plugin", "init", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin init command should not have returned an error") diff --git a/cmd/run_test.go b/cmd/run_test.go index ebaae822..438e2ec2 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "fmt" "os" "sync" "testing" @@ -13,7 +14,14 @@ import ( "github.com/zenizh/go-capturer" ) +var ( + waitBeforeStop = time.Second + pluginArchivePath = "" +) + func Test_runCmd(t *testing.T) { + globalTestConfigFile := "./test_global_runCmd.yaml" + pluginTestConfigFile := "./test_plugins_runCmd.yaml" // Create a test plugins config file. _, err := executeCommandC(rootCmd, "plugin", "init", "--force", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin init command should not have returned an error") @@ -25,6 +33,8 @@ func Test_runCmd(t *testing.T) { // Check that the config file was created. assert.FileExists(t, globalTestConfigFile, "configInitCmd should create a config file") + stopChan = make(chan struct{}) + var waitGroup sync.WaitGroup waitGroup.Add(1) @@ -45,7 +55,7 @@ func Test_runCmd(t *testing.T) { waitGroup.Add(1) go func(waitGroup *sync.WaitGroup) { - time.Sleep(100 * time.Millisecond) + time.Sleep(waitBeforeStop) StopGracefully( context.Background(), @@ -63,13 +73,14 @@ func Test_runCmd(t *testing.T) { waitGroup.Wait() - // Clean up. - require.NoError(t, os.Remove(pluginTestConfigFile)) require.NoError(t, os.Remove(globalTestConfigFile)) + require.NoError(t, os.Remove(pluginTestConfigFile)) } // Test_runCmdWithTLS tests the run command with TLS enabled on the server. func Test_runCmdWithTLS(t *testing.T) { + globalTLSTestConfigFile := "./testdata/gatewayd_tls.yaml" + pluginTestConfigFile := "./test_plugins_runCmdWithTLS.yaml" // Create a test plugins config file. _, err := executeCommandC(rootCmd, "plugin", "init", "--force", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin init command should not have returned an error") @@ -101,7 +112,7 @@ func Test_runCmdWithTLS(t *testing.T) { waitGroup.Add(1) go func(waitGroup *sync.WaitGroup) { - time.Sleep(100 * time.Millisecond) + time.Sleep(waitBeforeStop) StopGracefully( context.Background(), @@ -119,13 +130,14 @@ func Test_runCmdWithTLS(t *testing.T) { waitGroup.Wait() - // Clean up. require.NoError(t, os.Remove(pluginTestConfigFile)) } // Test_runCmdWithMultiTenancy tests the run command with multi-tenancy enabled. // Note: This test needs two instances of PostgreSQL running on ports 5432 and 5433. func Test_runCmdWithMultiTenancy(t *testing.T) { + globalTestConfigFile := "./testdata/gatewayd.yaml" + pluginTestConfigFile := "./test_plugins_runCmdWithMultiTenancy.yaml" // Create a test plugins config file. _, err := executeCommandC(rootCmd, "plugin", "init", "--force", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin init command should not have returned an error") @@ -140,7 +152,7 @@ func Test_runCmdWithMultiTenancy(t *testing.T) { // Test run command. output := capturer.CaptureOutput(func() { _, err := executeCommandC( - rootCmd, "run", "-c", "testdata/gatewayd.yaml", "-p", pluginTestConfigFile) + rootCmd, "run", "-c", globalTestConfigFile, "-p", pluginTestConfigFile) require.NoError(t, err, "run command should not have returned an error") }) // Print the output for debugging purposes. @@ -158,7 +170,7 @@ func Test_runCmdWithMultiTenancy(t *testing.T) { waitGroup.Add(1) go func(waitGroup *sync.WaitGroup) { - time.Sleep(500 * time.Millisecond) + time.Sleep(waitBeforeStop) StopGracefully( context.Background(), @@ -176,11 +188,12 @@ func Test_runCmdWithMultiTenancy(t *testing.T) { waitGroup.Wait() - // Clean up. require.NoError(t, os.Remove(pluginTestConfigFile)) } func Test_runCmdWithCachePlugin(t *testing.T) { + globalTestConfigFile := "./test_global_runCmdWithCachePlugin.yaml" + pluginTestConfigFile := "./test_plugins_runCmdWithCachePlugin.yaml" // TODO: Remove this once these global variables are removed from cmd/run.go. // https://github.com/gatewayd-io/gatewayd/issues/324 stopChan = make(chan struct{}) @@ -196,24 +209,25 @@ func Test_runCmdWithCachePlugin(t *testing.T) { // Check that the config file was created. assert.FileExists(t, globalTestConfigFile, "configInitCmd should create a config file") + // Pull the plugin archive and install it. + pluginArchivePath, err = mustPullPlugin() + require.NoError(t, err, "mustPullPlugin should not return an error") + assert.FileExists(t, pluginArchivePath, "mustPullPlugin should have downloaded the plugin archive") + // Test plugin install command. output, err := executeCommandC( - rootCmd, "plugin", "install", - "github.com/gatewayd-io/gatewayd-plugin-cache@v0.2.4", - "-p", pluginTestConfigFile, "--update") + rootCmd, "plugin", "install", "-p", pluginTestConfigFile, "--update", "--backup", + "--overwrite-config=true", "--name", "gatewayd-plugin-cache", pluginArchivePath) require.NoError(t, err, "plugin install should not return an error") - assert.Contains(t, output, "Downloading https://github.com/gatewayd-io/gatewayd-plugin-cache/releases/download/v0.2.4/gatewayd-plugin-cache-linux-amd64-v0.2.4.tar.gz") //nolint:lll - assert.Contains(t, output, "Downloading https://github.com/gatewayd-io/gatewayd-plugin-cache/releases/download/v0.2.4/checksums.txt") //nolint:lll - assert.Contains(t, output, "Download completed successfully") - assert.Contains(t, output, "Checksum verification passed") - assert.Contains(t, output, "Plugin binary extracted to plugins/gatewayd-plugin-cache") - assert.Contains(t, output, "Plugin installed successfully") + assert.Equal(t, output, "Installing plugin from CLI argument\nBackup completed successfully\nPlugin binary extracted to plugins/gatewayd-plugin-cache\nPlugin installed successfully\n") //nolint:lll // See if the plugin was actually installed. output, err = executeCommandC(rootCmd, "plugin", "list", "-p", pluginTestConfigFile) require.NoError(t, err, "plugin list should not return an error") assert.Contains(t, output, "Name: gatewayd-plugin-cache") + stopChan = make(chan struct{}) + var waitGroup sync.WaitGroup waitGroup.Add(1) @@ -234,7 +248,7 @@ func Test_runCmdWithCachePlugin(t *testing.T) { waitGroup.Add(1) go func(waitGroup *sync.WaitGroup) { - time.Sleep(time.Second) + time.Sleep(waitBeforeStop * 2) StopGracefully( context.Background(), @@ -252,8 +266,8 @@ func Test_runCmdWithCachePlugin(t *testing.T) { waitGroup.Wait() - // Clean up. require.NoError(t, os.RemoveAll("plugins/")) - require.NoError(t, os.Remove(pluginTestConfigFile)) require.NoError(t, os.Remove(globalTestConfigFile)) + require.NoError(t, os.Remove(pluginTestConfigFile)) + require.NoError(t, os.Remove(fmt.Sprintf("%s.bak", pluginTestConfigFile))) } diff --git a/cmd/testdata/gatewayd_plugins.yaml b/cmd/testdata/gatewayd_plugins.yaml new file mode 100644 index 00000000..3e3e0dec --- /dev/null +++ b/cmd/testdata/gatewayd_plugins.yaml @@ -0,0 +1,21 @@ +plugins: + - name: gatewayd-plugin-cache + enabled: True + url: github.com/gatewayd-io/gatewayd-plugin-cache@v0.2.10 + localPath: plugins/gatewayd-plugin-cache + args: ["--log-level", "debug"] + env: + - MAGIC_COOKIE_KEY=GATEWAYD_PLUGIN + - MAGIC_COOKIE_VALUE=5712b87aa5d7e9f9e9ab643e6603181c5b796015cb1c09d6f5ada882bf2a1872 + - REDIS_URL=redis://localhost:6379/0 + - EXPIRY=1h + - METRICS_ENABLED=True + - METRICS_UNIX_DOMAIN_SOCKET=/tmp/gatewayd-plugin-cache.sock + - METRICS_PATH=/metrics + - PERIODIC_INVALIDATOR_ENABLED=True + - PERIODIC_INVALIDATOR_INTERVAL=1m + - PERIODIC_INVALIDATOR_START_DELAY=1m + - API_ADDRESS=localhost:18080 + - EXIT_ON_STARTUP_ERROR=False + - SENTRY_DSN=https://70eb1abcd32e41acbdfc17bc3407a543@o4504550475038720.ingest.sentry.io/4505342961123328 + checksum: 867e09326da10b6e321d8dbcfcf2d20835bde79a82edc2b440dd81d151041672 diff --git a/cmd/utils.go b/cmd/utils.go index c3115d83..550c62e2 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -14,8 +14,12 @@ import ( "os" "path" "path/filepath" + "regexp" + "runtime" + "slices" "strings" + "github.com/codingsince1985/checksum" "github.com/gatewayd-io/gatewayd/config" gerr "github.com/gatewayd-io/gatewayd/errors" "github.com/google/go-github/v53/github" @@ -24,7 +28,9 @@ import ( koanfJson "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/parsers/yaml" jsonSchemaV5 "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/spf13/cast" "github.com/spf13/cobra" + yamlv3 "gopkg.in/yaml.v3" ) type ( @@ -212,22 +218,21 @@ func extractZip(filename, dest string) ([]string, error) { // Extract the files. filenames := []string{} - for _, file := range zipRc.File { - switch fileInfo := file.FileInfo(); { + for _, fileOrDir := range zipRc.File { + switch fileInfo := fileOrDir.FileInfo(); { case fileInfo.IsDir(): // Sanitize the path. - filename := filepath.Clean(file.Name) - if !path.IsAbs(filename) { - destPath := path.Join(dest, filename) + dirName := filepath.Clean(fileOrDir.Name) + if !path.IsAbs(dirName) { // Create the directory. - + destPath := path.Join(dest, dirName) if err := os.MkdirAll(destPath, FolderPermissions); err != nil { return nil, gerr.ErrExtractFailed.Wrap(err) } } case fileInfo.Mode().IsRegular(): // Sanitize the path. - outFilename := filepath.Join(filepath.Clean(dest), filepath.Clean(file.Name)) + outFilename := filepath.Join(filepath.Clean(dest), filepath.Clean(fileOrDir.Name)) // Check for ZipSlip. if strings.HasPrefix(outFilename, string(os.PathSeparator)) { @@ -243,7 +248,7 @@ func extractZip(filename, dest string) ([]string, error) { defer outFile.Close() // Open the file in the zip archive. - fileRc, err := file.Open() + fileRc, err := fileOrDir.Open() if err != nil { os.Remove(outFilename) return nil, gerr.ErrExtractFailed.Wrap(err) @@ -255,7 +260,7 @@ func extractZip(filename, dest string) ([]string, error) { return nil, gerr.ErrExtractFailed.Wrap(err) } - fileMode := file.FileInfo().Mode() + fileMode := fileOrDir.FileInfo().Mode() // Set the file permissions. if fileMode.IsRegular() && fileMode&ExecFileMask != 0 { if err := os.Chmod(outFilename, ExecFilePermissions); err != nil { @@ -270,7 +275,7 @@ func extractZip(filename, dest string) ([]string, error) { filenames = append(filenames, outFile.Name()) default: return nil, gerr.ErrExtractFailed.Wrap( - fmt.Errorf("unknown file type: %s", file.Name)) + fmt.Errorf("unknown file type: %s", fileOrDir.Name)) } } @@ -289,6 +294,7 @@ func extractTarGz(filename, dest string) ([]string, error) { if err != nil { return nil, gerr.ErrExtractFailed.Wrap(err) } + defer uncompressedStream.Close() // Create the output directory if it doesn't exist. if err := os.MkdirAll(dest, FolderPermissions); err != nil { @@ -437,8 +443,438 @@ func downloadFile( func deleteFiles(toBeDeleted []string) { for _, filename := range toBeDeleted { if err := os.Remove(filename); err != nil { - log.Println("There was an error deleting the file: ", err) + fmt.Println("There was an error deleting the file: ", err) //nolint:forbidigo return } } } + +// detectInstallLocation detects the install location based on the number of arguments. +func detectInstallLocation(args []string) Location { + if len(args) == 0 { + return LocationConfig + } + + return LocationArgs +} + +// detectSource detects the source of the path. +func detectSource(path string) Source { + if _, err := os.Stat(path); err == nil { + return SourceFile + } + + // Check if the path is a URL. + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, GitHubURLPrefix) { //nolint:lll + return SourceGitHub + } + + return SourceUnknown +} + +// getFileExtension returns the extension of the archive based on the OS. +func getFileExtension() Extension { + if runtime.GOOS == "windows" { + return ExtensionZip + } + + return ExtensionTarGz +} + +// installPlugin installs a plugin from a given URL. +func installPlugin(cmd *cobra.Command, pluginURL string) { + var ( + // This is a list of files that will be deleted after the plugin is installed. + toBeDeleted = []string{} + + // Source of the plugin: file or GitHub. + source = detectSource(pluginURL) + + // The extension of the archive based on the OS: .zip or .tar.gz. + archiveExt = getFileExtension() + + releaseID int64 + downloadURL string + pluginFilename string + checksumsFilename string + account string + err error + client *github.Client + ) + + switch source { + case SourceFile: + // Pull the plugin from a local archive. + pluginFilename = filepath.Clean(pluginURL) + if _, err := os.Stat(pluginFilename); os.IsNotExist(err) { + cmd.Println("The plugin file could not be found") + return + } + + if pluginName == "" { + cmd.Println("Plugin name not specified") + return + } + case SourceGitHub: + // Strip scheme from the plugin URL. + pluginURL = strings.TrimPrefix(strings.TrimPrefix(pluginURL, "http://"), "https://") + + // Validate the URL. + splittedURL := strings.Split(pluginURL, "@") + if len(splittedURL) < NumParts { + if pluginFilename == "" { + // If the version is not specified, use the latest version. + pluginURL = fmt.Sprintf("%s@%s", pluginURL, LatestVersion) + } + } + + validGitHubURL := regexp.MustCompile(GitHubURLRegex) + if !validGitHubURL.MatchString(pluginURL) { + cmd.Println( + "Invalid URL. Use the following format: github.com/account/repository@version") + return + } + + // Get the plugin version. + pluginVersion := LatestVersion + splittedURL = strings.Split(pluginURL, "@") + // If the version is not specified, use the latest version. + if len(splittedURL) < NumParts { + cmd.Println("Version not specified. Using latest version") + } + if len(splittedURL) >= NumParts { + pluginVersion = splittedURL[1] + } + + // Get the plugin account and repository. + accountRepo := strings.Split(strings.TrimPrefix(splittedURL[0], GitHubURLPrefix), "/") + if len(accountRepo) != NumParts { + cmd.Println( + "Invalid URL. Use the following format: github.com/account/repository@version") + return + } + account = accountRepo[0] + pluginName = accountRepo[1] + if account == "" || pluginName == "" { + cmd.Println( + "Invalid URL. Use the following format: github.com/account/repository@version") + return + } + + // Get the release artifact from GitHub. + client = github.NewClient(nil) + var release *github.RepositoryRelease + + if pluginVersion == LatestVersion || pluginVersion == "" { + // Get the latest release. + release, _, err = client.Repositories.GetLatestRelease( + context.Background(), account, pluginName) + } else if strings.HasPrefix(pluginVersion, "v") { + // Get an specific release. + release, _, err = client.Repositories.GetReleaseByTag( + context.Background(), account, pluginName, pluginVersion) + } + + if err != nil { + cmd.Println("The plugin could not be found: ", err.Error()) + return + } + + if release == nil { + cmd.Println("The plugin could not be found in the release assets") + return + } + + // Find and download the plugin binary from the release assets. + pluginFilename, downloadURL, releaseID = findAsset(release, func(name string) bool { + return strings.Contains(name, runtime.GOOS) && + strings.Contains(name, runtime.GOARCH) && + strings.Contains(name, string(archiveExt)) + }) + var filePath string + if downloadURL != "" && releaseID != 0 { + cmd.Println("Downloading", downloadURL) + filePath, err = downloadFile(client, account, pluginName, releaseID, pluginFilename) + toBeDeleted = append(toBeDeleted, filePath) + if err != nil { + cmd.Println("Download failed: ", err) + if cleanup { + deleteFiles(toBeDeleted) + } + return + } + cmd.Println("Download completed successfully") + } else { + cmd.Println("The plugin file could not be found in the release assets") + return + } + + // Find and download the checksums.txt from the release assets. + checksumsFilename, downloadURL, releaseID = findAsset(release, func(name string) bool { + return strings.Contains(name, "checksums.txt") + }) + if checksumsFilename != "" && downloadURL != "" && releaseID != 0 { + cmd.Println("Downloading", downloadURL) + filePath, err = downloadFile(client, account, pluginName, releaseID, checksumsFilename) + toBeDeleted = append(toBeDeleted, filePath) + if err != nil { + cmd.Println("Download failed: ", err) + if cleanup { + deleteFiles(toBeDeleted) + } + return + } + cmd.Println("Download completed successfully") + } else { + cmd.Println("The checksum file could not be found in the release assets") + return + } + + // Read the checksums text file. + checksums, err := os.ReadFile(checksumsFilename) + if err != nil { + cmd.Println("There was an error reading the checksums file: ", err) + return + } + + // Get the checksum for the plugin binary. + sum, err := checksum.SHA256sum(pluginFilename) + if err != nil { + cmd.Println("There was an error calculating the checksum: ", err) + return + } + + // Verify the checksums. + checksumLines := strings.Split(string(checksums), "\n") + for _, line := range checksumLines { + if strings.Contains(line, pluginFilename) { + checksum := strings.Split(line, " ")[0] + if checksum != sum { + cmd.Println("Checksum verification failed") + return + } + + cmd.Println("Checksum verification passed") + break + } + } + + if pullOnly { + cmd.Println("Plugin binary downloaded to", pluginFilename) + // Only the checksums file will be deleted if the --pull-only flag is set. + if err := os.Remove(checksumsFilename); err != nil { + cmd.Println("There was an error deleting the file: ", err) + } + return + } + case SourceUnknown: + default: + cmd.Println("Invalid URL or file path") + } + + // NOTE: The rest of the code is executed regardless of the source, + // since the plugin binary is already available (or downloaded) at this point. + + // Create a new "gatewayd_plugins.yaml" file if it doesn't exist. + if _, err := os.Stat(pluginConfigFile); os.IsNotExist(err) { + generateConfig(cmd, Plugins, pluginConfigFile, false) + } else if !backupConfig && !noPrompt { + // If the config file exists, we should prompt the user to backup + // the plugins configuration file. + cmd.Print("Do you want to backup the plugins configuration file? [Y/n] ") + var backupOption string + _, err := fmt.Scanln(&backupOption) + if err == nil && strings.ToLower(backupOption) == "n" { + backupConfig = false + } else { + backupConfig = true + } + } + + // Read the "gatewayd_plugins.yaml" file. + pluginsConfig, err := os.ReadFile(pluginConfigFile) + if err != nil { + cmd.Println(err) + return + } + + // Get the registered plugins from the plugins configuration file. + var localPluginsConfig map[string]interface{} + if err := yamlv3.Unmarshal(pluginsConfig, &localPluginsConfig); err != nil { + cmd.Println("Failed to unmarshal the plugins configuration file: ", err) + return + } + pluginsList := cast.ToSlice(localPluginsConfig["plugins"]) + + // Check if the plugin is already installed. + for _, plugin := range pluginsList { + // User already chosen to update the plugin using the --update CLI flag. + if update { + break + } + + pluginInstance := cast.ToStringMap(plugin) + if pluginInstance["name"] == pluginName { + // Show a list of options to the user. + cmd.Println("Plugin is already installed.") + if !noPrompt { + cmd.Print("Do you want to update the plugin? [y/N] ") + + var updateOption string + _, err := fmt.Scanln(&updateOption) + if err != nil && strings.ToLower(updateOption) == "y" { + break + } + } + + cmd.Println("Aborting...") + if cleanup { + deleteFiles(toBeDeleted) + } + return + } + } + + // Check if the user wants to take a backup of the plugins configuration file. + if backupConfig { + backupFilename := fmt.Sprintf("%s.bak", pluginConfigFile) + if err := os.WriteFile(backupFilename, pluginsConfig, FilePermissions); err != nil { + cmd.Println("There was an error backing up the plugins configuration file: ", err) + } + cmd.Println("Backup completed successfully") + } + + // Extract the archive. + var filenames []string + switch archiveExt { + case ExtensionZip: + filenames, err = extractZip(pluginFilename, pluginOutputDir) + case ExtensionTarGz: + filenames, err = extractTarGz(pluginFilename, pluginOutputDir) + default: + cmd.Println("Invalid archive extension") + return + } + + if err != nil { + cmd.Println("There was an error extracting the plugin archive: ", err) + if cleanup { + deleteFiles(toBeDeleted) + } + return + } + + // Delete all the files except the extracted plugin binary, + // which will be deleted from the list further down. + toBeDeleted = append(toBeDeleted, filenames...) + + // Find the extracted plugin binary. + localPath := "" + pluginFileSum := "" + for _, filename := range filenames { + if strings.Contains(filename, pluginName) { + cmd.Println("Plugin binary extracted to", filename) + + // Remove the plugin binary from the list of files to be deleted. + toBeDeleted = slices.DeleteFunc[[]string, string](toBeDeleted, func(s string) bool { + return s == filename + }) + + localPath = filename + // Get the checksum for the extracted plugin binary. + // TODO: Should we verify the checksum using the checksum.txt file instead? + pluginFileSum, err = checksum.SHA256sum(filename) + if err != nil { + cmd.Println("There was an error calculating the checksum: ", err) + return + } + break + } + } + + var contents string + if source == SourceGitHub { + // Get the list of files in the repository. + var repoContents *github.RepositoryContent + repoContents, _, _, err = client.Repositories.GetContents( + context.Background(), account, pluginName, DefaultPluginConfigFilename, nil) + if err != nil { + cmd.Println( + "There was an error getting the default plugins configuration file: ", err) + return + } + // Get the contents of the file. + contents, err = repoContents.GetContent() + if err != nil { + cmd.Println( + "There was an error getting the default plugins configuration file: ", err) + return + } + } else { + // Get the contents of the file. + contentsBytes, err := os.ReadFile( + filepath.Join(pluginOutputDir, DefaultPluginConfigFilename)) + if err != nil { + cmd.Println( + "There was an error getting the default plugins configuration file: ", err) + return + } + contents = string(contentsBytes) + } + + // Get the plugin configuration from the downloaded plugins configuration file. + var downloadedPluginConfig map[string]interface{} + if err := yamlv3.Unmarshal([]byte(contents), &downloadedPluginConfig); err != nil { + cmd.Println("Failed to unmarshal the downloaded plugins configuration file: ", err) + return + } + defaultPluginConfig := cast.ToSlice(downloadedPluginConfig["plugins"]) + + // Get the plugin configuration. + pluginConfig := cast.ToStringMap(defaultPluginConfig[0]) + + // Update the plugin's local path and checksum. + pluginConfig["localPath"] = localPath + pluginConfig["checksum"] = pluginFileSum + + // Add the plugin config to the list of plugin configs. + added := false + for idx, plugin := range pluginsList { + pluginInstance := cast.ToStringMap(plugin) + if pluginInstance["name"] == pluginName { + pluginsList[idx] = pluginConfig + added = true + break + } + } + if !added { + pluginsList = append(pluginsList, pluginConfig) + } + + // Merge the result back into the config map. + localPluginsConfig["plugins"] = pluginsList + + // Marshal the map into YAML. + updatedPlugins, err := yamlv3.Marshal(localPluginsConfig) + if err != nil { + cmd.Println("There was an error marshalling the plugins configuration: ", err) + return + } + + // Write the YAML to the plugins config file if the --overwrite-config flag is set. + if overwriteConfig { + if err = os.WriteFile(pluginConfigFile, updatedPlugins, FilePermissions); err != nil { + cmd.Println("There was an error writing the plugins configuration file: ", err) + return + } + } + + // Delete the downloaded and extracted files, except the plugin binary, + // if the --cleanup flag is set. + if cleanup { + deleteFiles(toBeDeleted) + } + + // TODO: Add a rollback mechanism. + cmd.Println("Plugin installed successfully") +} diff --git a/config/types.go b/config/types.go index 7cbeb245..e3364284 100644 --- a/config/types.go +++ b/config/types.go @@ -12,6 +12,7 @@ type Plugin struct { Args []string `json:"args"` Env []string `json:"env" jsonschema:"required"` Checksum string `json:"checksum" jsonschema:"required"` + URL string `json:"url"` } type PluginConfig struct { diff --git a/gatewayd_plugins.yaml b/gatewayd_plugins.yaml index 33bb7374..02e6d05b 100644 --- a/gatewayd_plugins.yaml +++ b/gatewayd_plugins.yaml @@ -79,6 +79,7 @@ startTimeout: 1m plugins: - name: gatewayd-plugin-cache enabled: True + url: github.com/gatewayd-io/gatewayd-plugin-cache@latest localPath: ../gatewayd-plugin-cache/gatewayd-plugin-cache args: ["--log-level", "debug"] env: diff --git a/go.mod b/go.mod index 95e462c5..d1f14b07 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/prometheus/common v0.46.0 github.com/rs/zerolog v1.31.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/spf13/cast v1.6.0 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 diff --git a/go.sum b/go.sum index 3181f5e2..b4c7bd67 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= @@ -338,6 +340,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/plugin/plugin_registry.go b/plugin/plugin_registry.go index ff4127e6..a30b7c98 100644 --- a/plugin/plugin_registry.go +++ b/plugin/plugin_registry.go @@ -398,6 +398,7 @@ func (reg *Registry) LoadPlugins( pluginCtx, span := otel.Tracer("").Start(ctx, "Load plugin") span.SetAttributes(attribute.Int("priority", priority)) span.SetAttributes(attribute.String("name", pCfg.Name)) + span.SetAttributes(attribute.String("url", pCfg.URL)) span.SetAttributes(attribute.Bool("enabled", pCfg.Enabled)) span.SetAttributes(attribute.String("checksum", pCfg.Checksum)) span.SetAttributes(attribute.String("local_path", pCfg.LocalPath))