Chris Roberts 9caa92a20a
Update command setup to use base client and then load scope
There are still some things left to address like how to handle
a target (or targets) and if that is something we should even
be doing. It may be best to just let the command deal with
target loading. There are also some considerations to make
around remote source.
2022-04-25 12:24:25 -05:00

542 lines
14 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"regexp"
"strings"
"github.com/DavidGamba/go-getoptions"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/vagrant-plugin-sdk/helper/paths"
"github.com/hashicorp/vagrant-plugin-sdk/terminal"
"github.com/hashicorp/vagrant/internal/clicontext"
"github.com/hashicorp/vagrant/internal/client"
clientpkg "github.com/hashicorp/vagrant/internal/client"
"github.com/hashicorp/vagrant/internal/clierrors"
"github.com/hashicorp/vagrant/internal/config"
"github.com/hashicorp/vagrant/internal/server/proto/vagrant_server"
"github.com/hashicorp/vagrant/internal/serverclient"
)
// baseCommand is embedded in all commands to provide common logic and data.
//
// The unexported values are not available until after Init is called. Some
// values are only available in certain circumstances, read the documentation
// for the field to determine if that is the case.
type baseCommand struct {
// Ctx is the base context for the command. It is up to commands to
// utilize this context so that cancellation works in a timely manner.
Ctx context.Context
// Log is the logger to use.
Log hclog.Logger
// LogOutput is the writer that Log points to. You SHOULD NOT use
// this directly. We have access to this so you can use
// hclog.OutputResettable if necessary.
LogOutput io.Writer
//---------------------------------------------------------------
// The fields below are only available after calling Init.
// cfg is the parsed configuration
cfg *config.Config
// UI is used to write to the CLI.
ui terminal.UI
// client for performing operations
client *clientpkg.Client
// basis to root these operations within
basis *clientpkg.Basis
// optional project to run operations within
project *clientpkg.Project
// optional target to run operations against
target *clientpkg.Target
// clientContext is set to the context information for the current
// connection. This might not exist in the contextStorage yet if this
// is from an env var or flags.
clientContext *clicontext.Config
// contextStorage is for CLI contexts.
contextStorage *clicontext.Storage
//---------------------------------------------------------------
// Internal fields that should not be accessed directly
// flagPlain is whether the output should be in plain mode.
flagPlain bool
// flagRemote is whether to execute using a remote runner or use
// a local runner.
flagRemote bool
// flagBasis is the basis to work within.
flagBasis string
// flagProject is the project to work within.
flagProject string
// flagTarget is the machine to target.
flagTarget string
// flagConnection contains manual flag-based connection info.
flagConnection clicontext.Config
// args that were present after parsing flags
args []string
// options passed in at the global level
globalOptions []Option
}
// Close cleans up any resources that the command created. This should be
// defered by any CLI command that embeds baseCommand in the Run command.
func (c *baseCommand) Close() (err error) {
c.Log.Trace("starting command closing")
if closer, ok := c.ui.(io.Closer); ok && closer != nil {
c.Log.Trace("closing command ui")
if e := closer.Close(); e != nil {
err = multierror.Append(err, e)
}
}
if c.client != nil {
c.Log.Trace("closing command client")
if e := c.client.Close(); e != nil {
err = multierror.Append(err, e)
}
}
return
}
func BaseCommand(ctx context.Context, log hclog.Logger, logOutput io.Writer, opts ...Option) (*baseCommand, error) {
bc := &baseCommand{
Ctx: ctx,
Log: log,
LogOutput: logOutput,
}
// Get just enough base configuration to
// allow setting up our client connection
c := &baseConfig{
Client: true,
Flags: bc.flagSet(flagSetConnection, nil),
}
// Apply any options that were passed. These
// should at least include the arguments so
// we can extract the flags properly
for _, opt := range opts {
opt(c)
}
if c.UI == nil {
c.UI = terminal.ConsoleUI(context.Background())
}
// Allow parser to not fail on unknown arguments
c.Flags.SetUnknownMode(getoptions.Pass)
if _, err := c.Flags.Parse(c.Args); err != nil {
c.UI.Output(clierrors.Humanize(err), terminal.WithErrorStyle())
return nil, err
}
// From the command side, the basis is simply where an extra Vagrantfile can
// live, as well as our storage context
if bc.flagBasis == "" {
bc.flagBasis = "default"
}
homeConfigPath, err := paths.NamedVagrantConfig(bc.flagBasis)
if err != nil {
return nil, err
}
bc.Log.Info("vagrant home directory defined",
"path", homeConfigPath)
// Setup our directory for context storage
contextStorage, err := clicontext.NewStorage(
clicontext.WithDir(homeConfigPath.Join("context")))
if err != nil {
return nil, err
}
bc.contextStorage = contextStorage
// We use our flag-based connection info if the user set an addr.
var flagConnection *clicontext.Config
if v := bc.flagConnection; v.Server.Address != "" {
flagConnection = &v
}
// Get the context we'll use. The ordering here is purposeful and creates
// the following precedence: (1) context (2) env (3) flags where the
// later values override the former.
connectOpts := []serverclient.ConnectOption{
serverclient.FromContext(bc.contextStorage, ""),
serverclient.FromEnv(),
serverclient.FromContextConfig(flagConnection),
}
bc.clientContext, err = serverclient.ContextConfig(connectOpts...)
if err != nil {
return nil, err
}
// Start building our client options
clientOpts := []clientpkg.Option{
clientpkg.WithLogger(bc.Log.ResetNamed("vagrant.client")),
clientpkg.WithClientConnect(connectOpts...),
}
if !bc.flagRemote {
clientOpts = append(clientOpts, clientpkg.WithLocal())
}
if bc.ui != nil {
clientOpts = append(clientOpts, clientpkg.WithUI(bc.ui))
}
// And build our client
bc.client, err = clientpkg.New(ctx, clientOpts...)
if err != nil {
return nil, err
}
// We always have a basis, so load that
if bc.basis, err = bc.client.LoadBasis(bc.flagBasis); err != nil {
return nil, err
}
// A project is optional (though generally needed) and there are
// two possibilites for how we load the project.
if bc.flagProject != "" {
// The first is that we are given a specific project that should be
// used within the defined basis. So lets load that.
if bc.project, err = bc.basis.LoadProject(bc.flagProject); err != nil {
return nil, err
}
} else {
if bc.project, err = bc.basis.DetectProject(); err != nil {
return nil, err
}
}
// Load in basis vagrantfile if there is one
if err = bc.basis.LoadVagrantfile(); err != nil {
return nil, err
}
// And if we have a project, load that vagrantfile too
if bc.project != nil {
if err = bc.project.LoadVagrantfile(); err != nil {
return nil, err
}
}
// There's also a chance we are supposed to be focused on
// a specific target, so load that if so
if bc.flagTarget != "" {
if bc.project == nil {
return nil, fmt.Errorf("cannot load target without valid project")
}
if bc.target, err = bc.project.LoadTarget(bc.flagTarget); err != nil {
return nil, err
}
}
return bc, err
}
// Init initializes the command by parsing flags, parsing the configuration,
// setting up the project, etc. You can control what is done by using the
// options.
//
// Init should be called FIRST within the Run function implementation. Many
// options will affect behavior of other functions that can be called later.
func (c *baseCommand) Init(opts ...Option) error {
baseCfg := baseConfig{
Config: true,
Client: true,
}
for _, opt := range c.globalOptions {
opt(&baseCfg)
}
for _, opt := range opts {
opt(&baseCfg)
}
// Init our UI first so we can write output to the user immediately.
ui := baseCfg.UI
if ui == nil {
ui = terminal.ConsoleUI(c.Ctx)
}
c.ui = ui
// Parse flags
remainingArgs, err := baseCfg.Flags.Parse(baseCfg.Args)
if err != nil {
c.ui.Output(clierrors.Humanize(err), terminal.WithErrorStyle())
return err
}
c.args = remainingArgs
// Reset the UI to plain if that was set
if c.flagPlain {
c.ui = terminal.NonInteractiveUI(c.Ctx)
}
// Parse the configuration
c.cfg = &config.Config{}
// If we have an app target requirement, we have to get it from the args
// or the config.
// if baseCfg.TargetRequired && c.project != nil {
// // If we have args, attempt to extract there first.
// if len(c.args) > 0 {
// match := reTarget.FindStringSubmatch(c.args[0])
// if match != nil {
// // Set our target
// t, err := c.project.LoadTarget(match[1])
// if err != nil {
// return err
// }
// c.target = t
// // Shift the args
// c.args = c.args[1:]
// // Explicitly set remote (TODO: why?)
// c.flagRemote = true
// }
// }
// // If we didn't get our ref, then we need to load config
// if c.target == nil {
// baseCfg.Config = true
// }
// }
// // If we're loading the config, then get it.
// if baseCfg.Config {
// cfg, err := c.initConfig(baseCfg.ConfigOptional)
// if err != nil {
// c.logError(c.Log, "failed to load configuration", err)
// return err
// }
// c.cfg = cfg
// if cfg != nil {
// // If we require an app target and we still haven't set it,
// // and the user provided it via the CLI, set it now. This code
// // path is only reached if it wasn't set via the args either
// // above.
// if c.flagTarget == "" {
// c.flagTarget = "default"
// }
// t, err := c.project.LoadTarget(c.flagTarget)
// if err != nil {
// return err
// }
// c.target = t
// }
// }
// Validate remote vs. local operations.
if c.flagRemote && c.target == nil {
if c.cfg == nil || c.cfg.Runner == nil || !c.cfg.Runner.Enabled {
err := errors.New(
"The `-remote` flag was specified but remote operations are not supported\n" +
"for this project.\n\n" +
"Remote operations must be manually enabled by using setting the 'runner.enabled'\n" +
"setting in your Vagrant configuration file. Please see the documentation\n" +
"on this setting for more information.")
c.logError(c.Log, "", err)
return err
}
}
if baseCfg.TargetRequired && c.project != nil {
if c.target == nil {
if len(c.cfg.Project.Targets) != 1 {
c.ui.Output(errTargetModeSingle, terminal.WithErrorStyle())
return ErrSentinel
}
target, err := c.project.LoadTarget(c.cfg.Project.Targets[0].Name)
if err != nil {
return err
}
c.target = target
}
}
return nil
}
type Tasker interface {
UI() terminal.UI
Task(context.Context, *vagrant_server.Job_RunOp, client.JobModifier) (*vagrant_server.Job_RunResult, error)
//CreateTask() *vagrant_server.Task
}
// Do calls the callback based on the loaded scope. This automatically handles any
// parallelization, waiting, and error handling. Your code should be
// thread-safe.
//
// Based on the scope the callback may be executed multiple times. When scoped by
// machine, it will be run against each requested machine. When the scope is basis
// or project, it will only be run once.
//
// If any error is returned, the caller should just exit. The error handling
// including messaging to the user is handling by this function call.
//
// If you want to early exit all the running functions, you should use
// the callback closure properties to cancel the passed in context. This
// will stop any remaining callbacks and exit early.
func (c *baseCommand) Do(ctx context.Context, f func(context.Context, *client.Client, client.JobModifier) error) (finalErr error) {
return f(ctx, c.client, c.Modifier())
}
func (c *baseCommand) Modifier() client.JobModifier {
return func(j *vagrant_server.Job) {
if c.basis != nil {
j.Basis = c.basis.Ref()
}
if c.project != nil {
j.Project = c.project.Ref()
}
if c.target != nil {
j.Target = c.target.Ref()
}
}
}
// logError logs an error and outputs it to the UI.
func (c *baseCommand) logError(log hclog.Logger, prefix string, err error) {
if err == ErrSentinel {
return
}
log.Error(prefix, "error", err)
if prefix != "" {
prefix += ": "
}
c.ui.Output("%s%s", prefix, err, terminal.WithErrorStyle())
}
// flagSet creates the flags for this command. The callback should be used
// to configure the set with your own custom options.
func (c *baseCommand) flagSet(bit flagSetBit, f func(*getoptions.GetOpt)) *getoptions.GetOpt {
set := getoptions.New()
set.BoolVar(
&c.flagPlain,
"plain",
false,
set.Description("Plain output: no colors, no animation."),
)
set.StringVar(
&c.flagTarget,
"target",
"",
set.Description("Target to apply. Certain commands require a single target for "+
"Vagrant configurations with multiple apps. If you have a single target, "+
"then this can be ignored."),
)
set.StringVar(
&c.flagBasis,
"basis",
"default",
set.Description("Basis to operate within."),
)
if bit&flagSetOperation != 0 {
set.BoolVar(
&c.flagRemote,
"remote",
false,
set.Description("True to use a remote runner to execute. This defaults to false \n"+
"unless 'runner.default' is set in your configuration."),
)
// set.StringMapVar(
// &c.flagRemoteSource,
// "remote-source",
// 1,
// MaxStringMapArgs,
// set.Description("Override configurations for how remote runners source data. "+
// "This is specified to the data source type being used in your configuration. "+
// "This is used for example to set a specific Git ref to run against."),
// )
}
if bit&flagSetConnection != 0 {
set.StringVar(
&c.flagConnection.Server.Address,
"server-addr",
"",
set.Description("Address for the server."),
)
set.BoolVar(
&c.flagConnection.Server.Tls,
"server-tls",
true,
set.Description("True if the server should be connected to via TLS."),
)
set.BoolVar(
&c.flagConnection.Server.TlsSkipVerify,
"server-tls-skip-verify",
false,
set.Description("True to skip verification of the TLS certificate advertised by the server."),
)
}
if f != nil {
// Configure our values
f(set)
}
return set
}
// flagSetBit is used with baseCommand.flagSet
type flagSetBit uint
const (
flagSetNone flagSetBit = 1 << iota
flagSetOperation // shared flags for operations (build, deploy, etc)
flagSetConnection // shared flags for server connections
)
const MaxStringMapArgs int = 50
var (
// ErrSentinel is a sentinel value that we can return from Init to force an exit.
ErrSentinel = errors.New("error sentinel")
errTargetModeSingle = strings.TrimSpace(`
This command requires a single targeted machine. You have multiple machines defined
so you can specify the machine to target using the "-machine" flag.
`)
reTarget = regexp.MustCompile(`^(?P<machine>[-0-9A-Za-z_]+)$`)
)