diff --git a/buidler/buidler.go b/buidler/buidler.go index 2cb8762..74dbe6d 100644 --- a/buidler/buidler.go +++ b/buidler/buidler.go @@ -23,6 +23,8 @@ func NewDeploymentProvider() *DeploymentProvider { call.NewNetworkCalls(), call.NewActionCalls(), call.NewDevNetCalls(), + call.NewGatewayCalls(), + call.NewExtensionCalls(), ) networks, err := rest.Networks.GetPublicNetworks() diff --git a/commands/actions/common.go b/commands/actions/common.go index 8e84f4e..ea1aa76 100644 --- a/commands/actions/common.go +++ b/commands/actions/common.go @@ -120,7 +120,7 @@ type actionsTenderlyYaml struct { Actions map[string]actionsModel.ProjectActions `yaml:"actions"` } -func mustGetActions() map[string]actionsModel.ProjectActions { +func MustGetActions() map[string]actionsModel.ProjectActions { if !config.IsAnyActionsInit() { logrus.Error(commands.Colorizer.Sprintf( "Actions not initialized. Are you in the right directory? Run %s to initialize project.", diff --git a/commands/actions/publish.go b/commands/actions/publish.go index 58117f5..4cfc053 100644 --- a/commands/actions/publish.go +++ b/commands/actions/publish.go @@ -74,7 +74,7 @@ func buildFunc(cmd *cobra.Command, args []string) { commands.CheckLogin() r = commands.NewRest() - allActions := mustGetActions() + allActions := MustGetActions() var slugs []string for k := range allActions { slugs = append(slugs, k) diff --git a/commands/extensions/common.go b/commands/extensions/common.go new file mode 100644 index 0000000..668bb81 --- /dev/null +++ b/commands/extensions/common.go @@ -0,0 +1,28 @@ +package extensions + +import ( + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/tenderly/tenderly-cli/commands" +) + +func init() { + commands.RootCmd.AddCommand(extensionsCmd) +} + +var extensionsCmd = &cobra.Command{ + Use: "node-extensions", + Short: "Create, build and deploy Node Extensions.", + Long: "Node Extensions allow you to easily build and deploy custom RPC endpoints for your dapps.\n" + + "Backed by Web3 Actions, you can define your own, custom JSON-RPC endpoints to fit your needs.", + Run: func(cmd *cobra.Command, args []string) { + commands.CheckLogin() + + logrus.Info(commands.Colorizer.Sprintf("\nWelcome to Node Extensions!\n"+ + "Initialize Node Extensions with %s.\n"+ + "Deploy Node Extensions with %s.\n", + commands.Colorizer.Bold(commands.Colorizer.Green("tenderly extensions init")), + commands.Colorizer.Bold(commands.Colorizer.Green("tenderly extensions deploy")), + )) + }, +} diff --git a/commands/extensions/config.go b/commands/extensions/config.go new file mode 100644 index 0000000..0c70fe0 --- /dev/null +++ b/commands/extensions/config.go @@ -0,0 +1,60 @@ +package extensions + +import ( + "github.com/tenderly/tenderly-cli/config" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + "github.com/tenderly/tenderly-cli/userError" + "gopkg.in/yaml.v3" + "os" +) + +func ReadExtensionsFromConfig() map[string][]extensionsModel.ConfigExtension { + extensions := make(map[string][]extensionsModel.ConfigExtension) + allExtensions := MustGetExtensions() + for accountAndProjectSlug, projectExtensions := range allExtensions { + extensions[accountAndProjectSlug] = make([]extensionsModel.ConfigExtension, len(projectExtensions.Specs)) + i := 0 + for configExtensionName, configExtension := range projectExtensions.Specs { + extensions[accountAndProjectSlug][i] = extensionsModel.ConfigExtension{ + Name: configExtensionName, + ActionName: configExtension.ActionName, + MethodName: configExtension.MethodName, + Description: configExtension.Description, + } + i++ + } + } + + return extensions +} + +type extensionsTenderlyYaml struct { + Extensions map[string]extensionsModel.ConfigProjectExtensions `yaml:"node_extensions"` +} + +func MustGetExtensions() map[string]extensionsModel.ConfigProjectExtensions { + content, err := config.ReadProjectConfig() + if err != nil { + userError.LogErrorf("failed reading project config: %s", + userError.NewUserError( + err, + "Failed reading project's tenderly.yaml config. This can happen if you are running an older version of the Tenderly CLI.", + ), + ) + os.Exit(1) + } + + var tenderlyYaml extensionsTenderlyYaml + err = yaml.Unmarshal(content, &tenderlyYaml) + if err != nil { + userError.LogErrorf("failed unmarshalling `node_extensions` config: %s", + userError.NewUserError( + err, + "Failed parsing `node_extensions` configuration. This can happen if you are running an older version of the Tenderly CLI.", + ), + ) + os.Exit(1) + } + + return tenderlyYaml.Extensions +} diff --git a/commands/extensions/deploy.go b/commands/extensions/deploy.go new file mode 100644 index 0000000..2bb7a28 --- /dev/null +++ b/commands/extensions/deploy.go @@ -0,0 +1,310 @@ +package extensions + +import ( + "fmt" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/tenderly/tenderly-cli/commands" + actionsModel "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + gatewaysModel "github.com/tenderly/tenderly-cli/model/gateways" + "github.com/tenderly/tenderly-cli/rest" + "os" + "strings" +) + +var ( + r *rest.Rest +) + +var extensionAccountSlug string +var extensionProjectSlug string + +func init() { + deployCmd.PersistentFlags().StringVar(&extensionAccountSlug, "account", "", "The account slug in which the extension will be deployed") + deployCmd.PersistentFlags().StringVar(&extensionProjectSlug, "project", "", "The project slug in which the extension will be deployed") + deployCmd.PersistentFlags().StringVar(&extensionName, "extensionName", "", "Name of the extension to deploy") + + extensionsCmd.AddCommand(deployCmd) +} + +var deployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy extensions for project", + Long: "Deploys the extension specified in command args.", + Run: deployFunc, +} + +func deployFunc(cmd *cobra.Command, args []string) { + commands.CheckLogin() + r = commands.NewRest() + + configExtensions := ReadExtensionsFromConfig() + + var deploymentTasks []deploymentTask + if shouldDeploySingleExtension() { + invalidArgs := validateArgs() + if len(invalidArgs) > 0 { + logrus.Error(commands.Colorizer.Red(fmt.Sprintf("Error deploying extension: missing required flag(s): %s", strings.Join(invalidArgs, ", ")))) + os.Exit(1) + } + accountAndProjectSlug := joinAccountAndProjectSlug(extensionAccountSlug, extensionProjectSlug) + projectExtensions := configExtensions[accountAndProjectSlug] + extensionToDeploy := findExtensionByName(projectExtensions, extensionName) + if extensionToDeploy == nil { + logrus.Error(commands.Colorizer.Red("Error deploying extension: couldn't read extension config from tenderly.yaml")) + os.Exit(1) + } + + projectData, err := initProjectData(extensionAccountSlug, extensionProjectSlug) + if err != nil { + logrus.Error( + commands.Colorizer.Red( + fmt.Sprintf("Error deploying extension: %s", + err.Error(), + )), + ) + os.Exit(1) + } + + deploymentTasks = append(deploymentTasks, deploymentTask{ + ProjectData: projectData, + Extension: *extensionToDeploy, + }) + } else { + for accountAndProjectSlug, projectExtensions := range configExtensions { + accountSlug, projectSlug := splitAccountAndProjectSlug(accountAndProjectSlug) + projectData, err := initProjectData(accountSlug, projectSlug) + if err != nil { + logrus.Error( + commands.Colorizer.Red( + fmt.Sprintf("Error deploying extensions: %s", + err.Error(), + )), + ) + os.Exit(1) + } + for _, extensionToDeploy := range projectExtensions { + deploymentTasks = append(deploymentTasks, deploymentTask{ + ProjectData: projectData, + Extension: extensionToDeploy, + }) + } + } + } + + for _, task := range deploymentTasks { + result := task.execute() + if result.Success { + logrus.Infof("Extension %s deployed successfully.\n", commands.Colorizer.Bold(commands.Colorizer.Green(task.Extension.Name))) + } else { + logrus.Errorf("%s", + commands.Colorizer.Red(fmt.Sprintf("Error deploying extension %s\n\t%s", + commands.Colorizer.Bold(commands.Colorizer.Red(task.Extension.Name)), + commands.Colorizer.Red(strings.Join(result.FailureReasons, "\n\t")), + )), + ) + } + } +} + +func validateArgs() []string { + invalidArgs := make([]string, 0) + if extensionAccountSlug == "" { + invalidArgs = append(invalidArgs, "account") + } + if extensionProjectSlug == "" { + invalidArgs = append(invalidArgs, "project") + } + if extensionName == "" { + invalidArgs = append(invalidArgs, "extensionName") + } + return invalidArgs +} + +type validationResult struct { + Success bool + FailureSlugs []validationFailureSlug +} + +type validationFailureSlug string + +const ( + methodNameInUseSlug validationFailureSlug = "method_name_in_use" + invalidMethodNameSlug validationFailureSlug = "invalid_method_name" + actionIsInUseSlug validationFailureSlug = "action_is_in_use" + actionDoesNotExistSlug validationFailureSlug = "action_does_not_exist" +) + +func getValidationFailureMessage(slug validationFailureSlug) string { + switch slug { + case methodNameInUseSlug: + return "Extension method name is already in use" + case invalidMethodNameSlug: + return "Invalid extension method name" + case actionIsInUseSlug: + return "Action is already in use" + case actionDoesNotExistSlug: + return "Action does not exist" + default: + return "Validation error" + } +} + +type extensionDeploymentResult struct { + Success bool + FailureReasons []string +} + +type deploymentTask struct { + ProjectData ProjectData + Extension extensionsModel.ConfigExtension +} + +func (dt *deploymentTask) validate() validationResult { + result := validationResult{ + FailureSlugs: make([]validationFailureSlug, 0), + Success: true, + } + + if !isMethodNameValid(dt.Extension.MethodName) { + result.Success = false + result.FailureSlugs = append(result.FailureSlugs, invalidMethodNameSlug) + } + + if !isMethodNameAvailableInBackend(dt.ProjectData.GetExtensions(), dt.Extension.MethodName) { + result.Success = false + result.FailureSlugs = append(result.FailureSlugs, methodNameInUseSlug) + } + + extensionAction := dt.ProjectData.FindActionByName(dt.Extension.ActionName) + if extensionAction == nil { + result.Success = false + result.FailureSlugs = append(result.FailureSlugs, actionDoesNotExistSlug) + } + + if extensionAction != nil && !isActionAvailable(dt.ProjectData.GetExtensions(), extensionAction) { + result.Success = false + result.FailureSlugs = append(result.FailureSlugs, actionIsInUseSlug) + } + + return result +} + +func (dt *deploymentTask) execute() extensionDeploymentResult { + result := extensionDeploymentResult{ + Success: true, + FailureReasons: make([]string, 0), + } + + validationResults := dt.validate() + if !validationResults.Success { + result.Success = false + for _, slug := range validationResults.FailureSlugs { + result.FailureReasons = append(result.FailureReasons, getValidationFailureMessage(slug)) + } + + return result + } + + extensionAction := dt.ProjectData.FindActionByName(dt.Extension.ActionName) + if extensionAction == nil { + result.FailureReasons = append(result.FailureReasons, string(actionDoesNotExistSlug)) + return result + } + + _, err := r.Extensions.DeployExtension( + dt.ProjectData.GetAccountSlug(), + dt.ProjectData.GetProjectSlug(), + extensionAction.ID, + dt.ProjectData.GetGateway().ID, + extensionName, + dt.Extension.MethodName) + + if err != nil { + result.FailureReasons = append(result.FailureReasons, err.Error()) + return result + } + + result.Success = true + return result +} + +func findExtensionByName(extensions []extensionsModel.ConfigExtension, name string) *extensionsModel.ConfigExtension { + for _, extension := range extensions { + if extension.Name == name { + return &extension + } + } + + return nil +} + +func getGateway(accountSlug, projectSlug string) (*gatewaysModel.Gateway, error) { + getGatewaysResponse, err := r.Gateways.GetGateways(accountSlug, projectSlug) + if err != nil { + return nil, err + } + + gateways := []gatewaysModel.Gateway(*getGatewaysResponse) + + if gateways == nil || len(gateways) == 0 { + return nil, errors.New("No gateway found for project \"" + accountSlug + "/" + projectSlug + "\".") + } + + return &gateways[0], nil +} + +func getActions(accountSlug, projectSlug string) ([]actionsModel.Action, error) { + response, err := r.Actions.GetActionsForExtensions(accountSlug, projectSlug) + if err != nil { + return nil, err + } + + return response.Actions, nil +} + +func getExtensions(accountSlug, projectSlug, gatewayID string) ([]extensionsModel.BackendExtension, error) { + response, err := r.Extensions.GetExtensions(accountSlug, projectSlug, gatewayID) + if err != nil { + return nil, err + } + + return response.Handlers, nil +} + +func initProjectData(accountSlug, projectSlug string) (ProjectData, error) { + gateway, err := getGateway(accountSlug, projectSlug) + if err != nil { + return nil, errors.Wrap(err, "Failed initializing project data: Failed getting gateway") + } + + actions, err := getActions(accountSlug, projectSlug) + if err != nil { + return nil, errors.Wrap(err, "Failed initializing project data: Failed getting actions") + } + + extensions, err := getExtensions(accountSlug, projectSlug, gateway.ID) + if err != nil { + return nil, errors.Wrap(err, "Failed initializing project data: Failed getting extensions") + } + + return NewProjectData(accountSlug, projectSlug, gateway, actions, extensions), nil +} + +func splitAccountAndProjectSlug(accountAndProjectSlug string) (accountSlug string, projectSlug string) { + projectInfo := strings.Split(accountAndProjectSlug, "/") + accountSlug = projectInfo[0] + projectSlug = projectInfo[1] + + return accountSlug, projectSlug +} + +func joinAccountAndProjectSlug(accountSlug string, projectSlug string) string { + return accountSlug + "/" + projectSlug +} + +func shouldDeploySingleExtension() bool { + return extensionAccountSlug != "" || extensionProjectSlug != "" || extensionName != "" +} diff --git a/commands/extensions/deploy_test.go b/commands/extensions/deploy_test.go new file mode 100644 index 0000000..524ade4 --- /dev/null +++ b/commands/extensions/deploy_test.go @@ -0,0 +1,206 @@ +package extensions + +import ( + actionsModel "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + gatewaysModel "github.com/tenderly/tenderly-cli/model/gateways" + "testing" +) + +func TestDeploymentTask_Validate(t *testing.T) { + projectData := NewProjectData( + "accountSlug", + "projectSlug", + &gatewaysModel.Gateway{ + ID: "61a69c43-1a70-4ac1-ab63-b07806761995", + Name: "", + AccessKey: "4d093d81-7fc1-456f-836d-2ce89afb9b1b", + }, + []actionsModel.Action{ + { + ID: "47790e62-6d15-4a1d-b3aa-b6276fc7c849", + Name: "action-1", + }, + { + ID: "c9676cb4-6b50-4501-882e-63c0aeef5fa1", + Name: "action-2", + }, + { + ID: "53b2a144-3f49-4021-9c09-b44136b06d34", + Name: "action-3", + }, + }, + []extensionsModel.BackendExtension{ + { + Name: "extension-1", + Method: "extension_methodName1", + ActionID: "47790e62-6d15-4a1d-b3aa-b6276fc7c849", + }, + { + Name: "extension-2", + Method: "extension_methodName2", + ActionID: "c9676cb4-6b50-4501-882e-63c0aeef5fa1", + }, + }, + ) + + t.Run("should return success if extension is valid", func(t *testing.T) { + extension := extensionsModel.ConfigExtension{ + Name: "extension-3", + MethodName: "extension_methodName3", + Description: "extension_description3", + ActionName: "action-3", + } + + task := deploymentTask{ + ProjectData: projectData, + Extension: extension, + } + + result := task.validate() + if !result.Success { + t.Errorf("expected result to be successful") + } + }) + + t.Run("should return error if extension method name is already used", func(t *testing.T) { + extension := extensionsModel.ConfigExtension{ + Name: "extension-2", + MethodName: "extension_methodName2", + Description: "extension_description3", + ActionName: "action-3", + } + + task := deploymentTask{ + ProjectData: projectData, + Extension: extension, + } + + result := task.validate() + if result.Success { + t.Errorf("expected result to be unsuccessful") + } else if len(result.FailureSlugs) != 1 { + t.Errorf("expected result to have 1 validation error") + } else if result.FailureSlugs[0] != methodNameInUseSlug { + t.Errorf("expected result to have %s validation error", methodNameInUseSlug) + } + }) + + t.Run("should return error if action is already used", func(t *testing.T) { + extension := extensionsModel.ConfigExtension{ + Name: "extension-3", + MethodName: "extension_methodName3", + Description: "extension_description3", + ActionName: "action-2", + } + + task := deploymentTask{ + ProjectData: projectData, + Extension: extension, + } + + result := task.validate() + if result.Success { + t.Errorf("expected result to be unsuccessful") + } else if len(result.FailureSlugs) != 1 { + t.Errorf("expected result to have 1 validation error") + } else if result.FailureSlugs[0] != actionIsInUseSlug { + t.Errorf("expected result to have %s validation error", actionIsInUseSlug) + } + }) + + t.Run("should return error if extension name is invalid", func(t *testing.T) { + invalidMethodNames := []string{ + "extension name", + "extension-name", + "extension_name_", + "extension_1", + "extension-1", + "extension-", + "extension_", + "extension-1-", + "prefix_name", + } + extension := extensionsModel.ConfigExtension{ + Name: "extension-3", + MethodName: "extension_methodName3", + Description: "extension_description3", + ActionName: "action-3", + } + task := deploymentTask{ + ProjectData: projectData, + } + + for _, invalidMethodName := range invalidMethodNames { + extension.MethodName = invalidMethodName + task.Extension = extension + + result := task.validate() + if result.Success { + t.Errorf("expected result to be unsuccessful") + } else if len(result.FailureSlugs) != 1 { + t.Errorf("expected result to have 1 validation error") + } else if result.FailureSlugs[0] != invalidMethodNameSlug { + t.Errorf("expected result to have %s validation error", invalidMethodNameSlug) + } + } + }) + + t.Run("should return error if action does not exist", func(t *testing.T) { + extension := extensionsModel.ConfigExtension{ + Name: "extension-3", + MethodName: "extension_methodName3", + Description: "extension_description3", + ActionName: "action-4", + } + + task := deploymentTask{ + ProjectData: projectData, + Extension: extension, + } + + result := task.validate() + if result.Success { + t.Errorf("expected result to be unsuccessful") + } else if len(result.FailureSlugs) != 1 { + t.Errorf("expected result to have 1 validation error") + } else if result.FailureSlugs[0] != actionDoesNotExistSlug { + t.Errorf("expected result to have %s validation error", actionDoesNotExistSlug) + } + }) + + t.Run("should return multiple errors if extension is invalid", func(t *testing.T) { + extension := extensionsModel.ConfigExtension{ + Name: "extension-2", + MethodName: "extension_methodName2", + Description: "extension_description3", + ActionName: "action-2", + } + + task := deploymentTask{ + ProjectData: projectData, + Extension: extension, + } + + result := task.validate() + if result.Success { + t.Errorf("expected result to be unsuccessful") + } else if len(result.FailureSlugs) != 2 { + t.Errorf("expected result to have 2 validation errors") + } else { + expectedSlugs := map[validationFailureSlug]bool{ + methodNameInUseSlug: false, + actionIsInUseSlug: false, + } + for _, slug := range result.FailureSlugs { + if !expectedSlugs[slug] { + expectedSlugs[slug] = true + } + } + if !expectedSlugs[methodNameInUseSlug] || + !expectedSlugs[actionIsInUseSlug] { + t.Errorf("expected result to have %s and %s validation errors", methodNameInUseSlug, actionIsInUseSlug) + } + } + }) +} diff --git a/commands/extensions/init.go b/commands/extensions/init.go new file mode 100644 index 0000000..00623d7 --- /dev/null +++ b/commands/extensions/init.go @@ -0,0 +1,191 @@ +package extensions + +import ( + "fmt" + "github.com/manifoldco/promptui" + "github.com/sirupsen/logrus" + "github.com/tenderly/tenderly-cli/commands/actions" + "github.com/tenderly/tenderly-cli/config" + actionsModel "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + "github.com/tenderly/tenderly-cli/userError" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/tenderly/tenderly-cli/commands" +) + +var extensionName string +var extensionDescription string +var extensionMethodName string + +func init() { + initCmd.PersistentFlags().StringVar(&extensionName, "name", "", "Name for the extension") + initCmd.PersistentFlags().StringVar(&extensionDescription, "description", "", "Description for the extension") + initCmd.PersistentFlags().StringVar(&extensionMethodName, "methodName", "", "Name for the extension method (must begin with \"extension_\")") + + extensionsCmd.AddCommand(initCmd) +} + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Init node extensions for project", + Long: "Guides you through setting up extensions in your project. It will populate the `node_extensions` section in the tenderly.yaml file.", + Run: func(cmd *cobra.Command, args []string) { + commands.CheckLogin() + + if !isMethodNameValid(extensionMethodName) { + logrus.Error( + commands.Colorizer.Red( + fmt.Sprintf( + "Error initializing extensions: invalid method name: %s\n"+ + "Please make sure that your extension's method name satisfies the following regex: `%s`\n", + extensionMethodName, + regexMethodName.String(), + )), + ) + os.Exit(1) + } + + actions := actions.MustGetActions() + eligibleActions := findEligibleActions(actions) + + if len(eligibleActions) == 0 { + logrus.Error( + commands.Colorizer.Red( + "Error initializing extensions: no actions found in tenderly.yaml that can be used to create a extension.\n" + + "Please make sure that you have at least one action in tenderly.yaml which has a non authenticated webhook trigger.\n", + ), + ) + os.Exit(1) + } + + projectExtensions := MustGetExtensions() + eligibleActions = findActionsNotInUse(eligibleActions, projectExtensions) + + if len(eligibleActions) == 0 { + logrus.Error( + commands.Colorizer.Red( + "Error initializing extensions: all eligible actions are already used by extensions in tenderly.yaml\n" + + "Please make sure that you have at least one action in tenderly.yaml which has a non authenticated webhook trigger and isn't used by any extension.\n", + ), + ) + os.Exit(1) + } + + projectName, actionName := promptActionSelect(eligibleActions) + if !isMethodNameAvailableInConfig(projectExtensions, projectName, extensionMethodName) { + logrus.Error( + commands.Colorizer.Red( + fmt.Sprintf( + "Error initializing extensions: method name %s is already used by another extension in project `%s`.\n"+ + "Please choose a different method name for your new extension.", + extensionMethodName, + projectName, + )), + ) + os.Exit(1) + } + + newExtension := &extensionsModel.ConfigExtension{ + MethodName: extensionMethodName, + Description: extensionDescription, + ActionName: actionName, + } + + addExtensionToConfig(projectExtensions, projectName, newExtension) + + logrus.Info(commands.Colorizer.Sprintf("\nInitialized extension %s in project %s using action %s", + commands.Colorizer.Bold(commands.Colorizer.Green(extensionName)), + commands.Colorizer.Bold(commands.Colorizer.Green(projectName)), + commands.Colorizer.Bold(commands.Colorizer.Green(actionName)), + )) + + os.Exit(0) + }, +} + +func promptActionSelect(projectActions map[string]map[string]*actionsModel.ActionSpec) (string, string) { + var projectActionNames []string + for projectName, actions := range projectActions { + for actionName, _ := range actions { + projectActionNames = append(projectActionNames, fmt.Sprintf("%s:%s", projectName, actionName)) + } + } + + promptActions := promptui.Select{ + Label: "Select action to use with extension", + Items: projectActionNames, + } + + index, _, err := promptActions.Run() + if err != nil { + userError.LogErrorf("prompt actions failed: %s", err) + os.Exit(1) + } + + parts := strings.Split(projectActionNames[index], ":") + + return parts[0], parts[1] +} + +func findActionsNotInUse( + projectActions map[string]map[string]*actionsModel.ActionSpec, + projectExtensions map[string]extensionsModel.ConfigProjectExtensions) map[string]map[string]*actionsModel.ActionSpec { + + for projectName, extensions := range projectExtensions { + for _, extension := range extensions.Specs { + actionSpecs := projectActions[projectName] + if _, ok := actionSpecs[extension.ActionName]; ok { + delete(actionSpecs, extension.ActionName) + } + } + if len(projectActions[projectName]) == 0 { + delete(projectActions, projectName) + } + } + + return projectActions +} + +func findEligibleActions(projectActions map[string]actionsModel.ProjectActions) map[string]map[string]*actionsModel.ActionSpec { + var filteredProjectActions = make(map[string]map[string]*actionsModel.ActionSpec) + + for projectName, actions := range projectActions { + filteredActions := filterActions(actions.Specs) + if len(filteredActions) > 0 { + filteredProjectActions[projectName] = filteredActions + } + } + + return filteredProjectActions +} + +func filterActions(actions actionsModel.NamedActionSpecs) map[string]*actionsModel.ActionSpec { + filteredActions := make(map[string]*actionsModel.ActionSpec) + + for name, spec := range actions { + err := spec.Parse() + if err != nil { + return nil + } + if spec.TriggerParsed.Type == actionsModel.WebhookType && !*spec.TriggerParsed.Webhook.Authenticated { + filteredActions[name] = spec + } + } + + return filteredActions +} + +func addExtensionToConfig(projectExtensions map[string]extensionsModel.ConfigProjectExtensions, projectName string, newExtension *extensionsModel.ConfigExtension) { + if projectExtensions == nil { + projectExtensions = make(map[string]extensionsModel.ConfigProjectExtensions) + } + if entry, ok := projectExtensions[projectName]; !ok { + entry.Specs = make(map[string]*extensionsModel.ConfigExtension) + projectExtensions[projectName] = entry + } + projectExtensions[projectName].Specs[extensionName] = newExtension + config.MustWriteExtensionsInit(projectName, projectExtensions[projectName]) +} diff --git a/commands/extensions/project_data.go b/commands/extensions/project_data.go new file mode 100644 index 0000000..8d70fb4 --- /dev/null +++ b/commands/extensions/project_data.go @@ -0,0 +1,102 @@ +package extensions + +import ( + actionsModel "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + gatewaysModel "github.com/tenderly/tenderly-cli/model/gateways" +) + +var _ ProjectData = (*projectData)(nil) + +type ProjectData interface { + GetProjectSlug() string + GetAccountSlug() string + GetGateway() *gatewaysModel.Gateway + GetActions() []actionsModel.Action + GetExtensions() []extensionsModel.BackendExtension + FindActionByName(name string) *actionsModel.Action + FindActionByID(id string) *actionsModel.Action + FindExtensionByName(name string) *extensionsModel.BackendExtension +} + +type projectData struct { + accountSlug string + projectSlug string + gateway *gatewaysModel.Gateway + actions []actionsModel.Action + extensions []extensionsModel.BackendExtension +} + +func NewProjectData( + accountSlug string, + projectSlug string, + gateway *gatewaysModel.Gateway, + actions []actionsModel.Action, + extensions []extensionsModel.BackendExtension, +) ProjectData { + if actions == nil { + actions = []actionsModel.Action{} + } + if extensions == nil { + extensions = []extensionsModel.BackendExtension{} + } + pd := &projectData{ + accountSlug: accountSlug, + projectSlug: projectSlug, + gateway: gateway, + actions: actions, + extensions: extensions, + } + + return pd +} + +func (pd *projectData) GetProjectSlug() string { + return pd.projectSlug +} + +func (pd *projectData) GetAccountSlug() string { + return pd.accountSlug +} + +func (pd *projectData) GetGateway() *gatewaysModel.Gateway { + return pd.gateway +} + +func (pd *projectData) GetActions() []actionsModel.Action { + return pd.actions +} + +func (pd *projectData) GetExtensions() []extensionsModel.BackendExtension { + return pd.extensions +} + +func (pd *projectData) FindActionByName(name string) *actionsModel.Action { + for _, action := range pd.actions { + if action.Name == name { + return &action + } + } + + return nil +} + +func (pd *projectData) FindActionByID(ID string) *actionsModel.Action { + for _, action := range pd.actions { + if action.ID == ID { + return &action + } + } + + return nil +} + +func (pd *projectData) FindExtensionByName(name string) *extensionsModel.BackendExtension { + for _, extension := range pd.extensions { + if extension.Name == name { + return &extension + } + } + + return nil +} diff --git a/commands/extensions/project_data_test.go b/commands/extensions/project_data_test.go new file mode 100644 index 0000000..0245bce --- /dev/null +++ b/commands/extensions/project_data_test.go @@ -0,0 +1,143 @@ +package extensions_test + +import ( + "github.com/tenderly/tenderly-cli/commands/extensions" + actionsModel "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + gatewaysModel "github.com/tenderly/tenderly-cli/model/gateways" + "testing" +) + +func TestProjectData_FindActionByID(t *testing.T) { + searchActionID := "47790e62-6d15-4a1d-b3aa-b6276fc7c849" + + testActions := []actionsModel.Action{ + { + ID: searchActionID, + Name: "action-1", + }, + { + ID: "c9676cb4-6b50-4501-882e-63c0aeef5fa1", + Name: "action-2", + }, + } + testProjectData := extensions.NewProjectData( + "accountSlug", + "projectSlug", + &gatewaysModel.Gateway{}, + testActions, + []extensionsModel.BackendExtension{}, + ) + + t.Run("should return nil if actions are nil", func(t *testing.T) { + testProjectData := extensions.NewProjectData( + "accountSlug", + "projectSlug", + &gatewaysModel.Gateway{}, + nil, + []extensionsModel.BackendExtension{}, + ) + action := testProjectData.FindActionByID(searchActionID) + if action != nil { + t.Errorf("expected action to be nil") + } + }) + + t.Run("should return action with given ID", func(t *testing.T) { + action := testProjectData.FindActionByID(searchActionID) + if action == nil { + t.Errorf("expected action to be found") + } + if action.ID != searchActionID { + t.Errorf("expected action to have ID %s, got %s", searchActionID, action.ID) + } + }) + + t.Run("should return nil if action with given ID is not found", func(t *testing.T) { + action := testProjectData.FindActionByID("not-found") + if action != nil { + t.Errorf("expected action to be nil") + } + }) + +} + +func TestProjectData_FindActionByName(t *testing.T) { + searchActionName := "action-1" + + testActions := []actionsModel.Action{ + { + ID: "47790e62-6d15-4a1d-b3aa-b6276fc7c849", + Name: searchActionName, + }, + { + ID: "c9676cb4-6b50-4501-882e-63c0aeef5fa1", + Name: "action-2", + }, + } + testProjectData := extensions.NewProjectData( + "accountSlug", + "projectSlug", + &gatewaysModel.Gateway{}, + testActions, + []extensionsModel.BackendExtension{}, + ) + + t.Run("should return action with given name", func(t *testing.T) { + action := testProjectData.FindActionByName(searchActionName) + if action == nil { + t.Errorf("expected action to be found") + } + if action.Name != searchActionName { + t.Errorf("expected action to have name %s, got %s", searchActionName, action.Name) + } + }) + + t.Run("should return nil if action with given name is not found", func(t *testing.T) { + action := testProjectData.FindActionByName("not-found") + if action != nil { + t.Errorf("expected action to be nil") + } + }) +} + +func TestProjectData_FindExtensionByName(t *testing.T) { + searchExtensionName := "extension-1" + + testExtensions := []extensionsModel.BackendExtension{ + { + Name: searchExtensionName, + Method: "extension_methodName1", + ActionID: "47790e62-6d15-4a1d-b3aa-b6276fc7c849", + }, + { + Name: "extension-2", + Method: "extension_methodName2", + ActionID: "c9676cb4-6b50-4501-882e-63c0aeef5fa1", + }, + } + testProjectData := extensions.NewProjectData( + "accountSlug", + "projectSlug", + &gatewaysModel.Gateway{}, + []actionsModel.Action{}, + testExtensions, + ) + + t.Run("should return extension with given name", func(t *testing.T) { + extension := testProjectData.FindExtensionByName(searchExtensionName) + if extension == nil { + t.Errorf("expected extension to be found") + } + if extension.Name != searchExtensionName { + t.Errorf("expected extension to have name %s, got %s", searchExtensionName, extension.Name) + } + }) + + t.Run("should return nil if extension with given name is not found", func(t *testing.T) { + extension := testProjectData.FindExtensionByName("not-found") + if extension != nil { + t.Errorf("expected extension to be nil") + } + }) +} diff --git a/commands/extensions/validators.go b/commands/extensions/validators.go new file mode 100644 index 0000000..cd76294 --- /dev/null +++ b/commands/extensions/validators.go @@ -0,0 +1,43 @@ +package extensions + +import ( + actionsModel "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" + "regexp" +) + +var regexMethodName = regexp.MustCompile("^extension_[a-z][A-Za-z0-9]{2,}(?:[A-Z][a-z0-9]+)*$") + +func isMethodNameValid(methodName string) bool { + return regexMethodName.MatchString(methodName) +} + +func isMethodNameAvailableInConfig(allExtensions map[string]extensionsModel.ConfigProjectExtensions, accountAndProjectSlug string, methodName string) bool { + projectExtensions := allExtensions[accountAndProjectSlug] + for _, extension := range projectExtensions.Specs { + if extension.MethodName == methodName { + return false + } + } + return true +} + +func isMethodNameAvailableInBackend(extensions []extensionsModel.BackendExtension, methodName string) bool { + for _, extension := range extensions { + if extension.Method == methodName { + return false + } + } + + return true +} + +func isActionAvailable(extensions []extensionsModel.BackendExtension, action *actionsModel.Action) bool { + for _, extension := range extensions { + if extension.ActionID == action.ID { + return false + } + } + + return true +} diff --git a/commands/util.go b/commands/util.go index 0b0df42..5f6e82d 100644 --- a/commands/util.go +++ b/commands/util.go @@ -35,6 +35,8 @@ func NewRest() *rest.Rest { call.NewNetworkCalls(), call.NewActionCalls(), call.NewDevNetCalls(), + call.NewGatewayCalls(), + call.NewExtensionCalls(), ) } diff --git a/config/config.go b/config/config.go index 9472ae0..83c134d 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/params" "github.com/tenderly/tenderly-cli/model/actions" + extensionsModel "github.com/tenderly/tenderly-cli/model/extensions" "github.com/tenderly/tenderly-cli/userError" "github.com/spf13/viper" @@ -29,9 +30,10 @@ const ( OrganizationName = "org_name" - Exports = "exports" - Actions = "actions" - Projects = "projects" + Exports = "exports" + Actions = "actions" + Extensions = "node_extensions" + Projects = "projects" ) var defaultsGlobal = map[string]interface{}{ @@ -363,6 +365,21 @@ func MustWriteActionsInit(projectSlug string, projectActions *actions.ProjectAct } } +func MustWriteExtensionsInit(projectSlug string, projectExtensions extensionsModel.ConfigProjectExtensions) { + act := projectConfig.GetStringMap(Extensions) + act[projectSlug] = projectExtensions + + projectConfig.Set(Extensions, act) + err := WriteProjectConfig() + if err != nil { + userError.LogErrorf( + "write project config: %s", + userError.NewUserError(err, "Couldn't write project config file"), + ) + os.Exit(1) + } +} + func SetProjectConfig(key string, value interface{}) { projectConfig.Set(key, value) } diff --git a/hardhat/hardhat.go b/hardhat/hardhat.go index a61826e..b1efb9a 100644 --- a/hardhat/hardhat.go +++ b/hardhat/hardhat.go @@ -23,6 +23,8 @@ func NewDeploymentProvider() *DeploymentProvider { call.NewNetworkCalls(), call.NewActionCalls(), call.NewDevNetCalls(), + call.NewGatewayCalls(), + call.NewExtensionCalls(), ) networks, err := rest.Networks.GetPublicNetworks() diff --git a/main.go b/main.go index e29b910..d175d8c 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( _ "github.com/tenderly/tenderly-cli/commands/contract" _ "github.com/tenderly/tenderly-cli/commands/devnet" _ "github.com/tenderly/tenderly-cli/commands/export" + _ "github.com/tenderly/tenderly-cli/commands/extensions" ) var ( diff --git a/model/actions/action.go b/model/actions/action.go index 3b2f11b..49d0c6b 100644 --- a/model/actions/action.go +++ b/model/actions/action.go @@ -9,6 +9,11 @@ import ( "github.com/tenderly/tenderly-cli/rest/payloads/generated/actions" ) +type Action struct { + ID string `json:"id"` + Name string `json:"name"` +} + type ProjectActions struct { Runtime string `json:"runtime" yaml:"runtime"` Sources string `json:"sources" yaml:"sources"` diff --git a/model/extensions/extension.go b/model/extensions/extension.go new file mode 100644 index 0000000..c602a79 --- /dev/null +++ b/model/extensions/extension.go @@ -0,0 +1,18 @@ +package extensions + +type BackendExtension struct { + Name string `json:"name" yaml:"name"` + Method string `json:"methodName" yaml:"method"` + ActionID string `json:"actionId" yaml:"action"` +} + +type ConfigExtension struct { + Name string `json:"name" yaml:"-"` + Description string `json:"description" yaml:"description"` + MethodName string `json:"method" yaml:"method"` + ActionName string `json:"action" yaml:"action"` +} + +type ConfigProjectExtensions struct { + Specs map[string]*ConfigExtension `json:"specs" yaml:"specs"` +} diff --git a/model/gateways/gateway.go b/model/gateways/gateway.go new file mode 100644 index 0000000..b2deecd --- /dev/null +++ b/model/gateways/gateway.go @@ -0,0 +1,7 @@ +package gateways + +type Gateway struct { + ID string `json:"id"` + Name string `json:"name"` + AccessKey string `json:"access_key"` +} diff --git a/rest/call/action.go b/rest/call/action.go index d678a99..7d7ee09 100644 --- a/rest/call/action.go +++ b/rest/call/action.go @@ -108,3 +108,27 @@ func (rest *ActionCalls) Publish(request actions2.PublishRequest, projectSlug st return &ret, err } + +func (rest *ActionCalls) GetActionsForExtensions(accountSlugOrID string, projectSlugOrID string) (*payloads.GetActionsForExtensionsResponse, error) { + retOrError := maybeErrorResponse{} + ret := payloads.GetActionsForExtensionsResponse{} + + path := fmt.Sprintf("/api/v1/account/%s/project/%s/actions-get-for-handlers", accountSlugOrID, projectSlugOrID) + response := client.Request( + "GET", + path, + nil, + ) + + err := json.NewDecoder(response).Decode(&retOrError) + if err == nil && retOrError.Error != nil { + return nil, fmt.Errorf("%s (%s)", retOrError.Error.Message, retOrError.Error.Slug) + } + + err = json.Unmarshal(retOrError.Data, &ret) + if err != nil { + return nil, err + } + + return &ret, err +} diff --git a/rest/call/extension.go b/rest/call/extension.go new file mode 100644 index 0000000..a8a7ca2 --- /dev/null +++ b/rest/call/extension.go @@ -0,0 +1,81 @@ +package call + +import ( + "encoding/json" + "fmt" + "github.com/tenderly/tenderly-cli/model/extensions" + "github.com/tenderly/tenderly-cli/rest" + "github.com/tenderly/tenderly-cli/rest/client" + "github.com/tenderly/tenderly-cli/rest/payloads" +) + +var _ rest.ExtensionRoutes = (*ExtensionCalls)(nil) + +type ExtensionCalls struct{} + +func NewExtensionCalls() *ExtensionCalls { + return &ExtensionCalls{} +} + +type DeployRequest struct { + GatewayID string `json:"gatewayId"` + MethodName string `json:"methodName"` + Name string `json:"name"` +} + +func (rest *ExtensionCalls) DeployExtension( + accountSlugOrID string, + projectSlugOrID string, + actionID string, + gatewayID string, + extensionName string, + extensionMethodName string) (*payloads.DeployExtensionResponse, error) { + req := &DeployRequest{ + GatewayID: gatewayID, + Name: extensionName, + MethodName: extensionMethodName, + } + reqJson, err := json.Marshal(req) + if err != nil { + fmt.Println(err) + } + + path := fmt.Sprintf("api/v1/account/%s/project/%s/handlers/%s/register-handler", accountSlugOrID, projectSlugOrID, actionID) + resp := client.Request( + "POST", + path, + reqJson, + ) + + var response *payloads.DeployExtensionResponse + + err = json.NewDecoder(resp).Decode(&response) + if err != nil { + return nil, err + } + + return response, err +} + +func (rest *ExtensionCalls) GetExtensions( + accountSlugOrID string, + projectSlugOrID string, + gatewayID string) (*payloads.GetExtensionsResponse, error) { + path := fmt.Sprintf("api/v1/account/%s/project/%s/handlers/%s/get", accountSlugOrID, projectSlugOrID, gatewayID) + resp := client.Request( + "GET", + path, + nil, + ) + + response := &payloads.GetExtensionsResponse{ + Handlers: []extensions.BackendExtension{}, + } + + err := json.NewDecoder(resp).Decode(&response) + if err != nil { + return nil, err + } + + return response, err +} diff --git a/rest/call/gateway.go b/rest/call/gateway.go new file mode 100644 index 0000000..d3ba60b --- /dev/null +++ b/rest/call/gateway.go @@ -0,0 +1,34 @@ +package call + +import ( + "encoding/json" + "fmt" + "github.com/tenderly/tenderly-cli/rest" + "github.com/tenderly/tenderly-cli/rest/client" + "github.com/tenderly/tenderly-cli/rest/payloads" +) + +var _ rest.GatewayRoutes = (*GatewayCalls)(nil) + +type GatewayCalls struct{} + +func NewGatewayCalls() *GatewayCalls { + return &GatewayCalls{} +} + +func (rest *GatewayCalls) GetGateways(accountID string, projectID string) (*payloads.GetGatewaysResponse, error) { + path := fmt.Sprintf("/api/v1/account/%s/project/%s/gateways", accountID, projectID) + resp := client.Request( + "GET", + path, + nil, + ) + + var response *payloads.GetGatewaysResponse + + err := json.NewDecoder(resp).Decode(&response) + if err != nil { + return nil, err + } + return response, nil +} diff --git a/rest/payloads/actionPayloads.go b/rest/payloads/actionPayloads.go new file mode 100644 index 0000000..74ae9f7 --- /dev/null +++ b/rest/payloads/actionPayloads.go @@ -0,0 +1,9 @@ +package payloads + +import ( + "github.com/tenderly/tenderly-cli/model/actions" +) + +type GetActionsForExtensionsResponse struct { + Actions []actions.Action +} diff --git a/rest/payloads/extensionPayloads.go b/rest/payloads/extensionPayloads.go new file mode 100644 index 0000000..f216b3e --- /dev/null +++ b/rest/payloads/extensionPayloads.go @@ -0,0 +1,9 @@ +package payloads + +import "github.com/tenderly/tenderly-cli/model/extensions" + +type DeployExtensionResponse struct{} + +type GetExtensionsResponse struct { + Handlers []extensions.BackendExtension +} diff --git a/rest/payloads/gatewayPayloads.go b/rest/payloads/gatewayPayloads.go new file mode 100644 index 0000000..9db96d1 --- /dev/null +++ b/rest/payloads/gatewayPayloads.go @@ -0,0 +1,5 @@ +package payloads + +import "github.com/tenderly/tenderly-cli/model/gateways" + +type GetGatewaysResponse []gateways.Gateway diff --git a/rest/rest.go b/rest/rest.go index 2807bec..dd3360b 100755 --- a/rest/rest.go +++ b/rest/rest.go @@ -39,6 +39,7 @@ type NetworkRoutes interface { } type ActionRoutes interface { + GetActionsForExtensions(accountSlugOrID string, projectSlugOrID string) (*payloads.GetActionsForExtensionsResponse, error) Validate(request generatedActions.ValidateRequest, projectSlug string) (*generatedActions.ValidateResponse, error) Publish(request generatedActions.PublishRequest, projectSlug string) (*generatedActions.PublishResponse, error) } @@ -47,15 +48,26 @@ type DevNetRoutes interface { SpawnRPC(accountID string, projectID string, templateSlug string, accessKey string, token string) (string, error) } +type ExtensionRoutes interface { + DeployExtension(accountSlugOrID string, projectSlugOrID string, actionID string, gatewayID string, extensionName string, extensionMethodName string) (*payloads.DeployExtensionResponse, error) + GetExtensions(accountSlugOrID string, projectSlugOrID string, gatewayID string) (*payloads.GetExtensionsResponse, error) +} + +type GatewayRoutes interface { + GetGateways(accountID string, projectID string) (*payloads.GetGatewaysResponse, error) +} + type Rest struct { - Auth AuthRoutes - User UserRoutes - Project ProjectRoutes - Contract ContractRoutes - Export ExportRoutes - Networks NetworkRoutes - Actions ActionRoutes - DevNet DevNetRoutes + Auth AuthRoutes + User UserRoutes + Project ProjectRoutes + Contract ContractRoutes + Export ExportRoutes + Networks NetworkRoutes + Actions ActionRoutes + DevNet DevNetRoutes + Gateways GatewayRoutes + Extensions ExtensionRoutes } func NewRest( @@ -67,15 +79,19 @@ func NewRest( networks NetworkRoutes, actions ActionRoutes, devnet DevNetRoutes, + gateways GatewayRoutes, + extensions ExtensionRoutes, ) *Rest { return &Rest{ - Auth: auth, - User: user, - Project: project, - Contract: contract, - Export: export, - Networks: networks, - Actions: actions, - DevNet: devnet, + Auth: auth, + User: user, + Project: project, + Contract: contract, + Export: export, + Networks: networks, + Actions: actions, + DevNet: devnet, + Gateways: gateways, + Extensions: extensions, } }