sq/cli/run.go

432 lines
13 KiB
Go
Raw Normal View History

package cli
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/neilotoole/sq/cli/config"
"github.com/neilotoole/sq/cli/config/yamlstore"
v0_34_0 "github.com/neilotoole/sq/cli/config/yamlstore/upgrades/v0.34.0" //nolint:revive
"github.com/neilotoole/sq/cli/flag"
"github.com/neilotoole/sq/cli/run"
"github.com/neilotoole/sq/drivers/csv"
"github.com/neilotoole/sq/drivers/json"
"github.com/neilotoole/sq/drivers/mysql"
"github.com/neilotoole/sq/drivers/postgres"
"github.com/neilotoole/sq/drivers/sqlite3"
"github.com/neilotoole/sq/drivers/sqlserver"
"github.com/neilotoole/sq/drivers/userdriver"
"github.com/neilotoole/sq/drivers/userdriver/xmlud"
"github.com/neilotoole/sq/drivers/xlsx"
"github.com/neilotoole/sq/libsq/core/cleanup"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/neilotoole/sq/libsq/core/ioz/checksum"
"github.com/neilotoole/sq/libsq/core/ioz/lockfile"
"github.com/neilotoole/sq/libsq/core/lg"
"github.com/neilotoole/sq/libsq/core/lg/lga"
"github.com/neilotoole/sq/libsq/core/lg/slogbuf"
"github.com/neilotoole/sq/libsq/core/options"
"github.com/neilotoole/sq/libsq/core/progress"
"github.com/neilotoole/sq/libsq/driver"
"github.com/neilotoole/sq/libsq/files"
"github.com/neilotoole/sq/libsq/source"
"github.com/neilotoole/sq/libsq/source/drivertype"
)
// getRun is a convenience function for getting Run
// from the cmd.Context().
func getRun(cmd *cobra.Command) *run.Run {
ru := run.FromContext(cmd.Context())
if ru.Cmd == nil {
// ru.Cmd is usually set by the cmd.preRun that is added
// by addCmd. But some commands (I'm looking at you __complete) don't
// interact with that mechanism. So, we set the field here for those
// odd cases.
ru.Cmd = cmd
}
return ru
}
// newRun returns a run.Run configured with standard values for logging,
// config, etc. This effectively is the bootstrap mechanism for sq.
// Note that the run.Run is not fully configured for use by a command
// until preRun and FinishRunInit are executed on it.
//
// Note: This func always returns a Run, even if an error occurs during
// bootstrap of the Run (for example if there's a config error). We do this
// to provide enough framework so that such an error can be logged or
// printed per the normal mechanisms, if at all possible.
func newRun(ctx context.Context, stdin *os.File, stdout, stderr io.Writer, args []string,
) (*run.Run, *slog.Logger, error) {
// logbuf holds log records until defaultLogging is completed.
log, logbuf := slogbuf.New()
log = log.With(lga.Pid, os.Getpid())
ru := &run.Run{
Stdin: stdin,
Out: stdout,
ErrOut: stderr,
OptionsRegistry: &options.Registry{},
}
RegisterDefaultOpts(ru.OptionsRegistry)
upgrades := yamlstore.UpgradeRegistry{
v0_34_0.Version: v0_34_0.Upgrade,
}
ctx = lg.NewContext(ctx, log)
var configErr error
ru.Config, ru.ConfigStore, configErr = yamlstore.Load(ctx,
args, ru.OptionsRegistry, upgrades)
log, logHandler, logCloser, logErr := defaultLogging(ctx, args, ru.Config)
ru.Cleanup = cleanup.New()
ru.LogCloser = logCloser
if logErr != nil {
stderrLog, h := stderrLogger()
_ = logbuf.Flush(ctx, h)
return ru, stderrLog, logErr
}
if logHandler != nil {
if err := logbuf.Flush(ctx, logHandler); err != nil {
return ru, log, err
}
}
if log == nil {
log = lg.Discard()
}
log = log.With(lga.Pid, os.Getpid())
if ru.Config == nil {
ru.Config = config.New()
}
if configErr != nil {
// configErr is more important, return that first
return ru, log, configErr
}
return ru, log, nil
}
// preRun is invoked by cobra prior to the command's RunE being
// invoked. It sets up the driver registry, databases, writers and related
// fundamental components. Subsequent invocations of this method
// are no-op.
func preRun(cmd *cobra.Command, ru *run.Run) error {
if ru == nil {
return errz.New("Run is nil")
}
if ru.Writers != nil {
// If ru.Writers is already set, then this function has already been
// called on ru. That's ok, just return.
return nil
}
ctx := cmd.Context()
if ru.Cleanup == nil {
ru.Cleanup = cleanup.New()
}
// If the --input=some/file flag is set, then we need
// to override ru.Stdin (which is typically stdin) to point
// it at the input source file.
if cmdFlagChanged(ru.Cmd, flag.Input) {
fpath, _ := ru.Cmd.Flags().GetString(flag.Input)
fpath, err := filepath.Abs(fpath)
if err != nil {
return errz.Wrapf(err, "failed to get absolute path for --%s", flag.Input)
}
f, err := os.Open(fpath)
if err != nil {
return errz.Wrapf(err, "failed to open file specified by flag --%s", flag.Input)
}
ru.Stdin = f
}
// If the --output=/some/file flag is set, then we need to
// override ru.Out (which is typically stdout) to point it at
// the output destination file.
if cmdFlagChanged(ru.Cmd, flag.Output) {
fpath, _ := ru.Cmd.Flags().GetString(flag.Output)
fpath, err := filepath.Abs(fpath)
if err != nil {
return errz.Wrapf(err, "failed to get absolute path for --%s", flag.Output)
}
// Ensure the parent dir exists
err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
if err != nil {
return errz.Wrapf(err, "failed to make parent dir for --%s", flag.Output)
}
f, err := os.Create(fpath)
if err != nil {
return errz.Wrapf(err, "failed to open file specified by flag --%s", flag.Output)
}
ru.Cleanup.AddC(f) // Make sure the file gets closed eventually
ru.Out = f
}
cmdOpts, err := getOptionsFromCmd(ru.Cmd)
if err != nil {
return err
}
ru.Writers, ru.Out, ru.ErrOut = newWriters(ru.Cmd, ru.Cleanup, cmdOpts, ru.Out, ru.ErrOut)
if err = FinishRunInit(ctx, ru); err != nil {
return err
}
if cmdRequiresConfigLock(cmd) {
var unlock func()
if unlock, err = lockReloadConfig(cmd); err != nil {
return err
}
ru.Cleanup.Add(unlock)
}
return nil
}
// FinishRunInit finishes setting up ru.
//
// TODO: This run.Run initialization mechanism is a bit of a mess.
// There's logic in newRun, preRun, FinishRunInit, as well as testh.Helper.init.
// Surely the init logic can be consolidated.
func FinishRunInit(ctx context.Context, ru *run.Run) error {
if ru.Cleanup == nil {
ru.Cleanup = cleanup.New()
}
cfg, log := ru.Config, lg.FromContext(ctx)
var scratchSrcFunc driver.ScratchSrcFunc
// scratchSrc could be nil, and that's ok
scratchSrc := cfg.Collection.Scratch()
if scratchSrc == nil {
scratchSrcFunc = sqlite3.NewScratchSource
} else {
scratchSrcFunc = func(_ context.Context, name string) (src *source.Source, clnup func() error, err error) {
return scratchSrc, nil, nil
}
}
var err error
// The Files instance may already have been created. If not, create it.
if ru.Files == nil {
var cfgLock lockfile.Lockfile
if cfgLock, err = ru.ConfigStore.Lockfile(); err != nil {
return err
}
cfgLockFunc := newProgressLockFunc(
cfgLock,
"acquire config lock",
config.OptConfigLockTimeout.Get(options.FromContext(ctx)),
)
// We use cache and temp dirs with paths based on a hash of the config's
// location. This ensures that multiple sq instances using different
// configs don't share the same cache/temp dir.
sum := checksum.Sum([]byte(ru.ConfigStore.Location()))
ru.Files, err = files.New(
ctx,
ru.OptionsRegistry,
cfgLockFunc,
filepath.Join(files.DefaultTempDir(), sum),
filepath.Join(files.DefaultCacheDir(), sum),
)
if err != nil {
lg.WarnIfFuncError(log, lga.Cleanup, ru.Cleanup.Run)
return err
}
}
// Note: it's important that files.Close is invoked
// after databases.Close (hence added to clnup first),
// because databases could depend upon the existence of
// files (such as a sqlite db file).
ru.Cleanup.AddE(ru.Files.Close)
ru.DriverRegistry = driver.NewRegistry(log)
dr := ru.DriverRegistry
ru.Grips = driver.NewGrips(dr, ru.Files, scratchSrcFunc)
ru.Cleanup.AddC(ru.Grips)
dr.AddProvider(drivertype.SQLite, &sqlite3.Provider{Log: log})
dr.AddProvider(drivertype.Pg, &postgres.Provider{Log: log})
dr.AddProvider(drivertype.MSSQL, &sqlserver.Provider{Log: log})
dr.AddProvider(drivertype.MySQL, &mysql.Provider{Log: log})
csvp := &csv.Provider{Log: log, Ingester: ru.Grips, Files: ru.Files}
dr.AddProvider(drivertype.CSV, csvp)
dr.AddProvider(drivertype.TSV, csvp)
ru.Files.AddDriverDetectors(csv.DetectCSV, csv.DetectTSV)
jsonp := &json.Provider{Log: log, Ingester: ru.Grips, Files: ru.Files}
dr.AddProvider(drivertype.JSON, jsonp)
dr.AddProvider(drivertype.JSONA, jsonp)
dr.AddProvider(drivertype.JSONL, jsonp)
sampleSize := driver.OptIngestSampleSize.Get(cfg.Options)
ru.Files.AddDriverDetectors(
json.DetectJSON(sampleSize),
json.DetectJSONA(sampleSize),
json.DetectJSONL(sampleSize),
)
dr.AddProvider(drivertype.XLSX, &xlsx.Provider{Log: log, Ingester: ru.Grips, Files: ru.Files})
ru.Files.AddDriverDetectors(xlsx.DetectXLSX)
// One day we may have more supported user driver genres.
userDriverImporters := map[string]userdriver.IngestFunc{
xmlud.Genre: xmlud.Ingest,
}
for i, udd := range cfg.Ext.UserDrivers {
udd := udd
errs := userdriver.ValidateDriverDef(udd)
if len(errs) > 0 {
err = errz.Combine(errs...)
err = errz.Wrapf(err, "failed validation of user driver definition [%d] {%s} from config",
i, udd.Name)
return err
}
importFn, ok := userDriverImporters[udd.Genre]
if !ok {
return errz.Errorf("unsupported genre {%s} for user driver {%s} specified via config",
udd.Genre, udd.Name)
}
// For each user driver definition, we register a
// distinct userdriver.Provider instance.
udp := &userdriver.Provider{
Log: log,
DriverDef: udd,
IngestFn: importFn,
Ingester: ru.Grips,
Files: ru.Files,
}
ru.DriverRegistry.AddProvider(drivertype.Type(udd.Name), udp)
ru.Files.AddDriverDetectors(udp.Detectors()...)
}
return nil
}
// markCmdRequiresConfigLock marks cmd as requiring a config lock.
// Thus, before the command's RunE is invoked, the config lock
// is acquired (in preRun), and released on cleanup.
func markCmdRequiresConfigLock(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
cmd.Annotations["config.lock"] = "true"
}
// cmdRequiresConfigLock returns true if markCmdRequiresConfigLock was
// previously invoked on cmd.
func cmdRequiresConfigLock(cmd *cobra.Command) bool {
return cmd.Annotations != nil && cmd.Annotations["config.lock"] == "true"
}
// lockReloadConfig acquires the lock for the config store, and updates the
// run (as found on cmd's context) with a fresh copy of the config, loaded
// after lock acquisition.
//
// The config lock should be acquired before making any changes to config.
// Timeout and progress options from ctx are honored.
// The caller is responsible for invoking the returned unlock func.
// Example usage:
//
// if unlock, err := lockReloadConfig(cmd); err != nil {
// return err
// } else {
// defer unlock()
// }
//
// However, in practice, most commands will invoke markCmdRequiresConfigLock
// instead of explicitly invoking lockReloadConfig.
func lockReloadConfig(cmd *cobra.Command) (unlock func(), err error) {
ctx := cmd.Context()
ru := run.FromContext(ctx)
if ru.ConfigStore == nil {
return nil, errz.New("config store is nil")
}
lock, err := ru.ConfigStore.Lockfile()
if err != nil {
return nil, errz.Wrap(err, "failed to get config lock")
}
lockTimeout := config.OptConfigLockTimeout.Get(options.FromContext(ctx))
bar := progress.FromContext(ctx).NewTimeoutWaiter(
"Acquire config lock",
time.Now().Add(lockTimeout),
)
err = lock.Lock(ctx, lockTimeout)
bar.Stop()
if err != nil {
return nil, errz.Wrap(err, "acquire config lock")
}
var cfg *config.Config
if cfg, err = ru.ConfigStore.Load(ctx); err != nil {
// An error occurred reloading config; release the lock before returning.
if unlockErr := lock.Unlock(); unlockErr != nil {
lg.FromContext(ctx).Warn("Failed to release config lock",
lga.Lock, lock, lga.Err, unlockErr)
}
return nil, err
}
// Update the run with the fresh config.
ru.Config = cfg
return func() {
if unlockErr := lock.Unlock(); unlockErr != nil {
lg.FromContext(ctx).Warn("Failed to release config lock",
lga.Lock, lock, lga.Err, unlockErr)
}
}, nil
}
// newProgressLockFunc returns a new lockfile.LockFunc that that acquires lock,
// and displays a progress bar while doing so.
func newProgressLockFunc(lock lockfile.Lockfile, msg string, timeout time.Duration) lockfile.LockFunc {
return func(ctx context.Context) (unlock func(), err error) {
bar := progress.FromContext(ctx).NewTimeoutWaiter(
msg,
time.Now().Add(timeout),
)
err = lock.Lock(ctx, timeout)
bar.Stop()
if err != nil {
return nil, errz.Wrap(err, msg)
}
return func() {
if err = lock.Unlock(); err != nil {
lg.FromContext(ctx).With(lga.Lock, lock, "for", msg).
Warn("Failed to release lock", lga.Err, err)
}
}, nil
}
}