graphql-engine/cli/cli.go
Ashish Rao 1cea6bdc57 cli: use json logs on non-terminal envs (close #2770) (#3528)
Co-authored-by: Shahidh K Muhammed <shahidh@hasura.io>
2019-12-25 20:14:02 +05:30

435 lines
12 KiB
Go

// Package cli and it's sub packages implements the command line tool for Hasura
// GraphQL Engine. The CLI operates on a directory, denoted by
// "ExecutionDirectory" in the "ExecutionContext" struct.
//
// The ExecutionContext is passed to all the subcommands so that a singleton
// context is available for the execution. Logger and Spinner comes from the same
// context.
package cli
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"time"
"github.com/hasura/graphql-engine/cli/telemetry"
"github.com/hasura/graphql-engine/cli/util"
"github.com/briandowns/spinner"
"github.com/gofrs/uuid"
"github.com/hasura/graphql-engine/cli/version"
"github.com/mattn/go-colorable"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"golang.org/x/crypto/ssh/terminal"
)
// Other constants used in the package
const (
// Name of the global configuration directory
GlobalConfigDirName = ".hasura"
// Name of the global configuration file
GlobalConfigFileName = "config.json"
// Name of the file to store last update check time
LastUpdateCheckFileName = "last_update_check_at"
)
// String constants
const (
StrTelemetryNotice = `Help us improve Hasura! The cli collects anonymized usage stats which
allow us to keep improving Hasura at warp speed. To opt-out or read more,
visit https://docs.hasura.io/1.0/graphql/manual/guides/telemetry.html
`
)
// ServerConfig has the config values required to contact the server.
type ServerConfig struct {
// Endpoint for the GraphQL Engine
Endpoint string
// AdminSecret (optional) required to query the endpoint
AdminSecret string
ParsedEndpoint *url.URL
}
type rawServerConfig struct {
// Endpoint for the GraphQL Engine
Endpoint string `json:"endpoint"`
// AccessKey (deprecated) (optional) Admin secret key required to query the endpoint
AccessKey string `json:"access_key,omitempty"`
// AdminSecret (optional) Admin secret required to query the endpoint
AdminSecret string `json:"admin_secret,omitempty"`
ParsedEndpoint *url.URL `json:"-"`
}
func (r rawServerConfig) toServerConfig() ServerConfig {
s := r.AdminSecret
if s == "" {
s = r.AccessKey
}
return ServerConfig{
Endpoint: r.Endpoint,
AdminSecret: s,
ParsedEndpoint: r.ParsedEndpoint,
}
}
func (s ServerConfig) toRawServerConfig() rawServerConfig {
return rawServerConfig{
Endpoint: s.Endpoint,
AccessKey: "",
AdminSecret: s.AdminSecret,
ParsedEndpoint: s.ParsedEndpoint,
}
}
// MarshalJSON converts s to JSON
func (s ServerConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(s.toRawServerConfig())
}
// UnmarshalJSON converts b to struct s
func (s ServerConfig) UnmarshalJSON(b []byte) error {
var r rawServerConfig
err := json.Unmarshal(b, &r)
if err != nil {
return errors.Wrap(err, "unmarshal error")
}
sc := r.toServerConfig()
s.Endpoint = sc.Endpoint
s.AdminSecret = sc.AdminSecret
s.ParsedEndpoint = sc.ParsedEndpoint
return nil
}
// ParseEndpoint ensures the endpoint is valid.
func (s *ServerConfig) ParseEndpoint() error {
nurl, err := url.Parse(s.Endpoint)
if err != nil {
return err
}
s.ParsedEndpoint = nurl
return nil
}
// ExecutionContext contains various contextual information required by the cli
// at various points of it's execution. Values are filled in by the
// initializers and passed on to each command. Commands can also fill in values
// to be used further down the line.
type ExecutionContext struct {
// CMDName is the name of CMD (os.Args[0]). To be filled in later to
// correctly render example strings etc.
CMDName string
// ID is a unique ID for this Execution
ID string
// ServerUUID is the unique ID for the server this execution is contacting.
ServerUUID string
// Spinner is the global spinner object used to show progress across the cli.
Spinner *spinner.Spinner
// Logger is the global logger object to print logs.
Logger *logrus.Logger
// ExecutionDirectory is the directory in which command is being executed.
ExecutionDirectory string
// MigrationDir is the name of directory where migrations are stored.
MigrationDir string
// ConfigFile is the file where endpoint etc. are stored.
ConfigFile string
// MetadataFile (optional) is a yaml|json file where Hasura metadata is stored.
MetadataFile []string
// ServerConfig is the configuration object storing the endpoint and admin secret
// information after reading from config file or env var.
ServerConfig *ServerConfig
// GlobalConfigDir is the ~/.hasura-graphql directory to store configuration
// globally.
GlobalConfigDir string
// GlobalConfigFile is the file inside GlobalConfigDir where values are
// stored.
GlobalConfigFile string
// GlobalConfig holds all the configuration options.
GlobalConfig *GlobalConfig
// IsStableRelease indicates if the CLI release is stable or not.
IsStableRelease bool
// Version indicates the version object
Version *version.Version
// Viper indicates the viper object for the execution
Viper *viper.Viper
// LogLevel indicates the logrus default logging level
LogLevel string
// NoColor indicates if the outputs shouldn't be colorized
NoColor bool
// Telemetry collects the telemetry data throughout the execution
Telemetry *telemetry.Data
// LastUpdateCheckFile is the file where the timestamp of last update check is stored
LastUpdateCheckFile string
// SkipUpdateCheck will skip the auto update check if set to true
SkipUpdateCheck bool
// IsTerminal indicates whether the current session is a terminal or not
IsTerminal bool
}
// NewExecutionContext returns a new instance of execution context
func NewExecutionContext() *ExecutionContext {
ec := &ExecutionContext{}
ec.Telemetry = telemetry.BuildEvent()
ec.Telemetry.Version = version.BuildVersion
return ec
}
// Prepare as the name suggests, prepares the ExecutionContext ec by
// initializing most of the variables to sensible defaults, if it is not already
// set.
func (ec *ExecutionContext) Prepare() error {
// set the command name
cmdName := os.Args[0]
if len(cmdName) == 0 {
cmdName = "hasura"
}
ec.CMDName = cmdName
ec.IsTerminal = terminal.IsTerminal(int(os.Stdout.Fd()))
// set spinner
ec.setupSpinner()
// set logger
ec.setupLogger()
// populate version
ec.setVersion()
// setup global config
err := ec.setupGlobalConfig()
if err != nil {
return errors.Wrap(err, "setting up global config failed")
}
ec.LastUpdateCheckFile = filepath.Join(ec.GlobalConfigDir, LastUpdateCheckFileName)
// initialize a blank server config
if ec.ServerConfig == nil {
ec.ServerConfig = &ServerConfig{}
}
// generate an execution id
if ec.ID == "" {
id := "00000000-0000-0000-0000-000000000000"
u, err := uuid.NewV4()
if err == nil {
id = u.String()
} else {
ec.Logger.Debugf("generating uuid for execution ID failed, %v", err)
}
ec.ID = id
ec.Logger.Debugf("execution id: %v", ec.ID)
}
ec.Telemetry.ExecutionID = ec.ID
return nil
}
// Validate prepares the ExecutionContext ec and then validates the
// ExecutionDirectory to see if all the required files and directories are in
// place.
func (ec *ExecutionContext) Validate() error {
// validate execution directory
err := ec.validateDirectory()
if err != nil {
return errors.Wrap(err, "validating current directory failed")
}
// set names of files and directories
ec.MigrationDir = filepath.Join(ec.ExecutionDirectory, "migrations")
ec.ConfigFile = filepath.Join(ec.ExecutionDirectory, "config.yaml")
ec.MetadataFile = append(ec.MetadataFile, filepath.Join(ec.MigrationDir, "metadata.yaml"))
ec.MetadataFile = append(ec.MetadataFile, filepath.Join(ec.MigrationDir, "metadata.json"))
// read config and parse the values into Config
err = ec.readConfig()
if err != nil {
return errors.Wrap(err, "cannot read config")
}
ec.Logger.Debug("graphql engine endpoint: ", ec.ServerConfig.Endpoint)
ec.Logger.Debug("graphql engine admin_secret: ", ec.ServerConfig.AdminSecret)
// get version from the server and match with the cli version
err = ec.checkServerVersion()
if err != nil {
return errors.Wrap(err, "version check")
}
state := util.GetServerState(ec.ServerConfig.Endpoint, ec.ServerConfig.AdminSecret, ec.Version.ServerSemver, ec.Logger)
ec.ServerUUID = state.UUID
ec.Telemetry.ServerUUID = ec.ServerUUID
ec.Logger.Debugf("server: uuid: %s", ec.ServerUUID)
return nil
}
func (ec *ExecutionContext) checkServerVersion() error {
v, err := version.FetchServerVersion(ec.ServerConfig.Endpoint)
if err != nil {
return errors.Wrap(err, "failed to get version from server")
}
ec.Version.SetServerVersion(v)
ec.Telemetry.ServerVersion = ec.Version.GetServerVersion()
isCompatible, reason := ec.Version.CheckCLIServerCompatibility()
ec.Logger.Debugf("versions: cli: [%s] server: [%s]", ec.Version.GetCLIVersion(), ec.Version.GetServerVersion())
ec.Logger.Debugf("compatibility check: [%v] %v", isCompatible, reason)
if !isCompatible {
return errors.Errorf("[cli: %s] [server: %s] versions incompatible: %s", ec.Version.GetCLIVersion(), ec.Version.GetServerVersion(), reason)
}
return nil
}
// readConfig reads the configuration from config file, flags and env vars,
// through viper.
func (ec *ExecutionContext) readConfig() error {
// need to get existing viper because https://github.com/spf13/viper/issues/233
v := ec.Viper
v.SetEnvPrefix("HASURA_GRAPHQL")
v.AutomaticEnv()
v.SetConfigName("config")
v.SetDefault("endpoint", "http://localhost:8080")
v.SetDefault("admin_secret", "")
v.SetDefault("access_key", "")
v.AddConfigPath(ec.ExecutionDirectory)
err := v.ReadInConfig()
if err != nil {
return errors.Wrap(err, "cannot read config from file/env")
}
adminSecret := v.GetString("admin_secret")
if adminSecret == "" {
adminSecret = v.GetString("access_key")
}
ec.ServerConfig = &ServerConfig{
Endpoint: v.GetString("endpoint"),
AdminSecret: adminSecret,
}
return ec.ServerConfig.ParseEndpoint()
}
// setupSpinner creates a default spinner if the context does not already have
// one.
func (ec *ExecutionContext) setupSpinner() {
if ec.Spinner == nil {
spnr := spinner.New(spinner.CharSets[7], 100*time.Millisecond)
spnr.Writer = os.Stderr
ec.Spinner = spnr
}
}
// Spin stops any existing spinner and starts a new one with the given message.
func (ec *ExecutionContext) Spin(message string) {
if ec.IsTerminal {
ec.Spinner.Stop()
ec.Spinner.Prefix = message
ec.Spinner.Start()
} else {
ec.Logger.Println(message)
}
}
// setupLogger creates a default logger if context does not have one set.
func (ec *ExecutionContext) setupLogger() {
if ec.Logger == nil {
logger := logrus.New()
if ec.IsTerminal {
if ec.NoColor {
logger.Formatter = &logrus.TextFormatter{
DisableColors: true,
DisableTimestamp: true,
}
} else {
logger.Formatter = &logrus.TextFormatter{
ForceColors: true,
DisableTimestamp: true,
}
}
} else {
logger.Formatter = &logrus.JSONFormatter{
PrettyPrint: false,
}
}
logger.Out = colorable.NewColorableStdout()
ec.Logger = logger
}
if ec.LogLevel != "" {
level, err := logrus.ParseLevel(ec.LogLevel)
if err != nil {
ec.Logger.WithError(err).Error("error parsing log-level flag")
return
}
ec.Logger.SetLevel(level)
}
// set the logger for telemetry
if ec.Telemetry.Logger == nil {
ec.Telemetry.Logger = ec.Logger
}
}
// SetVersion sets the version inside context, according to the variable
// 'version' set during build context.
func (ec *ExecutionContext) setVersion() {
if ec.Version == nil {
ec.Version = version.New()
}
}
// GetMetadataFilePath returns the file path based on the format.
func (ec *ExecutionContext) GetMetadataFilePath(format string) (string, error) {
ext := fmt.Sprintf(".%s", format)
for _, filePath := range ec.MetadataFile {
switch p := filepath.Ext(filePath); p {
case ext:
return filePath, nil
}
}
return "", errors.New("unsupported file type")
}
// GetExistingMetadataFile returns the path to the default metadata file that
// also exists, json or yaml
func (ec *ExecutionContext) GetExistingMetadataFile() (string, error) {
filename := ""
for _, format := range []string{"yaml", "json"} {
f, err := ec.GetMetadataFilePath(format)
if err != nil {
return "", errors.Wrap(err, "cannot get metadata file")
}
filename = f
if _, err := os.Stat(filename); os.IsNotExist(err) {
continue
}
break
}
return filename, nil
}