featurebase/ctl/cli.go
Lory Cloutier b6ae088f24 FB-1766: cleaning up the CmdIO objects passing alternate stdin/
stdout/stderr around

A lot of functions in the cmd and ctl packages were passing these
around and barely using them. Replaced them with a logger for most
functions. Some functions get an io.Writer instead so that their
tests can find the output they're looking for.

More cleanup on fb-1766: reworked the tests that were using io.Pipe
or os.Pipe to check their results so they now use a bytes.Buffer.

Unexported some variables that didn't need to be exported.
Fixed NewConfigCommand to use the provided stderr, not os.Stderr.
Added tests for rbf_dump, rbf_page, and keygen, since those weren't
being tested at all.

Added chksum_test, final cleanup.

(cherry picked from commit f627199acb)
2022-12-12 09:01:20 -08:00

554 lines
13 KiB
Go

package ctl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/chzyer/readline"
featurebase "github.com/featurebasedb/featurebase/v3"
"github.com/jedib0t/go-pretty/table"
"github.com/jedib0t/go-pretty/text"
featurebase "github.com/molecula/featurebase/v3"
"github.com/molecula/featurebase/v3/dax"
queryerhttp "github.com/molecula/featurebase/v3/dax/queryer/http"
"github.com/molecula/featurebase/v3/fbcloud"
"github.com/molecula/featurebase/v3/logger"
"github.com/pkg/errors"
)
const (
promptBegin string = "fbsql> "
promptMid string = " -> "
terminationChar string = ";"
exitCommand string = "exit"
nullValue string = "NULL"
)
var (
splash string = fmt.Sprintf(`FeatureBase CLI (%s)
Type "exit" to quit.
`, featurebase.Version)
)
type CLICommand struct {
Host string `json:"host"`
Port string `json:"port"`
HistoryPath string `json:"history-path"`
// Cloud Auth
ClientID string `json:"client-id"`
Region string `json:"region"`
Email string `json:"email"`
Password string `json:"password"`
// commands holds the list of sql commands to be executed.
commands []string
OrganizationID string `json:"org-id"`
DatabaseID string `json:"db-id"`
queryer FBQueryer
}
func NewCLICommand(logdest logger.Logger) *CLICommand {
historyPath := ""
home, err := os.UserHomeDir()
if err != nil {
fmt.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err)
} else {
historyDir := filepath.Join(home, ".featurebase")
err := os.MkdirAll(historyDir, 0750)
if err != nil {
fmt.Printf("Creating directory for history: %v\n", err)
} else {
historyPath = filepath.Join(historyDir, "cli_history")
}
}
return &CLICommand{
Host: "localhost",
HistoryPath: historyPath,
OrganizationID: "",
DatabaseID: "",
}
}
// printQualifiers displays the currently set OrganizationID and DatabaseID.
func (cmd *CLICommand) printQualifiers() {
fmt.Printf(" Host: %s\n Org: %s\n DB: %s\n",
hostPort(cmd.Host, cmd.Port),
cmd.OrganizationID,
cmd.DatabaseID,
)
}
func (cmd *CLICommand) setupClient() error {
if strings.TrimSpace(cmd.Host) == "" {
return errors.Errorf("no host provided")
}
if !strings.HasPrefix(cmd.Host, "http") {
cmd.Host = "http://" + cmd.Host
}
typ, err := cmd.detectFBType()
if err != nil {
return errors.Wrap(err, "detecting FeatureBase deployment type")
}
switch typ {
case featurebaseTypeStandard:
fmt.Println("Detected standard deployment")
cmd.queryer = &standardQueryer{
Host: cmd.Host,
Port: cmd.Port,
}
case featurebaseTypeDAX:
fmt.Println("Detected dax deployment")
cmd.queryer = &daxQueryer{
Host: cmd.Host,
Port: cmd.Port,
}
case featurebaseTypeCloud:
fmt.Println("Detected cloud deployment")
cmd.queryer = &fbcloud.Queryer{
Host: cmd.Host,
ClientID: cmd.ClientID,
Region: cmd.Region,
Email: cmd.Email,
Password: cmd.Password,
}
default:
return errors.Errorf("unknown type: %s", typ)
}
return nil
}
type featurebaseType string
const (
featurebaseTypeStandard featurebaseType = "standard"
featurebaseTypeDAX featurebaseType = "dax"
featurebaseTypeCloud featurebaseType = "cloud"
)
func hostPort(host, port string) string {
if port == "" {
return host
}
return host + ":" + port
}
// detectFBType determines if we're talking to standalone FeatureBase
// or FeatureBase Cloud
func (cmd *CLICommand) detectFBType() (featurebaseType, error) {
type trial struct {
port string
health string
typ featurebaseType
}
// trials is populated with the url/endpoints to try in order to detect if a
// process is running there which can support the cli requests.
trials := []trial{}
if cmd.Port != "" {
trials = append(trials,
// dax
trial{
port: cmd.Port,
health: "/queryer/health",
typ: featurebaseTypeDAX,
},
// standard
trial{
port: cmd.Port,
health: "/status",
typ: featurebaseTypeStandard,
},
)
} else {
// Try default ports just in case.
trials = append(trials,
// dax
trial{
port: "8080",
health: "/queryer/health",
typ: featurebaseTypeDAX,
},
// standard
trial{
port: "10101",
health: "/status",
typ: featurebaseTypeStandard,
},
)
}
for _, trial := range trials {
url := hostPort(cmd.Host, trial.port) + trial.health
if resp, err := http.Get(url); err != nil {
continue
} else if resp.StatusCode/100 == 2 {
cmd.Port = trial.port
return trial.typ, nil
}
}
return featurebaseTypeCloud, nil
}
func (cmd *CLICommand) Run(ctx context.Context) error {
// Print the splash message.
fmt.Print(splash)
err := cmd.setupClient()
if err != nil {
return errors.Wrap(err, "setting up client")
}
cmd.printQualifiers()
rl, err := readline.NewEx(&readline.Config{
Prompt: promptBegin,
HistoryFile: cmd.HistoryPath,
HistoryLimit: 100000,
DisableAutoSaveHistory: true,
})
if err != nil {
return errors.Wrap(err, "getting readline")
}
defer rl.Close()
// partialCommand holds all input prior to receiving a termination
// character.
var partialCommand string
// inMidCommand indicates whether a partial command has been received and
// we're still waiting for a termination character.
var inMidCommand bool
for {
if inMidCommand {
rl.SetPrompt(promptMid)
} else {
rl.SetPrompt(promptBegin)
// Add some white space before each new prompt.
fmt.Println()
}
// Read user provided input.
line, err := rl.Readline()
if err != nil {
return errors.Wrap(err, "reading line")
}
if !inMidCommand {
// Handle the exit command.
if line == exitCommand || line == exitCommand+terminationChar {
break
}
}
// Look for a termination character;
parts := strings.Split(line, terminationChar)
// Length of 1 means a termination character was not received.
if len(parts) == 1 {
if parts[0] != "" {
partialCommand = appendCommand(partialCommand, parts[0])
inMidCommand = true
}
continue
}
for i, part := range parts {
partIsFinal := i == len(parts)-1
partIsBlank := part == ""
if partIsBlank && partIsFinal {
continue
}
if partIsBlank && !partIsFinal {
if inMidCommand {
cmd.commands = append(cmd.commands, strings.TrimSpace(partialCommand))
partialCommand = ""
inMidCommand = false
}
continue
}
if !partIsBlank && partIsFinal {
partialCommand = part
inMidCommand = true
continue
}
if !partIsBlank && !partIsFinal {
partialCommand = appendCommand(partialCommand, part)
cmd.commands = append(cmd.commands, strings.TrimSpace(partialCommand))
partialCommand = ""
inMidCommand = false
}
}
err = rl.SaveHistory(strings.Join(cmd.commands, "; ") + ";")
if err != nil {
fmt.Printf("Couldn't save history: %v\n", err)
}
if err := cmd.executeCommands(ctx); err != nil {
return errors.Wrap(err, "executing commands")
}
}
return nil
}
func appendCommand(orig string, part string) string {
if orig == "" {
return part
} else {
return orig + " " + part
}
}
type FBQueryer interface {
Query(org, db, sql string) (*featurebase.WireQueryResponse, error)
}
func (cmd *CLICommand) executeCommands(ctx context.Context) error {
// Clear out the buffered commands on any exit from this method.
defer func() {
cmd.commands = nil
}()
for _, sql := range cmd.commands {
// Handle non-sql commands (for example, SET commands).
if handled, err := cmd.handleIfNonSQLCommand(ctx, sql); err != nil {
return errors.Wrapf(err, "handling non-SQL command: %s", sql)
} else if handled {
continue
}
sqlResponse, err := cmd.queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql)
if err != nil {
fmt.Printf("making query: %v\n", err)
continue
}
err = writeOut(sqlResponse, os.Stdout)
if err != nil {
return errors.Wrap(err, "writing out response")
}
}
return nil
}
// handleIfNonSQLCommand will handle special case command like "SET ..." and
// "USE ...". If the sql command matches one of these conditions and is handled,
// the bool returned will be true;
func (cmd *CLICommand) handleIfNonSQLCommand(ctx context.Context, sql string) (bool, error) {
var handled bool
// Get the first token from the SQL:
parts := strings.Split(sql, " ")
if len(parts) < 1 {
return handled, nil
}
token := strings.ToUpper(parts[0])
// Supported:
// SET ORG acme
// SET DB db1
// USE db1
switch token {
case "SET":
handled = true
switch len(parts) {
case 1:
// This will fall through and just print the qualifiers.
case 3:
switch strings.ToUpper(parts[1]) {
case "HOST":
cmd.Host = parts[2]
case "ORG":
cmd.OrganizationID = parts[2]
case "DB":
cmd.DatabaseID = parts[2]
}
default:
return handled, errors.Errorf("SET command takes a name and a value (SET DB db1)")
}
case "USE":
handled = true
if len(parts) != 2 {
return handled, errors.Errorf("USE command takes a single value (USE db1)")
}
cmd.DatabaseID = parts[1]
default:
return handled, nil
}
cmd.printQualifiers()
return handled, nil
}
func writeWarnings(r *featurebase.WireQueryResponse, w io.Writer) error {
if len(r.Warnings) > 0 {
if _, err := w.Write([]byte("\n")); err != nil {
return errors.Wrapf(err, "writing warning: %s", r.Error)
}
for _, warning := range r.Warnings {
if _, err := w.Write([]byte("Warning: " + warning + "\n")); err != nil {
return errors.Wrapf(err, "writing warning: %s", r.Error)
}
}
}
return nil
}
func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error {
if r == nil {
return errors.New("attempt to write out nil response")
}
if r.Error != "" {
if _, err := w.Write([]byte("Error: " + r.Error + "\n")); err != nil {
return errors.Wrapf(err, "writing error: %s", r.Error)
}
return writeWarnings(r, w)
}
t := table.NewWriter()
t.SetOutputMirror(w)
// Don't uppercase the header values.
t.Style().Format.Header = text.FormatDefault
t.AppendHeader(schemaToRow(r.Schema))
for _, row := range r.Data {
// If the value is nil, replace it with a null string; go-pretty doesn't
// expect nil pointers in the data values.
for i := range row {
if row[i] == nil {
row[i] = nullValue
}
}
t.AppendRow(table.Row(row))
}
t.Render()
err := writeWarnings(r, w)
if err != nil {
return err
}
lifeAffirmingMessage := ""
if r.ExecutionTime < 1000000 {
lifeAffirmingMessage = " (You're welcome! 🚀)"
}
if r.ExecutionTime > 5000000 {
lifeAffirmingMessage = " (Sorry! That took longer than expected 😭)"
}
if _, err := w.Write([]byte(fmt.Sprintf("\nExecution time: %dμs%s\n", r.ExecutionTime, lifeAffirmingMessage))); err != nil {
return errors.Wrapf(err, "writing execution time: %s", r.Error)
}
return nil
}
func schemaToRow(schema featurebase.WireQuerySchema) []interface{} {
ret := make([]interface{}, len(schema.Fields))
for i, field := range schema.Fields {
ret[i] = field.Name
}
return ret
}
// Ensure type implements interface.
var _ FBQueryer = (*standardQueryer)(nil)
// standardQueryer supports a standard featurebase deployment hitting the /sql
// endpoint with a payload containing only the sql statement.
type standardQueryer struct {
Host string
Port string
}
func (qryr *standardQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) {
buf := bytes.Buffer{}
url := fmt.Sprintf("%s/sql", hostPort(qryr.Host, qryr.Port))
buf.Write([]byte(sql))
resp, err := http.Post(url, "application/json", &buf)
if err != nil {
return nil, errors.Wrapf(err, "posting query")
}
fullbod, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "reading response")
}
sqlResponse := &featurebase.WireQueryResponse{}
// TODO(tlt): switch this back once all responses are typed
// if err := json.Unmarshal(fullbod, sqlResponse); err != nil {
if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil {
return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod)
}
return sqlResponse, nil
}
// Ensure type implements interface.
var _ FBQueryer = (*daxQueryer)(nil)
// daxQueryer is similar to the standardQueryer except that it hits a different
// endpoint, and its payload is a json object which includes, in addition to the
// sql statement, things like org and db.
type daxQueryer struct {
Host string
Port string
}
func (qryr *daxQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) {
buf := bytes.Buffer{}
url := fmt.Sprintf("%s/queryer/sql", hostPort(qryr.Host, qryr.Port))
sqlReq := &queryerhttp.SQLRequest{
OrganizationID: dax.OrganizationID(org),
DatabaseID: dax.DatabaseID(db),
SQL: sql,
}
if err := json.NewEncoder(&buf).Encode(sqlReq); err != nil {
return nil, errors.Wrapf(err, "encoding sql request: %s", sql)
}
resp, err := http.Post(url, "application/json", &buf)
if err != nil {
return nil, errors.Wrapf(err, "posting query")
}
fullbod, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "reading response")
}
sqlResponse := &featurebase.WireQueryResponse{}
// TODO(tlt): switch this back once all responses are typed
// if err := json.Unmarshal(fullbod, sqlResponse); err != nil {
if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil {
return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod)
}
return sqlResponse, nil
}