graphql-engine/cli/cli.go
Aravind K P a0c5e1669b cli: support ee-lite in CLI console
Closes https://hasurahq.atlassian.net/browse/PLAT-351

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7256
Co-authored-by: Sooraj <8408875+soorajshankar@users.noreply.github.com>
Co-authored-by: Manas Agarwal <5352361+manasag@users.noreply.github.com>
GitOrigin-RevId: 5096e76f11ab2860fae9fa7cd71c9afaead20cc0
2023-01-30 09:28:28 +00:00

1040 lines
34 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 (
"bytes"
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"io/fs"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/pgdump"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/v1graphql"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/v1version"
"github.com/hasura/graphql-engine/cli/v2/migrate/database/hasuradb"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/v1metadata"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/v1query"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/v2query"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura/commonmetadata"
"github.com/hasura/graphql-engine/cli/v2/internal/errors"
"github.com/hasura/graphql-engine/cli/v2/internal/httpc"
"github.com/hasura/graphql-engine/cli/v2/internal/statestore/settings"
"github.com/hasura/graphql-engine/cli/v2/internal/statestore/migrations"
"github.com/hasura/graphql-engine/cli/v2/internal/statestore"
"github.com/hasura/graphql-engine/cli/v2/internal/hasura"
"github.com/Masterminds/semver"
"github.com/briandowns/spinner"
"github.com/cockroachdb/redact"
"github.com/gofrs/uuid"
"github.com/hasura/graphql-engine/cli/v2/internal/metadataobject/actions/types"
"github.com/hasura/graphql-engine/cli/v2/plugins"
"github.com/hasura/graphql-engine/cli/v2/telemetry"
"github.com/hasura/graphql-engine/cli/v2/util"
"github.com/hasura/graphql-engine/cli/v2/version"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/subosito/gotenv"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
// 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"
DefaultMigrationsDirectory = "migrations"
DefaultMetadataDirectory = "metadata"
DefaultSeedsDirectory = "seeds"
)
const (
XHasuraAdminSecret = "X-Hasura-Admin-Secret"
XHasuraAccessKey = "X-Hasura-Access-Key"
)
const (
TelemetryNotice = `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://hasura.io/docs/latest/graphql/core/guides/telemetry.html
`
)
// ConfigVersion defines the version of the Config.
type ConfigVersion int
const (
// V1 represents config version 1
V1 ConfigVersion = iota + 1
// V2 represents config version 2
V2
V3
)
type MetadataMode int
const (
MetadataModeDirectory MetadataMode = iota
MetadataModeJSON
MetadataModeYAML
)
// ServerAPIPaths has the custom paths defined for server api
type ServerAPIPaths struct {
V1Query string `yaml:"v1_query,omitempty"`
V2Query string `yaml:"v2_query,omitempty"`
V1Metadata string `yaml:"v1_metadata,omitempty"`
GraphQL string `yaml:"graphql,omitempty"`
Config string `yaml:"config,omitempty"`
PGDump string `yaml:"pg_dump,omitempty"`
Version string `yaml:"version,omitempty"`
}
// GetQueryParams - encodes the values in url
func (s ServerAPIPaths) GetQueryParams() url.Values {
vals := url.Values{}
t := reflect.TypeOf(s)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("yaml")
splitTag := strings.Split(tag, ",")
if len(splitTag) == 0 {
continue
}
name := splitTag[0]
if name == "-" {
continue
}
v := reflect.ValueOf(s).Field(i)
vals.Add(name, v.String())
}
return vals
}
// ErrInvalidConfigVersion - if the config version is not valid
var ErrInvalidConfigVersion error = fmt.Errorf("invalid config version")
// NewConfigVersionValue returns ConfigVersion set with default value
func NewConfigVersionValue(val ConfigVersion, p *ConfigVersion) *ConfigVersion {
*p = val
return p
}
// Set sets the value of the named command-line flag.
func (c *ConfigVersion) Set(s string) error {
var op errors.Op = "cli.ConfigVersion.Set"
v, err := strconv.ParseInt(s, 0, 64)
*c = ConfigVersion(v)
if err != nil {
return errors.E(op, err)
}
if !c.IsValid() {
return errors.E(op, ErrInvalidConfigVersion)
}
return nil
}
// Type returns a string that uniquely represents this flag's type.
func (c *ConfigVersion) Type() string {
return "int"
}
func (c *ConfigVersion) String() string {
return strconv.Itoa(int(*c))
}
// IsValid returns if its a valid config version
func (c ConfigVersion) IsValid() bool {
return c != 0 && c <= V3
}
// ServerConfig has the config values required to contact the server
type ServerConfig struct {
// Endpoint for the GraphQL Engine
Endpoint string `yaml:"endpoint"`
// AccessKey (deprecated) (optional) Admin secret key required to query the endpoint
AccessKey string `yaml:"access_key,omitempty"`
// AdminSecret (optional) Admin secret required to query the endpoint
AdminSecret string `yaml:"admin_secret,omitempty"`
// Config option to allow specifying multiple admin secrets
// https://hasura.io/docs/latest/graphql/cloud/security/multiple-admin-secrets/
AdminSecrets []string `yaml:"admin_secrets,omitempty"`
// APIPaths (optional) API paths for server
APIPaths *ServerAPIPaths `yaml:"api_paths,omitempty"`
// InsecureSkipTLSVerify - indicates if TLS verification is disabled or not.
InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify,omitempty"`
// CAPath - Path to a cert file for the certificate authority
CAPath string `yaml:"certificate_authority,omitempty"`
ParsedEndpoint *url.URL `yaml:"-"`
HTTPClient *httpc.Client `yaml:"-"`
HasuraServerInternalConfig HasuraServerInternalConfig `yaml:"-"`
}
func (c *ServerConfig) GetAdminSecret() string {
// when HGE is configured with an admin secret, all API requests to HGE should be
// authenticated using a x-hasura-admin-secret header.
// admin secrets can be configured with two environment variables
// - HASURA_GRAPHQL_ADMIN_SECRET (ref: https://hasura.io/docs/latest/graphql/core/deployment/deployment-guides/docker/#docker-secure)
// - HASURA_GRAPHQL_ADMIN_SECRETS (ref: https://hasura.io/docs/latest/graphql/cloud/security/multiple-admin-secrets/)
// the environment variable HASURA_GRAPHQL_ADMIN_SECRETS takes precedence when set
if len(c.AdminSecrets) > 0 {
// when HASURA_GRAPHQL_ADMIN_SECRETS environment variable is set, use the first available admin secret as the value of the header
return c.AdminSecrets[0]
} else if c.AdminSecret != "" {
return c.AdminSecret
}
return ""
}
func (c *ServerConfig) GetHasuraInternalServerConfig(client *httpc.Client) error {
var op errors.Op = "cli.ServerConfig.GetHasuraInternalServerConfig"
// Determine from where assets should be served
url := c.getConfigEndpoint()
ctx, cancelFunc := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelFunc()
req, err := client.NewRequest("GET", url, nil)
if err != nil {
return errors.E(op, fmt.Errorf("error fetching config from server: %w", err))
}
r, err := client.Do(ctx, req, &c.HasuraServerInternalConfig)
if err != nil {
return errors.E(op, errors.KindNetwork, err)
}
defer r.Body.Close()
if r.StatusCode != http.StatusOK {
var horror hasuradb.HasuraError
err := json.NewDecoder(r.Body).Decode(&horror)
if err != nil {
return errors.E(op, errors.KindHasuraAPI, fmt.Errorf("error unmarshalling fetching server config"))
}
return errors.E(op, errors.KindHasuraAPI, fmt.Errorf("error fetching server config: %v", horror.Error()))
}
return nil
}
// HasuraServerConfig is the type returned by the v1alpha1/config API
// TODO: Move this type to a client implementation for hasura
type HasuraServerInternalConfig struct {
ConsoleAssetsDir string `json:"console_assets_dir"`
}
// GetVersionEndpoint provides the url to contact the version API
func (c *ServerConfig) GetVersionEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.Version)
return nurl.String()
}
// GetQueryEndpoint provides the url to contact the query API
func (c *ServerConfig) GetV1QueryEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.V1Query)
return nurl.String()
}
func (c *ServerConfig) GetV2QueryEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.V2Query)
return nurl.String()
}
func (c *ServerConfig) GetPGDumpEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.PGDump)
return nurl.String()
}
func (c *ServerConfig) GetV1GraphqlEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.GraphQL)
return nurl.String()
}
// GetQueryEndpoint provides the url to contact the query API
func (c *ServerConfig) GetV1MetadataEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.V1Metadata)
return nurl.String()
}
// GetVersionEndpoint provides the url to contact the config API
func (c *ServerConfig) getConfigEndpoint() string {
nurl := *c.ParsedEndpoint
nurl.Path = path.Join(nurl.Path, c.APIPaths.Config)
return nurl.String()
}
// ParseEndpoint ensures the endpoint is valid.
func (c *ServerConfig) ParseEndpoint() error {
var op errors.Op = "cli.ServerConfig.ParseEndpoint"
nurl, err := url.ParseRequestURI(c.Endpoint)
if err != nil {
return errors.E(op, err)
}
c.ParsedEndpoint = nurl
return nil
}
// Config represents configuration required for the CLI to function
type Config struct {
// Version of the config.
Version ConfigVersion `yaml:"version,omitempty"`
// DisableInteractive disables interactive prompt
DisableInteractive bool `yaml:"disable_interactive,omitempty"`
// ServerConfig to be used by CLI to contact server.
ServerConfig `yaml:",inline"`
// MetadataDirectory defines the directory where the metadata files were stored.
MetadataDirectory string `yaml:"metadata_directory,omitempty"`
// MetadataFile defines the path in which a JSON/YAML metadata file should be stored
MetadataFile string `yaml:"metadata_file,omitempty"`
// MigrationsDirectory defines the directory where the migration files were stored.
MigrationsDirectory string `yaml:"migrations_directory,omitempty"`
// SeedsDirectory defines the directory where seed files will be stored
SeedsDirectory string `yaml:"seeds_directory,omitempty"`
// ActionConfig defines the config required to create or generate codegen for an action.
ActionConfig *types.ActionExecutionConfig `yaml:"actions,omitempty"`
}
// 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
Stderr, Stdout io.Writer
// 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
// Envfile is the .env file to load ENV vars from
Envfile string
// MigrationDir is the name of directory where migrations are stored.
MigrationDir string
// MetadataDir is the name of directory where metadata files are stored.
MetadataDir string
// MetadataFile is the name of json/yaml file where metadata will be stored
MetadataFile string
// Seed directory -- directory in which seed files are to be stored
SeedsDirectory string
// ConfigFile is the file where endpoint etc. are stored.
ConfigFile string
// HGE Headers, are the custom headers which can be passed to HGE API
HGEHeaders map[string]string
// Config is the configuration object storing the endpoint and admin secret
// information after reading from config file or env var.
Config *Config
// 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
// PluginsConfig defines the config for plugins
PluginsConfig *plugins.Config
// CodegenAssetsRepo defines the config to handle codegen-assets repo
CodegenAssetsRepo *util.GitUtil
// InitTemplatesRepo defines the config to handle init-templates repo
InitTemplatesRepo *util.GitUtil
// IsTerminal indicates whether the current session is a terminal or not
IsTerminal bool
// instance of API client which communicates with Hasura API
APIClient *hasura.Client
// current database on which operation is being done
Source Source
HasMetadataV3 bool
// AllDatabases should be taken only incase if database isn't mentioned
AllDatabases bool
// after a `scripts update-config-v3` all migrate commands will try to automatically
// move cli state from hdb_catalog.* tables to catalog state if that hasn't happened
// already this configuration option will disable this step
// more details in: https://github.com/hasura/graphql-engine/issues/6861
DisableAutoStateMigration bool
// CliExtDestinationDir is the directory path that will be used to setup cli-ext
CliExtDestinationDir string
// CliExtDestinationBinPath is the full path of the cli-ext binary
CliExtDestinationBinPath string
// CLIExtSourceBinPath is the full path to a copy of cli-ext binary in the local file system
CliExtSourceBinPath string
// proPluginVersionValidated is used to avoid validating pro plugin multiple times
// while preparing the execution context
proPluginVersionValidated bool
MetadataMode MetadataMode
// Any request headers that has to be sent with every HTTP request that CLI sends to HGE
requestHeaders map[string]string
}
func (ec *ExecutionContext) AddRequestHeaders(headers map[string]string) {
if ec.requestHeaders == nil {
ec.requestHeaders = map[string]string{}
}
for k, v := range headers {
ec.requestHeaders[k] = v
}
}
type Source struct {
Name string
Kind hasura.SourceKind
}
// NewExecutionContext returns a new instance of execution context
func NewExecutionContext() *ExecutionContext {
ec := &ExecutionContext{
Stderr: os.Stderr,
Stdout: os.Stdout,
}
ec.MetadataMode = MetadataModeDirectory
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 {
var op errors.Op = "cli.ExecutionContext.Prepare"
// set the command name
cmdName := os.Args[0]
if len(cmdName) == 0 {
cmdName = "hasura"
}
ec.CMDName = cmdName
ec.IsTerminal = term.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.E(op, fmt.Errorf("setting up global config failed: %w", err))
}
if !ec.proPluginVersionValidated {
ec.validateProPluginVersion()
ec.proPluginVersionValidated = true
}
ec.LastUpdateCheckFile = filepath.Join(ec.GlobalConfigDir, LastUpdateCheckFileName)
// initialize a blank server config
if ec.Config == nil {
ec.Config = &Config{}
}
// 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
}
// SetupPlugins create and returns the inferred paths for hasura. By default, it assumes
// $HOME/.hasura as the base path
func (ec *ExecutionContext) SetupPlugins() error {
var op errors.Op = "cli.ExecutionContext.SetupPlugins"
base := filepath.Join(ec.GlobalConfigDir, "plugins")
base, err := filepath.Abs(base)
if err != nil {
return errors.E(op, fmt.Errorf("cannot get absolute path: %w", err))
}
ec.PluginsConfig = plugins.New(base)
ec.PluginsConfig.Logger = ec.Logger
ec.PluginsConfig.Repo.Logger = ec.Logger
if ec.GlobalConfig.CLIEnvironment == ServerOnDockerEnvironment {
ec.PluginsConfig.Repo.DisableCloneOrUpdate = true
}
err = ec.PluginsConfig.Prepare()
if err != nil {
return errors.E(op, err)
}
return nil
}
func (ec *ExecutionContext) validateProPluginVersion() {
if err := ec.SetupPlugins(); err != nil {
ec.Logger.Debugf("Validating installed pro plugin version failed: %v", err)
return
}
installedPlugins, err := ec.PluginsConfig.ListInstalledPlugins()
if err != nil {
return
}
proPluginVersion := installedPlugins["pro"]
cliVersion := ec.Version.GetCLIVersion()
proPluginSemVer, _ := semver.NewVersion(proPluginVersion)
cliSemVer := ec.Version.CLISemver
if proPluginSemVer == nil || cliSemVer == nil {
return
}
if cliSemVer.Major() != proPluginSemVer.Major() {
ec.Logger.Warnf("[cli: %s] [pro plugin: %s] incompatible version of cli and pro plugin.", cliVersion, proPluginVersion)
ec.Logger.Warn("Try running `hasura plugins upgrade pro` or `hasura plugins install pro --version <version>`")
}
}
func (ec *ExecutionContext) SetupCodegenAssetsRepo() error {
var op errors.Op = "cli.ExecutionContext.SetupCodegenAssetsRepo"
base := filepath.Join(ec.GlobalConfigDir, util.ActionsCodegenDirName)
base, err := filepath.Abs(base)
if err != nil {
return errors.E(op, fmt.Errorf("cannot get absolute path: %w", err))
}
ec.CodegenAssetsRepo = util.NewGitUtil(util.ActionsCodegenRepoURI, base, "")
ec.CodegenAssetsRepo.Logger = ec.Logger
if ec.GlobalConfig.CLIEnvironment == ServerOnDockerEnvironment {
ec.CodegenAssetsRepo.DisableCloneOrUpdate = true
}
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 {
var op errors.Op = "cli.ExecutionContext.Validate"
// validate execution directory
err := ec.validateDirectory()
if err != nil {
return errors.E(op, fmt.Errorf("validating current directory failed: %w", err))
}
// load .env file
err = ec.loadEnvfile()
if err != nil {
return errors.E(op, fmt.Errorf("loading .env file failed: %w", err))
}
// set names of config file
ec.ConfigFile = filepath.Join(ec.ExecutionDirectory, "config.yaml")
// read config and parse the values into Config
err = ec.readConfig()
if err != nil {
return errors.E(op, fmt.Errorf("cannot read config: %w", err))
}
// initialize HTTP client
// CLI uses a common http client defined in internal/httpc.Client
// get TLS Config
tlsConfig, err := httpc.GenerateTLSConfig(ec.Config.CAPath, ec.Config.InsecureSkipTLSVerify)
if err != nil || tlsConfig == nil {
return errors.E(op, fmt.Errorf("error while getting TLS config"))
}
// create a net/http.Client with TLS Config
standardHttpClient, err := httpc.NewHttpClientWithTLSConfig(tlsConfig)
if err != nil || standardHttpClient == nil {
return errors.E(op, fmt.Errorf("error while creating http client with TLS configuration %w", err))
}
// create httpc.Client
httpClient, err := httpc.New(standardHttpClient, ec.Config.Endpoint, ec.HGEHeaders)
if err != nil || httpClient == nil {
return errors.E(op, err)
}
ec.Config.HTTPClient = httpClient
err = util.GetServerStatus(ec.Config.GetVersionEndpoint(), ec.Config.HTTPClient)
if err != nil {
ec.Logger.Error("connecting to graphql-engine server failed")
ec.Logger.Info("possible reasons:")
ec.Logger.Info("1) Provided root endpoint of graphql-engine server is wrong. Verify endpoint key in config.yaml or/and value of --endpoint flag")
ec.Logger.Info("2) Endpoint should NOT be your GraphQL API, ie endpoint is NOT https://hasura-cloud-app.io/v1/graphql it should be: https://hasura-cloud-app.io")
ec.Logger.Info("3) Server might be unhealthy and is not running/accepting API requests")
ec.Logger.Info("4) Admin secret is not correct/set")
ec.Logger.Infoln()
return errors.E(op, err)
}
// get version from the server and match with the cli version
err = ec.checkServerVersion()
if err != nil {
return errors.E(op, fmt.Errorf("version check: %w", err))
}
// get the server feature flags
err = ec.Version.GetServerFeatureFlags()
if err != nil {
return errors.E(op, fmt.Errorf("error in getting server feature flags %w", err))
}
ec.AddRequestHeaders(map[string]string{GetAdminSecretHeaderName(ec.Version): ec.Config.GetAdminSecret()})
ec.Config.HTTPClient.SetHeaders(ec.requestHeaders)
// this populates the ec.Config.ServerConfig.HasuraServerInternalConfig
err = ec.Config.ServerConfig.GetHasuraInternalServerConfig(httpClient)
if err != nil {
// If config API is not enabled log it and don't fail
ec.Logger.Debugf("cannot get config information from server, this might be because config API is not enabled: %v", err)
}
// set name of migration directory
ec.MigrationDir = filepath.Join(ec.ExecutionDirectory, ec.Config.MigrationsDirectory)
if _, err := os.Stat(ec.MigrationDir); stderrors.Is(err, fs.ErrNotExist) {
err = os.MkdirAll(ec.MigrationDir, os.ModePerm)
if err != nil {
return errors.E(op, fmt.Errorf("cannot create migrations directory: %w", err))
}
}
ec.SeedsDirectory = filepath.Join(ec.ExecutionDirectory, ec.Config.SeedsDirectory)
if _, err := os.Stat(ec.SeedsDirectory); stderrors.Is(err, fs.ErrNotExist) {
err = os.MkdirAll(ec.SeedsDirectory, os.ModePerm)
if err != nil {
return errors.E(op, fmt.Errorf("cannot create seeds directory: %w", err))
}
}
if ec.Config.Version >= V2 && ec.Config.MetadataDirectory != "" {
if len(ec.Config.MetadataFile) > 0 {
ec.MetadataFile = filepath.Join(ec.ExecutionDirectory, ec.Config.MetadataFile)
if _, err := os.Stat(ec.MetadataFile); stderrors.Is(err, fs.ErrNotExist) {
if err := ioutil.WriteFile(ec.MetadataFile, []byte(""), os.ModePerm); err != nil {
return errors.E(op, err)
}
}
switch filepath.Ext(ec.MetadataFile) {
case ".json":
ec.MetadataMode = MetadataModeJSON
case ".yaml":
ec.MetadataMode = MetadataModeYAML
default:
return errors.E(op, fmt.Errorf("unrecogonized file extension. only .json/.yaml files are allowed for value of metadata_file"))
}
}
// set name of metadata directory
ec.MetadataDir = filepath.Join(ec.ExecutionDirectory, ec.Config.MetadataDirectory)
if _, err := os.Stat(ec.MetadataDir); stderrors.Is(err, fs.ErrNotExist) && !(len(ec.MetadataFile) > 0) {
err = os.MkdirAll(ec.MetadataDir, os.ModePerm)
if err != nil {
return errors.E(op, fmt.Errorf("cannot create metadata directory: %w", err))
}
}
}
ec.Logger.Debug("graphql engine endpoint: ", ec.Config.ServerConfig.Endpoint)
ec.Logger.Debug(redact.Sprintf("graphql engine admin_secret: %s", ec.Config.ServerConfig.AdminSecret).Redact())
uri, err := url.Parse(ec.Config.Endpoint)
if err != nil {
return errors.E(op, fmt.Errorf("error while parsing the endpoint :%w", err))
}
// check if server is using metadata v3
if ec.Config.APIPaths.V1Query != "" {
uri.Path = path.Join(uri.Path, ec.Config.APIPaths.V1Query)
} else {
uri.Path = path.Join(uri.Path, "v1/query")
}
requestUri := uri.String()
metadata, err := commonmetadata.New(httpClient, requestUri).ExportMetadata()
if err != nil {
return errors.E(op, err)
}
var v struct {
Version int `json:"version"`
}
if err := json.NewDecoder(metadata).Decode(&v); err != nil {
return errors.E(op, err)
}
if v.Version == 3 {
ec.HasMetadataV3 = true
}
if ec.Config.Version >= V3 && !ec.HasMetadataV3 {
return errors.E(op, fmt.Errorf(`config v3 can only be used with servers having metadata version >= 3
You could fix this problem by taking one of the following actions:
1. Upgrade your Hasura server to a newer version (>= v2.0.0) ie upgrade to a version which supports metadata v3
2. Force CLI to use an older config version via the --version <VERSION> flag`))
}
ec.APIClient = &hasura.Client{
V1Metadata: v1metadata.New(httpClient, ec.Config.GetV1MetadataEndpoint()),
V1Query: v1query.New(httpClient, ec.Config.GetV1QueryEndpoint()),
V2Query: v2query.New(httpClient, ec.Config.GetV2QueryEndpoint()),
PGDump: pgdump.New(httpClient, ec.Config.GetPGDumpEndpoint()),
V1Graphql: v1graphql.New(httpClient, ec.Config.GetV1GraphqlEndpoint()),
V1Version: v1version.New(httpClient, ec.Config.GetVersionEndpoint()),
}
var state *util.ServerState
if ec.HasMetadataV3 {
state = util.GetServerState(httpClient, ec.Config.GetV1MetadataEndpoint(), ec.HasMetadataV3, ec.Logger)
} else {
state = util.GetServerState(httpClient, ec.Config.GetV1QueryEndpoint(), ec.HasMetadataV3, ec.Logger)
}
ec.ServerUUID = state.UUID
ec.Telemetry.ServerUUID = ec.ServerUUID
ec.Logger.Debugf("server: uuid: %s", ec.ServerUUID)
// Set headers required for communicating with HGE
return nil
}
func (ec *ExecutionContext) checkServerVersion() error {
var op errors.Op = "cli.ExecutionContext.checkServerVersion"
v, err := version.FetchServerVersion(ec.Config.ServerConfig.GetVersionEndpoint(), ec.Config.HTTPClient)
if err != nil {
return errors.E(op, fmt.Errorf("failed to get version from server: %w", err))
}
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 {
ec.Logger.Warnf("[cli: %s] [server: %s] version mismatch: %s", ec.Version.GetCLIVersion(), ec.Version.GetServerVersion(), reason)
}
return nil
}
// WriteConfig writes the configuration from ec.Config or input config
func (ec *ExecutionContext) WriteConfig(config *Config) error {
var op errors.Op = "cli.ExecutionContext.WriteConfig"
var cfg *Config
if config != nil {
cfg = config
} else {
cfg = ec.Config
}
buf := new(bytes.Buffer)
encoder := yaml.NewEncoder(buf)
encoder.SetIndent(2)
err := encoder.Encode(cfg)
if err != nil {
return errors.E(op, err)
}
err = ioutil.WriteFile(ec.ConfigFile, buf.Bytes(), 0644)
if err != nil {
return errors.E(op, err)
}
return nil
}
type DefaultAPIPath string
// readConfig reads the configuration from config file, flags and env vars,
// through viper.
func (ec *ExecutionContext) readConfig() error {
var op errors.Op = "cli.ExecutionContext.readConfig"
// need to get existing viper because https://github.com/spf13/viper/issues/233
v := ec.Viper
v.SetEnvPrefix(util.ViperEnvPrefix)
v.SetEnvKeyReplacer(util.ViperEnvReplacer)
v.AutomaticEnv()
v.SetConfigName("config")
v.SetDefault("version", "1")
v.SetDefault("endpoint", "http://localhost:8080")
v.SetDefault("admin_secret", "")
v.SetDefault("access_key", "")
v.SetDefault("api_paths.query", "v1/query")
v.SetDefault("api_paths.v2_query", "v2/query")
v.SetDefault("api_paths.v1_metadata", "v1/metadata")
v.SetDefault("api_paths.graphql", "v1/graphql")
v.SetDefault("api_paths.config", "v1alpha1/config")
v.SetDefault("api_paths.pg_dump", "v1alpha1/pg_dump")
v.SetDefault("api_paths.version", "v1/version")
v.SetDefault("metadata_directory", DefaultMetadataDirectory)
v.SetDefault("migrations_directory", DefaultMigrationsDirectory)
v.SetDefault("seeds_directory", DefaultSeedsDirectory)
v.SetDefault("actions.kind", "synchronous")
v.SetDefault("actions.handler_webhook_baseurl", "http://localhost:3000")
v.SetDefault("actions.codegen.framework", "")
v.SetDefault("actions.codegen.output_dir", "")
v.SetDefault("actions.codegen.uri", "")
v.AddConfigPath(ec.ExecutionDirectory)
err := v.ReadInConfig()
if err != nil {
return errors.E(op, fmt.Errorf("cannot read config from file/env: %w", err))
}
adminSecret := v.GetString("admin_secret")
if adminSecret == "" {
adminSecret = v.GetString("access_key")
}
// Admin secrets can be specified as a string value of format
// ["secret1", "secret2"], similar to how the corresponding environment variable
// HASURA_GRAPHQL_ADMIN_SECRETS is configured with the server
adminSecretsList := v.GetString("admin_secrets")
adminSecrets := []string{}
if len(adminSecretsList) > 0 {
if err = json.Unmarshal([]byte(adminSecretsList), &adminSecrets); err != nil {
return errors.E(op, fmt.Errorf("parsing 'admin_secrets' from config.yaml / environment variable HASURA_GRAPHQL_ADMIN_SECRETS failed: expected value of format [\"secret1\", \"secret2\"]: %w", err))
}
}
ec.Config = &Config{
Version: ConfigVersion(v.GetInt("version")),
DisableInteractive: v.GetBool("disable_interactive"),
ServerConfig: ServerConfig{
Endpoint: v.GetString("endpoint"),
AdminSecret: adminSecret,
AdminSecrets: adminSecrets,
APIPaths: &ServerAPIPaths{
V1Query: v.GetString("api_paths.query"),
V2Query: v.GetString("api_paths.v2_query"),
V1Metadata: v.GetString("api_paths.v1_metadata"),
GraphQL: v.GetString("api_paths.graphql"),
Config: v.GetString("api_paths.config"),
PGDump: v.GetString("api_paths.pg_dump"),
Version: v.GetString("api_paths.version"),
},
InsecureSkipTLSVerify: v.GetBool("insecure_skip_tls_verify"),
CAPath: v.GetString("certificate_authority"),
},
MetadataDirectory: v.GetString("metadata_directory"),
MetadataFile: v.GetString("metadata_file"),
MigrationsDirectory: v.GetString("migrations_directory"),
SeedsDirectory: v.GetString("seeds_directory"),
ActionConfig: &types.ActionExecutionConfig{
Kind: v.GetString("actions.kind"),
HandlerWebhookBaseURL: v.GetString("actions.handler_webhook_baseurl"),
Codegen: &types.CodegenExecutionConfig{
Framework: v.GetString("actions.codegen.framework"),
OutputDir: v.GetString("actions.codegen.output_dir"),
URI: v.GetString("actions.codegen.uri"),
},
},
}
if !ec.Config.Version.IsValid() {
return errors.E(op, ErrInvalidConfigVersion)
}
err = ec.Config.ServerConfig.ParseEndpoint()
if err != nil {
return errors.E(op, fmt.Errorf("unable to parse server endpoint: %w", err))
}
return nil
}
// 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 = ec.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)
}
}
// loadEnvfile loads .env file
func (ec *ExecutionContext) loadEnvfile() error {
var op errors.Op = "cli.ExecutionContext.loadEnvfile"
envfile := ec.Envfile
if !filepath.IsAbs(ec.Envfile) {
envfile = filepath.Join(ec.ExecutionDirectory, ec.Envfile)
}
err := gotenv.Load(envfile)
if err != nil {
// return error if user provided envfile name
if ec.Envfile != ".env" {
return errors.E(op, err)
}
if !stderrors.Is(err, fs.ErrNotExist) {
ec.Logger.Warn(err)
}
}
if err == nil {
ec.Logger.Debug("ENV vars read from: ", envfile)
}
return nil
}
// setupLogger creates a default logger if context does not have one set.
func (ec *ExecutionContext) setupLogger() {
if ec.Logger == nil {
logger := logrus.New()
ec.Logger = logger
ec.Logger.SetOutput(ec.Stderr)
}
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)
}
ec.Logger.Hooks = make(logrus.LevelHooks)
ec.Logger.AddHook(newSpinnerHandlerHook(ec.Logger, ec.Spinner, ec.IsTerminal, ec.NoColor))
// 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()
}
}
func GetAdminSecretHeaderName(v *version.Version) string {
if v.ServerFeatureFlags.HasAccessKey {
return XHasuraAccessKey
}
return XHasuraAdminSecret
}
func GetCommonMetadataOps(ec *ExecutionContext) hasura.CommonMetadataOperations {
if !ec.HasMetadataV3 {
return ec.APIClient.V1Query
}
return ec.APIClient.V1Metadata
}
func GetMigrationsStateStore(ec *ExecutionContext) statestore.MigrationsStateStore {
if ec.Config.Version <= V2 {
if !ec.HasMetadataV3 {
return migrations.NewMigrationStateStoreHdbTable(ec.APIClient.V1Query, migrations.DefaultSchema, migrations.DefaultMigrationsTable)
}
return migrations.NewMigrationStateStoreHdbTable(ec.APIClient.V2Query, migrations.DefaultSchema, migrations.DefaultMigrationsTable)
}
return migrations.NewCatalogStateStore(statestore.NewCLICatalogState(ec.APIClient.V1Metadata))
}
func GetSettingsStateStore(ec *ExecutionContext, databaseName string) statestore.SettingsStateStore {
const (
defaultSettingsTable = "migration_settings"
defaultSchema = "hdb_catalog"
)
if ec.Config.Version <= V2 {
if !ec.HasMetadataV3 {
return settings.NewStateStoreHdbTable(ec.APIClient.V1Query, databaseName, defaultSchema, defaultSettingsTable)
}
return settings.NewStateStoreHdbTable(ec.APIClient.V2Query, databaseName, defaultSchema, defaultSettingsTable)
}
return settings.NewStateStoreCatalog(statestore.NewCLICatalogState(ec.APIClient.V1Metadata))
}