2022-04-25 12:24:06 -05:00

581 lines
15 KiB
Go

package cli
import (
"context"
"errors"
"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/path"
"github.com/hashicorp/vagrant-plugin-sdk/helper/paths"
"github.com/hashicorp/vagrant-plugin-sdk/terminal"
"github.com/hashicorp/vagrant/internal/clicontext"
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
basis *clientpkg.Basis
project *clientpkg.Project
targets []*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
// flagLabels are set via -label if flagSetOperation is set.
flagLabels map[string]string
// flagRemote is whether to execute using a remote runner or use
// a local runner.
flagRemote bool
// flagRemoteSource are the remote data source overrides for jobs.
flagRemoteSource map[string]string
// flagBasis is the basis to work within.
flagBasis string
// flagMachine 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() error {
if closer, ok := c.ui.(io.Closer); ok && closer != nil {
closer.Close()
}
if c.basis != nil {
c.basis.Close()
}
return nil
}
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
}
// Setup our basis path
homeConfigPath, err := paths.VagrantHome()
if err != nil {
bc.ui.Output(clierrors.Humanize(err), terminal.WithErrorStyle())
return nil, err
}
bc.Log.Debug("home configuration directory", "path", homeConfigPath.String())
// Setup our base directory for context management
contextStorage, err := clicontext.NewStorage(
clicontext.WithDir(homeConfigPath.Join("context")))
if err != nil {
bc.ui.Output(clierrors.Humanize(err), terminal.WithErrorStyle())
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
basisOpts := []clientpkg.Option{
clientpkg.WithLogger(bc.Log),
clientpkg.WithClientConnect(connectOpts...),
clientpkg.WithBasis(
&vagrant_server.Basis{
Name: homeConfigPath.String(),
Path: homeConfigPath.String(),
},
),
}
if !bc.flagRemote {
basisOpts = append(basisOpts, clientpkg.WithLocal())
}
if bc.ui != nil {
basisOpts = append(basisOpts, clientpkg.WithUI(bc.ui))
}
basis, err := clientpkg.New(context.Background(), basisOpts...)
if err != nil {
return nil, err
}
bc.basis = basis
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)
}
// TODO(spox): re-enable custom basis
// Set our basis reference if provided
// if c.flagBasis != "" {
// c.basis.SetRef(&vagrant_server.Ref_Basis{Name: c.flagBasis})
// }
// Determine if we are in a project and setup if so
cwd, err := path.NewPath(".").Abs()
if err != nil {
panic("cannot setup local directory")
}
if _, err := config.FindPath("", ""); err == nil {
c.project, err = c.basis.LoadProject(
&vagrant_server.Project{
Name: cwd.String(),
Path: cwd.String(),
Basis: c.basis.Ref(),
},
)
if err != nil {
return err
}
}
// 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 machine
t, err := c.project.LoadTarget(&vagrant_server.Target{
Name: match[1],
Project: c.project.Ref(),
})
if err != nil {
return err
}
c.targets = append(c.targets, t)
// Shift the args
c.args = c.args[1:]
// Explicitly set remote
c.flagRemote = true
}
}
// If we didn't get our ref, then we need to load config
if len(c.targets) == 0 {
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
}
// vagrantfile, err := c.basis.ParseVagrantfile()
// if err != nil {
// c.logError(c.Log, "failed to parse vagrantfile", err)
// return err
// }
// cfg.Project, err = cfg.LoadProject(vagrantfile, c.project.Ref())
// if err != nil {
// c.logError(c.Log, "failed to load project", 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(&vagrant_server.Target{
Name: c.flagTarget,
Project: c.project.Ref(),
})
if err != nil {
return err
}
c.targets = append(c.targets, t)
}
}
// Create our client
// if baseCfg.Client {
// var err error
// c.basis, err = c.initClient()
// if err != nil {
// c.logError(c.Log, "failed to create client", err)
// return err
// }
// }
// Validate remote vs. local operations.
if c.flagRemote && len(c.targets) == 0 {
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 this is a single app mode then make sure that we only have
// one app or that we have an app target.
if false && baseCfg.TargetRequired {
if len(c.targets) == 0 {
if len(c.cfg.Project.Targets) != 1 {
c.ui.Output(errTargetModeSingle, terminal.WithErrorStyle())
return ErrSentinel
}
if c.project == nil {
c.project, err = c.basis.LoadProject(
&vagrant_server.Project{
Name: c.cfg.Project.Location,
Path: c.cfg.Project.Location,
Basis: c.basis.Ref(),
},
)
if err != nil {
return err
}
}
target, err := c.project.LoadTarget(&vagrant_server.Target{
Name: c.cfg.Project.Targets[0].Name,
Project: c.project.Ref(),
})
if err != nil {
return err
}
c.targets = append(c.targets, target)
}
}
return nil
}
type Tasker interface {
UI() terminal.UI
Task(context.Context, *vagrant_server.Job_RunOp) (*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, Tasker) error) (finalErr error) {
// Start with checking if we are running in a machine based scope
if len(c.targets) > 0 {
for _, m := range c.targets {
c.Log.Warn("running command on machine", "machine", m)
// If the context has been canceled, then bail
if err := ctx.Err(); err != nil {
return err
}
if err := f(ctx, m); err != nil {
if err != ErrSentinel {
finalErr = multierror.Append(finalErr, err)
}
}
}
return
}
// Now we can check if this is project scoped
if c.project != nil {
finalErr = f(ctx, c.project)
return
}
// And if we're still here, it's gotta be basis scoped
return f(ctx, c.basis)
}
// 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.StringMapVar(
&c.flagLabels,
"label",
1,
MaxStringMapArgs,
set.Description("Labels to set for this operation. Can be specified multiple times."),
)
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_]+)$`)
)