Skip to content

Commit

Permalink
feat(connect): introduce ignite connect (#102)
Browse files Browse the repository at this point in the history
  • Loading branch information
julienrbrt authored Feb 7, 2025
1 parent df7f61a commit 7867ed9
Show file tree
Hide file tree
Showing 24 changed files with 2,754 additions and 5 deletions.
3 changes: 3 additions & 0 deletions app.ignite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ apps:
wasm:
description: Scaffold a CosmosWasm-enabled chain with ease
path: ./wasm
connect:
description: Interact with any Cosmos SDK based blockchain using Ignite Connect
path: ./connect
cca:
description: Scaffold a blockchain frontend in seconds with Ignite
path: ./cca
Expand Down
5 changes: 5 additions & 0 deletions connect/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Connect App Changelog

## [`v0.1.0`](https://github.com/ignite/apps/releases/tag/connect/v0.1.0)

* First release of the Connect app compatible with Ignite >= v28.x.y
41 changes: 41 additions & 0 deletions connect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Connect

This Ignite App extends [Ignite CLI](https://github.com/ignite/cli) to let a user interact with any Cosmos SDK based chain.

## Installation

```shell
ignite app install -g github.com/ignite/apps/connect
```

### Usage

* Discover available chains

```shell
ignite connect discover
```

* Add a chain to interact with

```shell
ignite connect add atomone
```

* (Or) Add a local chain to interact with

```shell
ignite connect add simapp localhost:9090
```

* List all connected chains

```shell
ignite connect
```

* Remove a connected chain

```shell
ignite connect rm atomone
```
87 changes: 87 additions & 0 deletions connect/chains/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package chains

import (
"fmt"
"os"
"path"

"gopkg.in/yaml.v3"

igniteconfig "github.com/ignite/cli/v28/ignite/config"
)

var (
configName = "connect.yaml"

// ErrConfigNotFound is returned when the config file is not found
ErrConfigNotFound = fmt.Errorf("config file not found")
)

type Config struct {
Chains map[string]*ChainConfig `yaml:"chains"`
}

type ChainConfig struct {
ChainID string `yaml:"chain_id"`
Bech32Prefix string `yaml:"bech32_prefix"`
GRPCEndpoint string `yaml:"grpc_endpoint"`
}

func (c *Config) Save() error {
out, err := yaml.Marshal(c)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}

configDir, err := ConfigDir()
if err != nil {
return err
}

connectConfigPath := path.Join(configDir, configName)
if err := os.WriteFile(connectConfigPath, out, 0o644); err != nil {
return fmt.Errorf("error saving config: %w", err)
}

return nil
}

func ReadConfig() (*Config, error) {
configDir, err := ConfigDir()
if err != nil {
return nil, err
}

connectConfigPath := path.Join(configDir, configName)
if _, err := os.Stat(connectConfigPath); os.IsNotExist(err) {
return &Config{map[string]*ChainConfig{}}, ErrConfigNotFound
} else if err != nil {
return nil, fmt.Errorf("failed to check config file: %w", err)
}

data, err := os.ReadFile(connectConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}

var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}

return &c, nil
}

func ConfigDir() (string, error) {
igniteConfigDir, err := igniteconfig.DirPath()
if err != nil {
return "", fmt.Errorf("failed to get ignite config directory: %w", err)
}

dir := path.Join(igniteConfigDir, "apps", "connect")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}

return dir, nil
}
164 changes: 164 additions & 0 deletions connect/chains/descriptors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package chains

import (
"context"
"crypto/tls"
"fmt"
"os"
"path"

authv1betav1 "cosmossdk.io/api/cosmos/auth/v1beta1"
autocliv1 "cosmossdk.io/api/cosmos/autocli/v1"
reflectionv1 "cosmossdk.io/api/cosmos/reflection/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protodesc"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/descriptorpb"
)

type Conn struct {
chainName string
config *ChainConfig
configDir string
client *grpc.ClientConn

ProtoFiles *protoregistry.Files
ModuleOptions map[string]*autocliv1.ModuleOptions
}

func NewConn(chainName string, cfg *ChainConfig) (*Conn, error) {
configDir, err := ConfigDir()
if err != nil {
return nil, err
}

return &Conn{
chainName: chainName,
config: cfg,
configDir: configDir,
}, nil
}

// fdsCacheFilename returns the filename for the cached file descriptor set.
func (c *Conn) fdsCacheFilename() string {
return path.Join(c.configDir, fmt.Sprintf("%s.fds", c.chainName))
}

// appOptsCacheFilename returns the filename for the app options cache file.
func (c *Conn) appOptsCacheFilename() string {
return path.Join(c.configDir, fmt.Sprintf("%s.autocli", c.chainName))
}

func (c *Conn) Load(ctx context.Context) error {
var err error
fdSet := &descriptorpb.FileDescriptorSet{}
fdsFilename := c.fdsCacheFilename()

if _, err := os.Stat(fdsFilename); os.IsNotExist(err) {
client, err := c.Connect()
if err != nil {
return err
}

reflectionClient := reflectionv1.NewReflectionServiceClient(client)
fdRes, err := reflectionClient.FileDescriptors(ctx, &reflectionv1.FileDescriptorsRequest{})
if err != nil {
return fmt.Errorf("error getting file descriptors: %w", err)
}

fdSet = &descriptorpb.FileDescriptorSet{File: fdRes.Files}
bz, err := proto.Marshal(fdSet)
if err != nil {
return err
}

if err = os.WriteFile(fdsFilename, bz, 0o600); err != nil {
return err
}
} else {
bz, err := os.ReadFile(fdsFilename)
if err != nil {
return err
}

if err = proto.Unmarshal(bz, fdSet); err != nil {
return err
}
}

c.ProtoFiles, err = protodesc.FileOptions{AllowUnresolvable: true}.NewFiles(fdSet)
if err != nil {
return fmt.Errorf("error building protoregistry.Files: %w", err)
}

appOptsFilename := c.appOptsCacheFilename()
if _, err := os.Stat(appOptsFilename); os.IsNotExist(err) {
client, err := c.Connect()
if err != nil {
return err
}

autocliQueryClient := autocliv1.NewQueryClient(client)
appOptsRes, err := autocliQueryClient.AppOptions(ctx, &autocliv1.AppOptionsRequest{})
if err != nil {
return fmt.Errorf("error getting autocli config: %w", err)
}

bz, err := proto.Marshal(appOptsRes)
if err != nil {
return err
}

if err := os.WriteFile(appOptsFilename, bz, 0o600); err != nil {
return err
}

c.ModuleOptions = appOptsRes.ModuleOptions
} else {
bz, err := os.ReadFile(appOptsFilename)
if err != nil {
return err
}

var appOptsRes autocliv1.AppOptionsResponse
if err := proto.Unmarshal(bz, &appOptsRes); err != nil {
return err
}

c.ModuleOptions = appOptsRes.ModuleOptions
}

return nil
}

func (c *Conn) Connect() (*grpc.ClientConn, error) {
if c.client != nil {
return c.client, nil
}

var err error
creds := credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
})

c.client, err = grpc.NewClient(c.config.GRPCEndpoint, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, fmt.Errorf("failed to connect to gRPC server: %w", err)
}

// try connection by querying an endpoint
// fallback to insecure if it doesn't work
authClient := authv1betav1.NewQueryClient(c.client)
if _, err = authClient.Params(context.Background(), &authv1betav1.QueryParamsRequest{}); err != nil {
creds = insecure.NewCredentials()
c.client, err = grpc.NewClient(c.config.GRPCEndpoint, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, fmt.Errorf("failed to connect to gRPC server: %w", err)
}
}

return c.client, nil
}
3 changes: 3 additions & 0 deletions connect/chains/keyring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package chains

// TODO(@julienrbrt): Implement in follow-up.
Loading

0 comments on commit 7867ed9

Please sign in to comment.