graphql-engine/cli/cli.go
2018-08-06 17:03:17 +05:30

366 lines
11 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 (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/briandowns/spinner"
"github.com/hasura/graphql-engine/cli/version"
colorable "github.com/mattn/go-colorable"
homedir "github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
// Environment variable names recognised by the CLI.
const (
// ENV_ENDPOINT is the name of env var which indicates the Hasura GraphQL
// Engine endpoint URL.
ENV_ENDPOINT = "HASURA_GRAPHQL_ENDPOINT"
// ENV_ACCESS_KEY is the name of env var that has the access key for GraphQL
// Engine endpoint.
ENV_ACCESS_KEY = "HASURA_GRAPHQL_ACCESS_KEY"
)
// Other constants used in the package
const (
// Name of the global configuration directory
GLOBAL_CONFIG_DIR_NAME = ".hasura-graphql"
// Name of the global configuration file
GLOBAL_CONFIG_FILE_NAME = "config.json"
)
// HasuraGraphQLConfig has the config values required to contact the server.
type HasuraGraphQLConfig struct {
// Endpoint for the GraphQL Engine
Endpoint string `json:"endpoint"`
// AccessKey (optional) required to query the endpoint
AccessKey string `json:"access_key,omitempty"`
ParsedEndpoint *url.URL `json:"-"`
}
// ParseEndpoint ensures the endpoint is valid.
func (hgc *HasuraGraphQLConfig) ParseEndpoint() error {
nurl, err := url.Parse(hgc.Endpoint)
if err != nil {
return err
}
hgc.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
// 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 file where Hasura metadata is stored.
MetadataFile string
// Config is the configuration object storing the endpoint and access key
// information after reading from config file or env var.
Config *HasuraGraphQLConfig
// GlobalConfigDir is the ~/.hasura-graphql directory to store configuration
// globally.
GlobalConfigDir string
// GlobalConfigFile is the file inside GlobalConfigDir where values are
// stored.
GlobalConfigFile string
// 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
}
// 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
// set spinner
ec.setupSpinner()
// set logger
ec.setupLogger()
// populate version
ec.setVersion()
// setup global config directory
err := ec.setupGlobalConfigDir()
if err != nil {
return errors.Wrap(err, "setting up config directory failed")
}
// initialize a blank config
ec.Config = &HasuraGraphQLConfig{}
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 {
// prepare the context
err := ec.Prepare()
if err != nil {
return errors.Wrap(err, "failed preparing context")
}
// 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 = filepath.Join(ec.MigrationDir, "metadata.yaml")
// 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.Config.Endpoint)
ec.Logger.Debug("graphql engine access_key: ", ec.Config.AccessKey)
// get version from the server and match with the cli version
return ec.checkServerVersion()
}
func (ec *ExecutionContext) checkServerVersion() error {
v, err := version.FetchServerVersion(ec.Config.Endpoint)
if err != nil {
return errors.Wrap(err, "failed to get version from server")
}
ec.Version.SetServerVersion(v)
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.SetDefault("endpoint", "http://localhost:8080")
v.SetDefault("access_key", "")
v.SetEnvPrefix("HASURA_GRAPHQL")
v.AutomaticEnv()
v.SetConfigName("config")
v.AddConfigPath(ec.ExecutionDirectory)
err := v.ReadInConfig()
if err != nil {
return errors.Wrap(err, "cannor read config file")
}
ec.Config = &HasuraGraphQLConfig{
Endpoint: v.GetString("endpoint"),
AccessKey: v.GetString("access_key"),
}
err = ec.Config.ParseEndpoint()
return err
}
// 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) {
ec.Spinner.Stop()
ec.Spinner.Prefix = message
ec.Spinner.Start()
}
// setupLogger creates a default logger if context does not have one set.
func (ec *ExecutionContext) setupLogger() {
if ec.Logger == nil {
logger := logrus.New()
logger.Formatter = &logrus.TextFormatter{
ForceColors: true,
DisableTimestamp: true,
}
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)
}
}
// setupGlobalConfigDir ensures that global config directory exists and the
// paths are correctly set.
func (ec *ExecutionContext) setupGlobalConfigDir() error {
if len(ec.GlobalConfigDir) == 0 {
home, err := homedir.Dir()
if err != nil {
return errors.Wrap(err, "cannot get home directory")
}
globalConfigDir := filepath.Join(home, GLOBAL_CONFIG_DIR_NAME)
ec.GlobalConfigDir = globalConfigDir
}
err := os.MkdirAll(ec.GlobalConfigDir, os.ModePerm)
if err != nil {
return errors.Wrap(err, "cannot create config directory")
}
ec.GlobalConfigFile = filepath.Join(ec.GlobalConfigDir, GLOBAL_CONFIG_FILE_NAME)
return nil
}
// validateDirectory sets execution directory and validate it to see that or any
// of the parent directory is a valid project directory. A valid project
// directory contains the following:
// 1. migrations directory
// 2. config.yaml file
// 3. metadata.yaml (optional)
// If the current directory or any parent directory (upto filesystem root) is
// found to have these files, ExecutionDirectory is set as that directory.
func (ec *ExecutionContext) validateDirectory() error {
if len(ec.ExecutionDirectory) == 0 {
cwd, err := os.Getwd()
if err != nil {
return errors.Wrap(err, "error getting current working directory")
}
ec.ExecutionDirectory = cwd
}
ed, err := os.Stat(ec.ExecutionDirectory)
if err != nil {
if os.IsNotExist(err) {
return errors.Wrap(err, "did not find required directory. use 'init'?")
} else {
return errors.Wrap(err, "error getting directory details")
}
}
if !ed.IsDir() {
return errors.Errorf("'%s' is not a directory", ed.Name())
}
// config.yaml
// migrations/
// (optional) metadata.yaml
dir, err := recursivelyValidateDirectory(ec.ExecutionDirectory)
if err != nil {
return errors.Wrap(err, "validate")
}
ec.ExecutionDirectory = dir
return nil
}
// filesRequired are the files that are mandatory to qualify for a project
// directory.
var filesRequired = []string{
"config.yaml",
"migrations",
}
// recursivelyValidateDirectory tries to parse 'startFrom' as a project
// directory by checking for the 'filesRequired'. If the parent of 'startFrom'
// (nextDir) is filesystem root, error is returned. Otherwise, 'nextDir' is
// validated, recursively.
func recursivelyValidateDirectory(startFrom string) (validDir string, err error) {
err = validateDirectory(startFrom)
if err != nil {
nextDir := filepath.Dir(startFrom)
cleaned := filepath.Clean(nextDir)
isWindowsRoot, _ := regexp.MatchString(`^[a-zA-Z]:\\$`, cleaned)
// return error if filesystem boundary is hit
if cleaned == "/" || isWindowsRoot {
return nextDir, errors.Errorf("cannot find [%s] | search stopped at filesystem boundary", strings.Join(filesRequired, ", "))
}
return recursivelyValidateDirectory(nextDir)
}
return startFrom, nil
}
// validateDirectory tries to parse dir for the filesRequired and returns error
// if any one of them is missing.
func validateDirectory(dir string) error {
notFound := []string{}
for _, f := range filesRequired {
if _, err := os.Stat(filepath.Join(dir, f)); os.IsNotExist(err) {
relpath, e := filepath.Rel(dir, f)
if e == nil {
f = relpath
}
notFound = append(notFound, f)
}
}
if len(notFound) > 0 {
return errors.Errorf("cannot validate directory '%s': [%s] not found", dir, strings.Join(notFound, ", "))
}
return nil
}
// 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()
}
}