diff --git a/buidler/buidler.go b/buidler/buidler.go index aef6bb8..2cb8762 100644 --- a/buidler/buidler.go +++ b/buidler/buidler.go @@ -22,6 +22,7 @@ func NewDeploymentProvider() *DeploymentProvider { call.NewExportCalls(), call.NewNetworkCalls(), call.NewActionCalls(), + call.NewDevNetCalls(), ) networks, err := rest.Networks.GetPublicNetworks() diff --git a/commands/devnet/devnet.go b/commands/devnet/devnet.go new file mode 100644 index 0000000..fc7017d --- /dev/null +++ b/commands/devnet/devnet.go @@ -0,0 +1,15 @@ +package devnet + +import ( + "github.com/spf13/cobra" + "github.com/tenderly/tenderly-cli/commands" +) + +func init() { + commands.RootCmd.AddCommand(CmdDevNet) +} + +var CmdDevNet = &cobra.Command{ + Use: "devnet", + Short: "Tenderly DevNets.", +} diff --git a/commands/devnet/spawn_rpc.go b/commands/devnet/spawn_rpc.go new file mode 100644 index 0000000..14b191f --- /dev/null +++ b/commands/devnet/spawn_rpc.go @@ -0,0 +1,122 @@ +package devnet + +import ( + "os" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/tenderly/tenderly-cli/commands" + "github.com/tenderly/tenderly-cli/config" + "github.com/tenderly/tenderly-cli/userError" +) + +var accountID string +var projectSlug string +var templateSlug string +var accessKey string +var token string + +func init() { + cmdSpawnRpc.PersistentFlags().StringVar( + &accountID, + "account", + "", + "The Tenderly account username or organization slug. If not provided, the system will try to read 'account_id' from the 'tenderly.yaml' configuration file.", + ) + cmdSpawnRpc.PersistentFlags().StringVar( + &projectSlug, + "project", + "", + "The DevNet project slug. If not provided, the system will try to read 'project_slug' from the 'tenderly.yaml' configuration file.", + ) + cmdSpawnRpc.PersistentFlags().StringVar( + &templateSlug, + "template", + "", + "The DevNet template slug which is going to be applied when spawning the DevNet RPC.", + ) + cmdSpawnRpc.PersistentFlags().StringVar( + &accessKey, + "access_key", + "", + "The Tenderly access key. If not provided, the system will try to read 'access_key' from the 'tenderly.yaml' configuration file.", + ) + cmdSpawnRpc.PersistentFlags().StringVar( + &token, + "token", + "", + "The Tenderly JWT. If not provided, the system will try to read 'token' from the 'tenderly.yaml' configuration file.", + ) + CmdDevNet.AddCommand(cmdSpawnRpc) +} + +var cmdSpawnRpc = &cobra.Command{ + Use: "spawn-rpc", + Short: "Spawn DevNet RPC", + Long: `Spawn DevNet RPC that represents the JSON-RPC endpoint for your DevNet`, + Run: spawnRPCHandler, +} + +func spawnRPCHandler(cmd *cobra.Command, args []string) { + if accountID == "" { + commands.CheckLogin() + accountID = config.GetGlobalString(config.AccountID) + } + + if accountID == "" { + err := userError.NewUserError( + errors.New("account not found"), + "An account is required. Please log in using tenderly login or include the '--account' flag.", + ) + userError.LogError(err) + os.Exit(1) + } + + if accessKey == "" { + accessKey = config.GetGlobalString(config.AccessKey) + } + + if token == "" { + token = config.GetGlobalString(config.Token) + } + + if accessKey == "" && token == "" { + err := userError.NewUserError( + errors.New("access key or token not found"), + "An access key or token is required. Please log in using tenderly login or include the '--access_key' or '--token' flag.", + ) + userError.LogError(err) + os.Exit(1) + } + + if projectSlug == "" { + projectSlug = config.MaybeGetString(config.ProjectSlug) + } + + if projectSlug == "" { + err := userError.NewUserError( + errors.New("project not found"), + "No project was found. To set a project, use tenderly use project or include the '--project' flag.", + ) + userError.LogError(err) + os.Exit(1) + } + + if templateSlug == "" { + err := userError.NewUserError( + errors.New("Missing required argument 'template'"), + "The 'template' argument is required. Please include the '--template' flag and provide the DevNet template slug.", + ) + userError.LogError(err) + os.Exit(1) + } + + rest := commands.NewRest() + response, err := rest.DevNet.SpawnRPC(accountID, projectSlug, templateSlug, accessKey, token) + if err != nil { + logrus.Error("Failed to spawn RPC", err) + return + } + logrus.Info(response) +} diff --git a/commands/util.go b/commands/util.go index 16e8bf5..0b0df42 100644 --- a/commands/util.go +++ b/commands/util.go @@ -34,6 +34,7 @@ func NewRest() *rest.Rest { call.NewExportCalls(), call.NewNetworkCalls(), call.NewActionCalls(), + call.NewDevNetCalls(), ) } diff --git a/hardhat/hardhat.go b/hardhat/hardhat.go index 76964ed..a61826e 100644 --- a/hardhat/hardhat.go +++ b/hardhat/hardhat.go @@ -22,6 +22,7 @@ func NewDeploymentProvider() *DeploymentProvider { call.NewExportCalls(), call.NewNetworkCalls(), call.NewActionCalls(), + call.NewDevNetCalls(), ) networks, err := rest.Networks.GetPublicNetworks() diff --git a/main.go b/main.go index 4193eb8..e29b910 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( // THIS IS HOW WE SUBSCRIBE NESTED COMMANDS _ "github.com/tenderly/tenderly-cli/commands/actions" _ "github.com/tenderly/tenderly-cli/commands/contract" + _ "github.com/tenderly/tenderly-cli/commands/devnet" _ "github.com/tenderly/tenderly-cli/commands/export" ) diff --git a/rest/call/devnet.go b/rest/call/devnet.go new file mode 100644 index 0000000..d19558b --- /dev/null +++ b/rest/call/devnet.go @@ -0,0 +1,57 @@ +package call + +import ( + "encoding/json" + "fmt" + "github.com/tenderly/tenderly-cli/config" + "github.com/tenderly/tenderly-cli/rest" + "github.com/tenderly/tenderly-cli/rest/client" + "github.com/tenderly/tenderly-cli/rest/payloads" +) + +var _ rest.DevNetRoutes = (*DevNetCalls)(nil) + +type DevNetCalls struct{} + +func NewDevNetCalls() *DevNetCalls { + return &DevNetCalls{} +} + +type SpawnRPCRequest struct { + Template string `json:"templateSlugOrId"` +} + +func (rest *DevNetCalls) SpawnRPC( + accountID string, + projectID string, + templateSlug string, + accessKey string, + token string, +) (string, error) { + req := &SpawnRPCRequest{ + Template: templateSlug, + } + reqJson, err := json.Marshal(req) + if err != nil { + return "", err + } + config.SetProjectConfig(config.AccessKey, accessKey) + config.SetProjectConfig(config.Token, token) + path := fmt.Sprintf("api/v1/account/%s/project/%s/devnet/container/spawn-rpc", accountID, projectID) + resp := client.Request( + "POST", + path, + reqJson, + ) + var response *SpawnRPCResponse + err = json.NewDecoder(resp).Decode(&response) + if err != nil { + return "", err + } + return response.URL, err +} + +type SpawnRPCResponse struct { + URL string `json:"url"` + Error *payloads.ApiError `json:"error"` +} diff --git a/rest/client/client.go b/rest/client/client.go index 1646156..282f26f 100755 --- a/rest/client/client.go +++ b/rest/client/client.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "github.com/tenderly/tenderly-cli/rest/payloads" "io" "net/http" "os" @@ -12,33 +13,31 @@ import ( "github.com/sirupsen/logrus" "github.com/tenderly/tenderly-cli/config" - "github.com/tenderly/tenderly-cli/rest/payloads" "github.com/tenderly/tenderly-cli/userError" ) -const sessionLimitErrorSlug = "session_limit_exceeded" +const ( + sessionLimitErrorSlug = "session_limit_exceeded" + defaultApiBaseURL = "https://api.tenderly.co" +) func Request(method, path string, body []byte) io.Reader { - apiBase := "https://api.tenderly.co" - if alternativeApiBase := config.MaybeGetString("api_base"); len(alternativeApiBase) != 0 { - apiBase = alternativeApiBase - } - - requestUrl := fmt.Sprintf("%s/%s", apiBase, strings.TrimPrefix(path, "/")) + apiBaseURL := resolveApiBaseURL() + requestURL := resolveRequestURL(apiBaseURL, path) req, err := http.NewRequest( method, - requestUrl, + requestURL, bytes.NewReader(body), ) if err != nil { - userError.LogErrorf("failed creating request: %s", userError.NewUserError( + userError.LogErrorf("failed to create request: %s", userError.NewUserError( err, - "Failed creating request. Please try again.", + "Failed to create request. Please try again.", )) os.Exit(1) } - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: false} + ensureTLS() if key := config.GetAccessKey(); key != "" { // set access key @@ -48,7 +47,7 @@ func Request(method, path string, body []byte) io.Reader { req.Header.Add("Authorization", "Bearer "+token) urlPath := fmt.Sprintf("api/v1/account/%s/token", config.GetAccountId()) - if requestUrl != fmt.Sprintf("%s/%s", apiBase, urlPath) { + if requestURL != fmt.Sprintf("%s/%s", apiBaseURL, urlPath) { var request payloads.GenerateAccessTokenRequest request.Name = "CLI access token" @@ -110,10 +109,12 @@ func Request(method, path string, body []byte) io.Reader { } } - logrus.WithFields(logrus.Fields{ - "request_url": requestUrl, - "request_body": string(body), - }).Debug("Making request") + logrus.WithFields( + logrus.Fields{ + "request_url": requestURL, + "request_body": string(body), + }, + ).Debug("Making request") res, err := http.DefaultClient.Do(req) if err != nil { @@ -123,9 +124,13 @@ func Request(method, path string, body []byte) io.Reader { )) os.Exit(1) } + defer func() { + _ = res.Body.Close() + }() + + handleResponseStatus(res, err) data, err := io.ReadAll(res.Body) - defer res.Body.Close() if err != nil { userError.LogErrorf("failed reading response body: %s", userError.NewUserError( @@ -139,3 +144,51 @@ func Request(method, path string, body []byte) io.Reader { return bytes.NewReader(data) } + +// handleResponseStatus handles the response status code. +func handleResponseStatus(res *http.Response, err error) { + if res.StatusCode < 200 || res.StatusCode >= 300 { + if res.StatusCode >= 500 { + userError.LogErrorf("request failed: %s", userError.NewUserError( + err, + fmt.Sprintf( + "The request failed with a status code of %d and status message of '%s'. Please try again.", + res.StatusCode, + res.Status, + ), + )) + } else { + userError.LogErrorf("request failed: %s", userError.NewUserError( + err, + fmt.Sprintf( + "The request failed with a status code of %d and status message of '%s'", + res.StatusCode, + res.Status, + ), + )) + } + os.Exit(1) + } +} + +// ensureTLS configures the default http transport to use TLS. +func ensureTLS() { + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{ + InsecureSkipVerify: false, + } +} + +// resolveRequestURL resolves the request URL based on the API base URL and the path. +func resolveRequestURL(apiBaseURL string, path string) string { + requestURL := fmt.Sprintf("%s/%s", apiBaseURL, strings.TrimPrefix(path, "/")) + return requestURL +} + +// resolveApiBaseURL resolves the API base URL based on the config. +func resolveApiBaseURL() string { + apiBase := defaultApiBaseURL + if apiBaseOverride := config.MaybeGetString("api_base"); len(apiBaseOverride) != 0 { + apiBase = apiBaseOverride + } + return apiBase +} diff --git a/rest/rest.go b/rest/rest.go index 0e119f8..2807bec 100755 --- a/rest/rest.go +++ b/rest/rest.go @@ -43,6 +43,10 @@ type ActionRoutes interface { Publish(request generatedActions.PublishRequest, projectSlug string) (*generatedActions.PublishResponse, error) } +type DevNetRoutes interface { + SpawnRPC(accountID string, projectID string, templateSlug string, accessKey string, token string) (string, error) +} + type Rest struct { Auth AuthRoutes User UserRoutes @@ -51,6 +55,7 @@ type Rest struct { Export ExportRoutes Networks NetworkRoutes Actions ActionRoutes + DevNet DevNetRoutes } func NewRest( @@ -61,6 +66,7 @@ func NewRest( export ExportRoutes, networks NetworkRoutes, actions ActionRoutes, + devnet DevNetRoutes, ) *Rest { return &Rest{ Auth: auth, @@ -70,5 +76,6 @@ func NewRest( Export: export, Networks: networks, Actions: actions, + DevNet: devnet, } }