diff --git a/explorer/cmd/cmd.go b/explorer/cmd/cmd.go index 9bd0da7f..f31b1c78 100644 --- a/explorer/cmd/cmd.go +++ b/explorer/cmd/cmd.go @@ -2,6 +2,8 @@ package cmd import "github.com/ignite/cli/v28/ignite/services/plugin" +const flagRPCAddress = "rpc-address" + // GetCommands returns the list of explorer app commands. func GetCommands() []*plugin.Command { return []*plugin.Command{ @@ -11,9 +13,17 @@ func GetCommands() []*plugin.Command { Aliases: []string{"e"}, Commands: []*plugin.Command{ { - Use: "gex [rpc_url]", - Short: "Run gex", + Use: "gex", + Short: "Run gex explorer", Aliases: []string{"g"}, + Flags: []*plugin.Flag{ + { + Name: flagRPCAddress, + Usage: "The chain RPC address", + DefaultValue: "http://localhost:26657", + Type: plugin.FlagTypeString, + }, + }, }, }, }, diff --git a/explorer/cmd/gex.go b/explorer/cmd/gex.go index c28de036..0d21c5aa 100644 --- a/explorer/cmd/gex.go +++ b/explorer/cmd/gex.go @@ -2,49 +2,40 @@ package cmd import ( "context" - "net/url" - "os" "github.com/ignite/cli/v28/ignite/pkg/errors" "github.com/ignite/cli/v28/ignite/services/plugin" "github.com/ignite/apps/explorer/gex" + "github.com/ignite/apps/explorer/pkg/xurl" ) -const maxNumArgs = 1 - // ExecuteGex executes explorer gex subcommand. func ExecuteGex(ctx context.Context, cmd *plugin.ExecutedCommand) error { - argc := len(cmd.Args) - if argc > maxNumArgs { - return errors.Errorf("accepts at most %d arg(s), received %d", maxNumArgs, argc) + flags, err := cmd.NewFlags() + if err != nil { + return err } - ssl := false - host := "localhost" - port := "26657" - - if argc == 1 { - rpcURL, err := url.Parse(cmd.Args[0]) - if err != nil { - return errors.Wrapf(err, "failed to parse RPC URL %s", cmd.Args[0]) - } - - ssl = rpcURL.Scheme == "https" - host = rpcURL.Hostname() - port = rpcURL.Port() - if port == "" { - if ssl { - port = "443" - } else { - port = "80" - } - } + rpcAddress, _ := flags.GetString(flagRPCAddress) + if err != nil { + return errors.Errorf("could not get --%s flag: %s", flagRPCAddress, err) } - g, err := gex.New() + rpcURL, err := xurl.Parse(rpcAddress) + if err != nil { + return errors.Wrapf(err, "failed to parse RPC URL %s", rpcAddress) + } + + g, err := gex.New( + gex.WithHost(rpcURL.Hostname()), + gex.WithPort(rpcURL.Port()), + gex.WithSSL(xurl.IsSSL(rpcURL)), + ) if err != nil { return errors.Wrap(err, "failed to initialize Gex") } - return g.Run(ctx, os.Stdout, os.Stderr, host, port, ssl) + defer g.Cleanup() + + return g.Run(ctx) } diff --git a/explorer/gex/gex.go b/explorer/gex/gex.go index 5a8675ae..8a2daf0d 100644 --- a/explorer/gex/gex.go +++ b/explorer/gex/gex.go @@ -15,14 +15,84 @@ import ( "github.com/ignite/ignite-files/gex" ) -// Gex represents the Gex binary structure. -type Gex struct { - path string - cleanup func() +type ( + // Gex represents the Gex binary structure. + Gex struct { + path string + host string + port string + ssl bool + stdout io.Writer + stderr io.Writer + stdin io.Reader + cleanup func() + } + // Option configures the gex options. + Option func(*Gex) +) + +// newGex returns a Gex with default options. +func newGex() *Gex { + return &Gex{ + host: "localhost", + port: "26657", + ssl: false, + stdout: os.Stdout, + stderr: os.Stderr, + stdin: os.Stdin, + cleanup: nil, + } +} + +// WithHost set the gex host. +func WithHost(host string) Option { + return func(m *Gex) { + m.host = host + } +} + +// WithPort set the gex port. +func WithPort(port string) Option { + return func(m *Gex) { + m.port = port + } +} + +// WithSSL set gex SSL. +func WithSSL(ssl bool) Option { + return func(m *Gex) { + m.ssl = ssl + } +} + +// WithStdout set gex Stdout. +func WithStdout(stdout io.Writer) Option { + return func(m *Gex) { + m.stdout = stdout + } +} + +// WithStdErr set gex StdErr. +func WithStdErr(stderr io.Writer) Option { + return func(m *Gex) { + m.stderr = stderr + } +} + +// WithStdIn set gex StdIn. +func WithStdIn(stdin io.Reader) Option { + return func(m *Gex) { + m.stdin = stdin + } } // New returns the Gex binary executable. -func New() (*Gex, error) { +func New(options ...Option) (*Gex, error) { + g := newGex() + for _, apply := range options { + apply(g) + } + // untar the binary. gzr, err := gzip.NewReader(bytes.NewReader(gex.Binary())) if err != nil { @@ -40,15 +110,12 @@ func New() (*Gex, error) { return nil, errors.Wrap(err, "failed to read tar entry") } - path, cleanup, err := localfs.SaveBytesTemp(binary, "gex", 0o755) + g.path, g.cleanup, err = localfs.SaveBytesTemp(binary, "gex", 0o755) if err != nil { return nil, errors.Wrap(err, "failed to save gex binary as temp file") } - return &Gex{ - path: path, - cleanup: cleanup, - }, nil + return g, nil } // Cleanup clean the temporary Gex binary. @@ -58,18 +125,20 @@ func (g *Gex) Cleanup() error { } // Run runs gex with provided parameters. -func (g *Gex) Run(ctx context.Context, stdout, stderr io.Writer, host, port string, ssl bool) error { - cmd := []string{g.path} - - if host != "" { - cmd = append(cmd, "-h", host) - } - if port != "" { - cmd = append(cmd, "-p", port) +func (g *Gex) Run(ctx context.Context) error { + cmd := []string{ + g.path, + "-h", g.host, + "-p", g.port, } - if ssl { + if g.ssl { cmd = append(cmd, "-s") } - - return exec.Exec(ctx, cmd, exec.StepOption(step.Stdout(stdout)), exec.StepOption(step.Stderr(stderr))) + return exec.Exec( + ctx, + cmd, + exec.StepOption(step.Stdout(g.stdout)), + exec.StepOption(step.Stderr(g.stderr)), + exec.StepOption(step.Stdin(g.stdin)), + ) } diff --git a/explorer/integration/app_test.go b/explorer/integration/app_test.go new file mode 100644 index 00000000..410b2aab --- /dev/null +++ b/explorer/integration/app_test.go @@ -0,0 +1,90 @@ +package integration_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + pluginsconfig "github.com/ignite/cli/v28/ignite/config/plugins" + "github.com/ignite/cli/v28/ignite/pkg/cmdrunner/step" + "github.com/ignite/cli/v28/ignite/services/plugin" + envtest "github.com/ignite/cli/v28/integration" + "github.com/stretchr/testify/require" +) + +func TestGexExplorer(t *testing.T) { + var ( + require = require.New(t) + env = envtest.New(t) + app = env.Scaffold("github.com/test/explorer") + servers = app.RandomizeServerPorts() + ctx, cancel = context.WithCancel(env.Ctx()) + ) + + dir, err := os.Getwd() + require.NoError(err) + pluginPath := filepath.Join(filepath.Dir(filepath.Dir(dir)), "explorer") + + env.Must(env.Exec("add explorer plugin locally", + step.NewSteps(step.New( + step.Exec(envtest.IgniteApp, "app", "install", pluginPath), + step.Workdir(app.SourcePath()), + )), + )) + + // One local plugin expected + assertLocalPlugins(t, app, []pluginsconfig.Plugin{{Path: pluginPath}}) + assertGlobalPlugins(t, nil) + + var ( + isRetrieved bool + got string + output = &bytes.Buffer{} + stepCtx, stepCancel = context.WithCancel(env.Ctx()) + ) + steps := step.NewSteps( + step.New( + step.Stdout(output), + step.Workdir(app.SourcePath()), + step.PreExec(func() error { + return env.IsAppServed(ctx, servers.API) + }), + step.Exec(envtest.IgniteApp, "e", "gex", "--rpc-address", servers.RPC), + step.InExec(func() error { + time.Sleep(15 * time.Second) + stepCancel() + return nil + }), + ), + ) + + go func() { + defer cancel() + isRetrieved = env.Exec("run gex", steps, envtest.ExecRetry(), envtest.ExecCtx(stepCtx)) + }() + + env.Must(app.Serve("should serve", envtest.ExecCtx(ctx))) + + if !isRetrieved { + t.FailNow() + } +} + +func assertLocalPlugins(t *testing.T, app envtest.App, expectedPlugins []pluginsconfig.Plugin) { + t.Helper() + cfg, err := pluginsconfig.ParseDir(app.SourcePath()) + require.NoError(t, err) + require.ElementsMatch(t, expectedPlugins, cfg.Apps, "unexpected local apps") +} + +func assertGlobalPlugins(t *testing.T, expectedPlugins []pluginsconfig.Plugin) { + t.Helper() + cfgPath, err := plugin.PluginsPath() + require.NoError(t, err) + cfg, err := pluginsconfig.ParseDir(cfgPath) + require.NoError(t, err) + require.ElementsMatch(t, expectedPlugins, cfg.Apps, "unexpected global apps") +} diff --git a/explorer/integration/gex_test.go b/explorer/integration/gex_test.go deleted file mode 100644 index 544ea666..00000000 --- a/explorer/integration/gex_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package integration_test - -import ( - "os" - "path/filepath" - "testing" - - pluginsconfig "github.com/ignite/cli/v28/ignite/config/plugins" - "github.com/ignite/cli/v28/ignite/pkg/cmdrunner/step" - "github.com/ignite/cli/v28/ignite/services/plugin" - envtest "github.com/ignite/cli/v28/integration" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGexExplorer(t *testing.T) { - var ( - require = require.New(t) - assert = assert.New(t) - env = envtest.New(t) - app = env.Scaffold("github.com/test/explorer") - - assertPlugins = func(expectedLocalPlugins, expectedGlobalPlugins []pluginsconfig.Plugin) { - localCfg, err := pluginsconfig.ParseDir(app.SourcePath()) - require.NoError(err) - assert.ElementsMatch(expectedLocalPlugins, localCfg.Apps, "unexpected local apps") - - globalCfgPath, err := plugin.PluginsPath() - require.NoError(err) - globalCfg, err := pluginsconfig.ParseDir(globalCfgPath) - require.NoError(err) - assert.ElementsMatch(expectedGlobalPlugins, globalCfg.Apps, "unexpected global apps") - } - ) - - dir, err := os.Getwd() - require.NoError(err) - pluginPath := filepath.Join(filepath.Dir(filepath.Dir(dir)), "explorer") - - env.Must(env.Exec("add explorer plugin", - step.NewSteps(step.New( - step.Exec(envtest.IgniteApp, "app", "install", pluginPath), - step.Workdir(app.SourcePath()), - )), - )) - - // one local plugin expected - assertPlugins( - []pluginsconfig.Plugin{ - { - Path: pluginPath, - }, - }, - nil, - ) - - env.Must(env.Exec("run gex explorer help", - step.NewSteps(step.New( - step.Exec( - envtest.IgniteApp, - "e", - "gex", - "--help", - ), - step.Workdir(app.SourcePath()), - )), - )) -} diff --git a/explorer/main.go b/explorer/main.go index 9366af3c..5ad210ea 100644 --- a/explorer/main.go +++ b/explorer/main.go @@ -20,10 +20,10 @@ func (app) Manifest(context.Context) (*plugin.Manifest, error) { } func (app) Execute(ctx context.Context, c *plugin.ExecutedCommand, _ plugin.ClientAPI) error { - args := c.OsArgs - name := args[len(args)-1] + // Remove the first two elements "ignite" and "flags" from OsArgs. + args := c.OsArgs[2:] - switch name { + switch args[0] { case "gex": return cmd.ExecuteGex(ctx, c) default: diff --git a/explorer/pkg/xurl/xurl.go b/explorer/pkg/xurl/xurl.go new file mode 100644 index 00000000..1646a3e5 --- /dev/null +++ b/explorer/pkg/xurl/xurl.go @@ -0,0 +1,73 @@ +package xurl + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +const ( + schemeHTTP = "http" + schemeHTTPS = "https" +) + +// Parse ensures that url has a port number and scheme suits with the connection type. +func Parse(s string) (*url.URL, error) { + // Check if the URL contains a schema + if !strings.Contains(s, "://") { + // Handle the case where the URI is an IP:PORT or HOST:PORT + // without scheme prefix because that case can't be URL parsed. + // When the URI has no scheme it is parsed as a path by "url.Parse" + // placing the colon within the path, which is invalid. + if host, isAddrPort := addressPort(address(s)); isAddrPort { + return &url.URL{Host: host}, nil + } + + // Prepend a default schema (e.g., "http://") if it doesn't have one + s = fmt.Sprintf("%s://%s", schemeHTTP, s) + } + + // Parsing the URL + u, err := url.Parse(s) + if err != nil { + return nil, err + } + + port := u.Port() + if port == "" { + port = "80" + if u.Scheme == schemeHTTPS { + port = "443" + } + } + + u.Host = fmt.Sprintf("%s:%s", u.Hostname(), port) + return u, nil +} + +// IsSSL ensures that address is SSL protocol. +func IsSSL(url *url.URL) bool { + return url.Scheme == schemeHTTPS +} + +// address ensures that address contains localhost as host if non specified. +func address(address string) string { + if strings.HasPrefix(address, ":") { + return "localhost" + address + } + return address +} + +// addressPort verify if the string is an address and port host. +func addressPort(s string) (string, bool) { + // Use the net split function to support IPv6 addresses + host, port, err := net.SplitHostPort(s) + if err != nil { + return "", false + } + if host == "" { + host = "0.0.0.0" + } + return net.JoinHostPort(host, port), true +} diff --git a/explorer/pkg/xurl/xurl_test.go b/explorer/pkg/xurl/xurl_test.go new file mode 100644 index 00000000..1e276e6d --- /dev/null +++ b/explorer/pkg/xurl/xurl_test.go @@ -0,0 +1,192 @@ +package xurl + +import ( + "net/url" + "testing" + + "github.com/ignite/cli/v28/ignite/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + cases := []struct { + name string + addr string + want *url.URL + err error + }{ + { + name: "http", + addr: "http://localhost", + want: &url.URL{Host: "localhost:80", Scheme: schemeHTTP}, + }, + { + name: "https", + addr: "https://localhost", + want: &url.URL{Host: "localhost:443", Scheme: schemeHTTPS}, + }, + { + name: "custom", + addr: "http://localhost:4000", + want: &url.URL{Host: "localhost:4000", Scheme: schemeHTTP}, + }, + { + name: "custom ssl", + addr: "https://localhost:4005", + want: &url.URL{Host: "localhost:4005", Scheme: schemeHTTPS}, + }, + { + name: "no schema and port", + addr: "localhost", + want: &url.URL{Host: "localhost:80", Scheme: schemeHTTP}, + }, + { + name: "no schema", + addr: "localhost:80", + want: &url.URL{Host: "localhost:80"}, + }, + { + name: "invalid address", + addr: "://.e", + err: errors.New("parse \"://.e\": missing protocol scheme"), + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := Parse(tt.addr) + if tt.err != nil { + require.Error(t, err) + require.Equal(t, tt.err.Error(), err.Error()) + return + } + require.NoError(t, err) + require.EqualValues(t, tt.want, addr) + }) + } +} + +func Test_addressPort(t *testing.T) { + tests := []struct { + name string + arg string + wantHost string + want bool + }{ + { + name: "URI path", + arg: "/test/false", + want: false, + }, + { + name: "invalid address", + arg: "aeihf3/aef/f..//", + want: false, + }, + { + name: "host and port", + arg: "102.33.3.43:10000", + wantHost: "102.33.3.43:10000", + want: true, + }, + { + name: "local port", + arg: "0.0.0.0:10000", + wantHost: "0.0.0.0:10000", + want: true, + }, + { + name: "only port", + arg: ":10000", + wantHost: "0.0.0.0:10000", + want: true, + }, + { + name: "only host", + arg: "102.33.3.43", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, got := addressPort(tt.arg) + require.Equal(t, tt.want, got) + require.Equal(t, tt.wantHost, gotHost) + }) + } +} + +func Test_address(t *testing.T) { + tests := []struct { + name string + address string + want string + }{ + { + name: "localhost", + address: "localhost", + want: "localhost", + }, + { + name: "empty port", + address: "127.0.0.1", + want: "127.0.0.1", + }, + { + name: "empty string", + address: "", + want: "", + }, + { + name: "empty host and port", + address: ":", + want: "localhost:", + }, + { + name: "empty host", + address: ":80", + want: "localhost:80", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := address(tt.address) + require.Equal(t, tt.want, got) + }) + } +} + +func TestIsSSL(t *testing.T) { + tests := []struct { + name string + url *url.URL + want bool + }{ + { + name: "no ssl", + url: &url.URL{Host: "localhost:80", Scheme: schemeHTTP}, + want: false, + }, + { + name: "just not ssl schema", + url: &url.URL{Scheme: schemeHTTP}, + want: false, + }, + { + name: "ssl", + url: &url.URL{Host: "localhost:80", Scheme: schemeHTTPS}, + want: true, + }, + { + name: "just ssl schema", + url: &url.URL{Scheme: schemeHTTPS}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsSSL(tt.url) + require.Equal(t, tt.want, got) + }) + } +}