Skip to content

Commit

Permalink
Merge pull request #5 from teamscanworks/feat/goldenimage
Browse files Browse the repository at this point in the history
Implement Golden Test Image + Initial API Server & Client
  • Loading branch information
bonedaddy authored Jul 19, 2023
2 parents fe0a5b0 + 1236318 commit 69e05ea
Show file tree
Hide file tree
Showing 23 changed files with 737 additions and 416 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ breaker-cli
config.yaml

keyring-test/

**/*.txt
76 changes: 12 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Breaker

`breaker` functions as a basic service for the `x/circuit` module, facilitating circuit breaker capabilities. Using a HTTP API payloads can be submitted to an endpoint that can be used to trip or reset circuits.

Given that `breaker` is limited by the functionality present in `x/circuit`, the ability to gate access to module request urls is limited to an allowed/denied list that applies to all addresses.
`breaker` functions as a basic service for the `x/circuit` module, facilitating circuit breaker capabilities, exposing the ability to trip and reset circuits via a HTTP API.

# Features

Expand All @@ -11,75 +9,25 @@ Given that `breaker` is limited by the functionality present in `x/circuit`, the
* Fetch statistical information (disabled commands, etc..)
* JWT authentication
* YAML based configuration
* Basic keyring management for cosmos-sdk

# Dependency Management

Until `compass` is publicly released, managing dependencies for `breaker` requires marking the compass repository as a private module, which can be done in on the following ways:

* running `go env GOPRIVATE=github.com/teamscanworks/compass`
* running `export GOPRIVATE=github.com/teamscanworks/compass`
* Service specific keyring

# Usage

## Build CLI
For usage related documentation please consult the (docs folder)[./docs/README.md]

To build the CLI run `make build`, which outputs an executable `breaker-cli` in the current directory.

## Populate Configuration File

To populate the config file with a default configuration suitable for further customization run:

```shell
$> ./breaker-cli config new
```

## Initialize Keyring

When initializing the configuration file for the first time it is recommended that you create a new mnemonic which will be used to facilitate the actual signing of transactions.

To do so run the following command, which avoids logging the mnemonic phrase by using `fmt.Println` instead.


```shell
$> ./breaker-cli config new-key --create.mnemonic
{"level":"info","ts":1688684424.1259928,"logger":"compass","caller":"[email protected]/client.go:92","msg":"initialized client"}
{"level":"warn","ts":1688684424.1260705,"logger":"breaker.client","caller":"breakerclient/breakerclient.go:94","msg":"no keys found, you should create at least one"}
{"level":"info","ts":1688684424.126079,"caller":"cli/cli.go:216","msg":"creating mnemonic"}
Enter keyring passphrase (attempt 1/3):
Re-enter keyring passphrase:
mnemonic icon concert service unusual wonder observe radar flock other lunch antique patch company snack gravity invest hurt seek card mercy point gadget legal violin
```

## Display Active Keypair

To display the active keypair that is used for signing transactions run the following command, making sure the address has appropriate permissions for using the `x/circuit` module

```shell
$> ./breaker-cli config list-active-keypair
{"level":"info","ts":1688684300.6084576,"logger":"compass","caller":"[email protected]/client.go:92","msg":"initialized client"}
Enter keyring passphrase (attempt 1/3):
{"level":"info","ts":1688684302.6615007,"logger":"breaker.client","caller":"breakerclient/breakerclient.go:96","msg":"configured from address","from.address":"cosmos10d2kehl8ss0q5yn90hk4rw3fxk8nfug4saczwv"}
{"level":"info","ts":1688684302.6652818,"caller":"cli/cli.go:183","msg":"found active keypair","address":"cosmos10d2kehl8ss0q5yn90hk4rw3fxk8nfug4saczwv"}
```

## Running The API Server
## Running Tests

After populating the configuration file you can start the API server as follows. You will be prompted to enter a password to decrypt the keyring that was previously configured.
Due to the usage of `x/circuit`, a special test environment needs to be prepared in order to accurately run all tests. This can be done by running the following commands anytime you want to start from a fresh golden image:

```shell
$> ./breaker-cli api start 11:27:31
{"level":"info","ts":1688668051.7295012,"logger":"compass","caller":"[email protected]/client.go:92","msg":"initialized client"}
Enter keyring passphrase (attempt 1/3):
{"level":"info","ts":1688668053.498679,"logger":"breaker.client","caller":"breakerclient/breakerclient.go:97","msg":"configured from address","from.address":"cosmos18q2gyed58368mmrkz3k30s6kyrx0p4wrykals7"}
$> make reset-simd
$> make start-simd
$> ./scripts/submit_prop.sh
$> ./scripts/submit_votes.sh # this sleeps for about 70 seconds to allow gov proposal to pass
```

## Running Tests

To create a fresh test environment run `make reset-simd`. After this you can run `make start-simd` which will start a simd environment designed for basic testing of `breaker` and running unit tests.
After the above commands have completed you may now now run all unit tests:

```shell
$> make reset-simd
$> make start-simd
# open new terminal window
$> make test
$> make test
```
61 changes: 47 additions & 14 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"fmt"
"net/http"

"github.com/go-chi/chi/v5"
Expand All @@ -20,16 +21,16 @@ type API struct {
jwt *JWT
breakerClient *breakerclient.BreakerClient
addr string
// if true, do not invoke any cosmos transactions
dryRun bool
// used to block closure until api is shutdown
doneCh chan struct{}
}

// Options used to configured the API Server
type ApiOpts struct {
ListenAddress string
Password string
IdentifierField string
TokenValidityDurationSeconds int64
DryRun bool
}

// Prepares the http api server
Expand All @@ -38,13 +39,28 @@ func NewAPI(
log *zap.Logger,
jwt *JWT,
opts ApiOpts,
bc *breakerclient.BreakerClient,
) (*API, error) {
ctx, cancel := context.WithCancel(ctx)
logger := log.Named("breaker.api")
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(NewLoggerMiddleware(logger))
api := API{ctx: ctx, cancel: cancel, router: r, jwt: NewJWT(opts.Password, opts.IdentifierField, opts.TokenValidityDurationSeconds), addr: opts.ListenAddress, logger: logger, dryRun: opts.DryRun}

api := API{
ctx: ctx,
cancel: cancel,
router: chi.NewRouter(),
jwt: NewJWT(
opts.Password,
opts.IdentifierField,
opts.TokenValidityDurationSeconds,
),
addr: opts.ListenAddress,
logger: log.Named("breaker.api"),
breakerClient: bc,
doneCh: make(chan struct{}, 1),
}

// initialize router
api.router.Use(middleware.RequestID)
api.router.Use(NewLoggerMiddleware(api.logger))
api.router.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
// authenticated urls
Expand All @@ -54,21 +70,35 @@ func NewAPI(
})
r.Group(func(r chi.Router) {
// unauthenticated urls
r.Get("/status/listDisabledCommands", api.ListDisabledCommands)
r.Get("/status/accounts", api.ListAccounts)
r.Route("/status", func(r chi.Router) {
r.Route("/list", func(r chi.Router) {
r.Get("/disabledCommands", api.ListDisabledCommands)
r.Get("/accounts", api.ListAccounts)
})
})
})
})

return &api, nil
}

// Sets the breakerClient field, needed for non dry-run webhook calls, as well as the status calls.
func (api *API) WithBreakerClient(client *breakerclient.BreakerClient) {
api.breakerClient = client
// Configures the breakerclient such that it may be used by the API for signing transactions.
// This should be called against breakerclient.BreakerClient before passing it as a parameter during api initialization
func ConfigBreakerClient(
client *breakerclient.BreakerClient,
keyName string,
) error {
if err := client.SetFromAddress(); err != nil {
return fmt.Errorf("failed to initialize from address %s", err)
}
client.UpdateClientFromName(keyName)
return nil
}

// Cancels the api context, triggering a shutdown of the api router.
func (api *API) Close() {
api.cancel()
<-api.doneCh
}

// Blocking call that starts a http server exposing the api.
Expand All @@ -78,6 +108,7 @@ func (api *API) Serve() error {
Handler: api.router,
}
errCh := make(chan error, 1)
api.logger.Info("starting api")
go func() {
errCh <- server.ListenAndServe()
}()
Expand All @@ -86,7 +117,9 @@ func (api *API) Serve() error {
case err := <-errCh:
return err
case <-api.ctx.Done():
return server.Close()
err := server.Shutdown(api.ctx)
api.doneCh <- struct{}{}
return err
}
}
}
135 changes: 135 additions & 0 deletions api/api_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package api

import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"

"cosmossdk.io/x/circuit/types"
)

type APIClient struct {
hc *http.Client
url string
jwt string
}

// Returns a new client for usage with the breaker api.
// Requires providing a valid JWT that has been issued, which can be done via the cli
//
// NOTE: JWT is not required for the `/status` api calls
//
// TODO: add a way of acquiring/renewing JWT via api
func NewAPIClient(url string, jwt string) APIClient {
return APIClient{
hc: http.DefaultClient,
url: url,
jwt: jwt,
}
}

// Returns all commands which have had a circuit tripped
func (ac *APIClient) DisabledCommands() (*types.DisabledListResponse, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v1/status/list/disabledCommands", ac.url), &bytes.Buffer{})
if err != nil {
return nil, fmt.Errorf("failed to construct http request %s", err)
}
res, err := ac.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send http request %s", err)
}
var resp types.DisabledListResponse
data, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read http response body %s", err)
}
if err = resp.Unmarshal(data); err != nil {
return nil, fmt.Errorf("failed to deserialize http response body %s", err)
}
return &resp, nil
}

// Returns all accounts that have been granted some form of permission with the circuit breaker module
func (ac *APIClient) Accounts() (*types.AccountsResponse, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v1/status/list/accounts", ac.url), &bytes.Buffer{})
if err != nil {
return nil, fmt.Errorf("failed to construct http request %s", err)
}
res, err := ac.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send http request %s", err)
}
var resp types.AccountsResponse
data, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read http response body %s", err)
}
if err = resp.Unmarshal(data); err != nil {
return nil, fmt.Errorf("failed to deserialize http response body %s", err)
}
return &resp, nil
}

// Trips a circuit, preventing access to the given urls, emitting the `message` via system logs
func (ac *APIClient) TripCircuit(urls []string, message string) (*Response, error) {
payload := PayloadV1{
Urls: urls,
Message: message,
Operation: MODE_TRIP,
}
data, err := json.Marshal(&payload)
if err != nil {
return nil, fmt.Errorf("failed to serialize payload %s", err)
}
buffer := bytes.NewBuffer(data)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/v1/webhook", ac.url), buffer)
if err != nil {
return nil, fmt.Errorf("failed to construct http request %s", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer: %s", ac.jwt))
res, err := ac.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send http request %s", err)
}
return ac.unmarshalResponse(res.Body)
}

// Resets a circuit, allowing access to the given urls, emitting the `message` via system logs
func (ac *APIClient) ResetCircuit(urls []string, message string) (*Response, error) {
payload := PayloadV1{
Urls: urls,
Message: message,
Operation: MODE_RESET,
}
data, err := json.Marshal(&payload)
if err != nil {
return nil, fmt.Errorf("failed to serialize payload %s", err)
}
buffer := bytes.NewBuffer(data)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/v1/webhook", ac.url), buffer)
if err != nil {
return nil, fmt.Errorf("failed to construct http request %s", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer: %s", ac.jwt))
res, err := ac.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send http request %s", err)
}
return ac.unmarshalResponse(res.Body)
}

func (ac *APIClient) unmarshalResponse(body io.ReadCloser) (*Response, error) {
var resp Response

data, err := ioutil.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("failed to read http response body %s", err)
}
if err = json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to deserialize http response body %s", err)
}
return &resp, nil
}
Loading

0 comments on commit 69e05ea

Please sign in to comment.