cli, lib, lang: Port to new cli library

The new version of the urfave/cli library is moving to generics, and
it's completely unclear to me why this is an improvement. Their new API
is very complicated to understand, which for me, defeats the purpose of
golang.

In parallel, I needed to do some upcoming cli API refactoring, so this
was a good time to look into new libraries. After a review of the
landscape, I found the alexflint/go-arg library which has a delightfully
elegant API. It does have a few rough edges, but it's otherwise very
usable, and I think it would be straightforward to add features and fix
issues.

Thanks Alex!
This commit is contained in:
James Shubin
2024-03-01 18:09:06 -05:00
parent e767655ede
commit 589a5f9aeb
32 changed files with 609 additions and 1047 deletions

View File

@@ -21,379 +21,127 @@
package cli
import (
"context"
"fmt"
"log"
"sort"
"os"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/gapi"
_ "github.com/purpleidea/mgmt/lang" // import so the GAPI registers
_ "github.com/purpleidea/mgmt/yamlgraph"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/urfave/cli/v2"
"github.com/alexflint/go-arg"
)
// CLI is the entry point for using mgmt normally from the CLI.
func CLI(data *cliUtil.Data) error {
func CLI(ctx context.Context, data *cliUtil.Data) error {
// test for sanity
if data == nil {
return fmt.Errorf("this CLI was not run correctly")
}
if data.Program == "" || data.Version == "" {
return fmt.Errorf("program was not compiled correctly, see Makefile")
return fmt.Errorf("program was not compiled correctly")
}
if data.Copying == "" {
return fmt.Errorf("program copyrights were removed, can't run")
}
// All of these flags can be accessed in your GAPI implementation with
// the `c.Lineage()[1].Type` and `c.Lineage()[1].IsSet` functions. Their
// own flags can be accessed with `c.Type` and `c.IsSet` directly.
runFlags := []cli.Flag{
// common flags which all can use
args := Args{}
args.version = data.Version // copy this in
args.description = data.Tagline
// useful for testing multiple instances on same machine
&cli.StringFlag{
Name: "hostname",
Value: "",
Usage: "hostname to use",
},
&cli.StringFlag{
Name: "prefix",
Usage: "specify a path to the working prefix directory",
EnvVars: []string{"MGMT_PREFIX"},
},
&cli.BoolFlag{
Name: "tmp-prefix",
Usage: "request a pseudo-random, temporary prefix to be used",
},
&cli.BoolFlag{
Name: "allow-tmp-prefix",
Usage: "allow creation of a new temporary prefix if main prefix is unavailable",
},
&cli.BoolFlag{
Name: "no-watch",
Usage: "do not update graph under any switch events",
},
&cli.BoolFlag{
Name: "no-stream-watch",
Usage: "do not update graph on stream switch events",
},
&cli.BoolFlag{
Name: "no-deploy-watch",
Usage: "do not change deploys after an initial deploy",
},
&cli.BoolFlag{
Name: "noop",
Usage: "globally force all resources into no-op mode",
},
&cli.IntFlag{
Name: "sema",
Value: -1,
Usage: "globally add a semaphore to all resources with this lock count",
},
&cli.StringFlag{
Name: "graphviz, g",
Value: "",
Usage: "output file for graphviz data",
},
&cli.StringFlag{
Name: "graphviz-filter, gf",
Value: "",
Usage: "graphviz filter to use",
},
&cli.Int64Flag{
Name: "converged-timeout, t",
Value: -1,
Usage: "after approximately this many seconds without activity, we're considered to be in a converged state",
EnvVars: []string{"MGMT_CONVERGED_TIMEOUT"},
},
&cli.BoolFlag{
Name: "converged-timeout-no-exit",
Usage: "don't exit on converged-timeout",
},
&cli.StringFlag{
Name: "converged-status-file",
Value: "",
Usage: "file to append the current converged state to, mostly used for testing",
},
&cli.IntFlag{
Name: "max-runtime",
Value: 0,
Usage: "exit after a maximum of approximately this many seconds",
EnvVars: []string{"MGMT_MAX_RUNTIME"},
},
// if empty, it will startup a new server
&cli.StringSliceFlag{
Name: "seeds, s",
Value: &cli.StringSlice{}, // empty slice
Usage: "default etc client endpoint",
EnvVars: []string{"MGMT_SEEDS"},
},
// port 2379 and 4001 are common
&cli.StringSliceFlag{
Name: "client-urls",
Value: &cli.StringSlice{},
Usage: "list of URLs to listen on for client traffic",
EnvVars: []string{"MGMT_CLIENT_URLS"},
},
// port 2380 and 7001 are common
&cli.StringSliceFlag{
Name: "server-urls, peer-urls",
Value: &cli.StringSlice{},
Usage: "list of URLs to listen on for server (peer) traffic",
EnvVars: []string{"MGMT_SERVER_URLS"},
},
// port 2379 and 4001 are common
&cli.StringSliceFlag{
Name: "advertise-client-urls",
Value: &cli.StringSlice{},
Usage: "list of URLs to listen on for client traffic",
EnvVars: []string{"MGMT_ADVERTISE_CLIENT_URLS"},
},
// port 2380 and 7001 are common
&cli.StringSliceFlag{
Name: "advertise-server-urls, advertise-peer-urls",
Value: &cli.StringSlice{},
Usage: "list of URLs to listen on for server (peer) traffic",
EnvVars: []string{"MGMT_ADVERTISE_SERVER_URLS"},
},
&cli.IntFlag{
Name: "ideal-cluster-size",
Value: -1,
Usage: "ideal number of server peers in cluster; only read by initial server",
EnvVars: []string{"MGMT_IDEAL_CLUSTER_SIZE"},
},
&cli.BoolFlag{
Name: "no-server",
Usage: "do not start embedded etcd server (do not promote from client to peer)",
},
&cli.BoolFlag{
Name: "no-network",
Usage: "run single node instance without clustering or opening tcp ports to the outside",
EnvVars: []string{"MGMT_NO_NETWORK"},
},
&cli.BoolFlag{
Name: "no-pgp",
Usage: "don't create pgp keys",
},
&cli.StringFlag{
Name: "pgp-key-path",
Value: "",
Usage: "path for instance key pair",
},
&cli.StringFlag{
Name: "pgp-identity",
Value: "",
Usage: "default identity used for generation",
},
&cli.BoolFlag{
Name: "prometheus",
Usage: "start a prometheus instance",
},
&cli.StringFlag{
Name: "prometheus-listen",
Value: "",
Usage: "specify prometheus instance binding",
},
config := arg.Config{
Program: data.Program,
}
deployFlags := []cli.Flag{
// common flags which all can use
&cli.StringSliceFlag{
Name: "seeds, s",
Value: &cli.StringSlice{}, // empty slice
Usage: "default etc client endpoint",
EnvVars: []string{"MGMT_SEEDS"},
},
&cli.BoolFlag{
Name: "noop",
Usage: "globally force all resources into no-op mode",
},
&cli.IntFlag{
Name: "sema",
Value: -1,
Usage: "globally add a semaphore to all resources with this lock count",
},
&cli.BoolFlag{
Name: "no-git",
Usage: "don't look at git commit id for safe deploys",
},
&cli.BoolFlag{
Name: "force",
Usage: "force a new deploy, even if the safety chain would break",
},
parser, err := arg.NewParser(config, &args)
if err != nil {
// programming error
return errwrap.Wrapf(err, "cli config error")
}
getFlags := []cli.Flag{
// common flags which all can use
&cli.BoolFlag{
Name: "noop",
Usage: "simulate the download (can't recurse)",
},
&cli.IntFlag{
Name: "sema",
Value: -1, // maximum parallelism
Usage: "globally add a semaphore to downloads with this lock count",
},
&cli.BoolFlag{
Name: "update",
Usage: "update all dependencies to the latest versions",
},
err = parser.Parse(data.Args[1:]) // XXX: args[0] needs to be dropped
if err == arg.ErrHelp {
parser.WriteHelp(os.Stdout)
return nil
}
if err == arg.ErrVersion {
fmt.Printf("%s\n", data.Version) // byon: bring your own newline
return nil
}
if err != nil {
//parser.WriteHelp(os.Stdout) // TODO: is doing this helpful?
return cliUtil.CliParseError(err) // consistent errors
}
subCommandsRun := []*cli.Command{} // run sub commands
subCommandsDeploy := []*cli.Command{} // deploy sub commands
subCommandsGet := []*cli.Command{} // get (download) sub commands
names := []string{}
for name := range gapi.RegisteredGAPIs {
names = append(names, name)
}
sort.Strings(names) // ensure deterministic order when parsing
for _, x := range names {
name := x // create a copy in this scope
fn := gapi.RegisteredGAPIs[name]
gapiObj := fn()
commandRun := &cli.Command{
Name: name,
Usage: fmt.Sprintf("run using the `%s` frontend", name),
Action: func(c *cli.Context) error {
if err := run(c, name, gapiObj); err != nil {
log.Printf("run: error: %v", err)
//return cli.NewExitError(err.Error(), 1) // TODO: ?
return cli.NewExitError("", 1)
}
return nil
},
Flags: gapiObj.CliFlags(gapi.CommandRun),
}
subCommandsRun = append(subCommandsRun, commandRun)
commandDeploy := &cli.Command{
Name: name,
Usage: fmt.Sprintf("deploy using the `%s` frontend", name),
Action: func(c *cli.Context) error {
if err := deploy(c, name, gapiObj); err != nil {
log.Printf("deploy: error: %v", err)
//return cli.NewExitError(err.Error(), 1) // TODO: ?
return cli.NewExitError("", 1)
}
return nil
},
Flags: gapiObj.CliFlags(gapi.CommandDeploy),
}
subCommandsDeploy = append(subCommandsDeploy, commandDeploy)
if _, ok := gapiObj.(gapi.GettableGAPI); ok {
commandGet := &cli.Command{
Name: name,
Usage: fmt.Sprintf("get (download) using the `%s` frontend", name),
Action: func(c *cli.Context) error {
if err := get(c, name, gapiObj); err != nil {
log.Printf("get: error: %v", err)
//return cli.NewExitError(err.Error(), 1) // TODO: ?
return cli.NewExitError("", 1)
}
return nil
},
Flags: gapiObj.CliFlags(gapi.CommandGet),
}
subCommandsGet = append(subCommandsGet, commandGet)
}
}
app := cli.NewApp()
app.Name = data.Program // App.name and App.version pass these values through
app.Version = data.Version
app.Usage = "next generation config management"
app.Metadata = map[string]interface{}{ // additional flags
"flags": data.Flags,
}
// if no app.Command is specified
app.Action = func(c *cli.Context) error {
// print the license
if c.Bool("license") {
fmt.Printf("%s", data.Copying)
return nil
}
// print help if no flags are set
cli.ShowAppHelp(c)
// display the license
if args.License {
fmt.Printf("%s", data.Copying) // file comes with a trailing nl
return nil
}
// global flags
app.Flags = []cli.Flag{
&cli.BoolFlag{
Name: "license",
Usage: "prints the software license",
},
if ok, err := args.Run(ctx, data); err != nil {
return err
} else if ok { // did we activate one of the commands?
return nil
}
app.Commands = []*cli.Command{
//{
// Name: gapi.CommandTODO,
// Aliases: []string{"TODO"},
// Usage: "TODO",
// Action: TODO,
// Flags: TODOFlags,
//},
}
// print help if no subcommands are set
parser.WriteHelp(os.Stdout)
// run always requires a frontend to start the engine, but if you don't
// want a graph, you can use the `empty` frontend. The engine backend is
// agnostic to which frontend is running, in fact, you can deploy with
// multiple different frontends, one after another, on the same engine.
if len(subCommandsRun) > 0 {
commandRun := &cli.Command{
Name: gapi.CommandRun,
Aliases: []string{"r"},
Usage: "Run code on this machine",
Subcommands: subCommandsRun,
Flags: runFlags,
}
app.Commands = append(app.Commands, commandRun)
}
if len(subCommandsDeploy) > 0 {
commandDeploy := &cli.Command{
Name: gapi.CommandDeploy,
Aliases: []string{"d"},
Usage: "Deploy code into the cluster",
Subcommands: subCommandsDeploy,
Flags: deployFlags,
}
app.Commands = append(app.Commands, commandDeploy)
}
if len(subCommandsGet) > 0 {
commandGet := &cli.Command{
Name: gapi.CommandGet,
Aliases: []string{"g"},
Usage: "Download code from the internet",
Subcommands: subCommandsGet,
Flags: getFlags,
}
app.Commands = append(app.Commands, commandGet)
}
commandEtcd := &cli.Command{
Name: "etcd",
//Aliases: []string{"e"},
Usage: "Run standalone etcd",
Action: func(*cli.Context) error {
// this never runs, it gets preempted in the real main()
return nil
},
}
app.Commands = append(app.Commands, commandEtcd)
app.EnableBashCompletion = true
return app.Run(data.Args)
return nil
}
// Args is the CLI parsing structure and type of the parsed result. This
// particular struct is the top-most one.
type Args struct {
// XXX: We cannot have both subcommands and a positional argument.
// XXX: I think it's a bug of this library that it can't handle argv[0].
//Argv0 string `arg:"positional"`
License bool `arg:"--license" help:"display the license and exit"`
RunCmd *RunArgs `arg:"subcommand:run" help:"run code on this machine"`
DeployCmd *DeployArgs `arg:"subcommand:deploy" help:"deploy code into a cluster"`
// This never runs, it gets preempted in the real main() function.
// XXX: Can we do it nicely with the new arg parser? can it ignore all args?
EtcdCmd *EtcdArgs `arg:"subcommand:etcd" help:"run standalone etcd"`
// version is a private handle for our version string.
version string `arg:"-"` // ignored from parsing
// description is a private handle for our description string.
description string `arg:"-"` // ignored from parsing
}
// Version returns the version string. Implementing this signature is part of
// the API for the cli library.
func (obj *Args) Version() string {
return obj.version
}
// Description returns a description string. Implementing this signature is part
// of the API for the cli library.
func (obj *Args) Description() string {
return obj.description
}
// Run executes the correct subcommand. It errors if there's ever an error. It
// returns true if we did activate one of the subcommands. It returns false if
// we did not. This information is used so that the top-level parser can return
// usage or help information if no subcommand activates.
func (obj *Args) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
if cmd := obj.RunCmd; cmd != nil {
return cmd.Run(ctx, data)
}
if cmd := obj.DeployCmd; cmd != nil {
return cmd.Run(ctx, data)
}
// NOTE: we could return true, fmt.Errorf("...") if more than one did
return false, nil // nobody activated
}
// EtcdArgs is the CLI parsing structure and type of the parsed result. This
// particular one is empty because the `etcd` subcommand is preempted in the
// real main() function.
type EtcdArgs struct{}

View File

@@ -22,58 +22,103 @@ import (
"fmt"
"log"
"os"
"os/signal"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/etcd/client"
"github.com/purpleidea/mgmt/etcd/deployer"
etcdfs "github.com/purpleidea/mgmt/etcd/fs"
"github.com/purpleidea/mgmt/gapi"
emptyGAPI "github.com/purpleidea/mgmt/gapi/empty"
langGAPI "github.com/purpleidea/mgmt/lang/gapi"
"github.com/purpleidea/mgmt/lib"
"github.com/purpleidea/mgmt/util/errwrap"
yamlGAPI "github.com/purpleidea/mgmt/yamlgraph"
"github.com/pborman/uuid"
"github.com/urfave/cli/v2"
git "gopkg.in/src-d/go-git.v4"
)
// deploy is the cli target to manage deploys to our cluster.
// TODO: add a timeout and/or cancel signal to replace context.TODO()
func deploy(c *cli.Context, name string, gapiObj gapi.GAPI) error {
cliContext := c.Lineage()[1]
if cliContext == nil {
return fmt.Errorf("could not get cli context")
// DeployArgs is the CLI parsing structure and type of the parsed result. This
// particular one contains all the common flags for the `deploy` subcommand
// which all frontends can use.
type DeployArgs struct {
Seeds []string `arg:"--seeds,env:MGMT_SEEDS" help:"default etc client endpoint"`
Noop bool `arg:"--noop" help:"globally force all resources into no-op mode"`
Sema int `arg:"--sema" default:"-1" help:"globally add a semaphore to all resources with this lock count"`
NoGit bool `arg:"--no-git" help:"don't look at git commit id for safe deploys"`
Force bool `arg:"--force" help:"force a new deploy, even if the safety chain would break"`
DeployEmpty *emptyGAPI.Args `arg:"subcommand:empty" help:"deploy empty payload"`
DeployLang *langGAPI.Args `arg:"subcommand:lang" help:"deploy lang (mcl) payload"`
DeployYaml *yamlGAPI.Args `arg:"subcommand:yaml" help:"deploy yaml graph payload"`
}
// Run executes the correct subcommand. It errors if there's ever an error. It
// returns true if we did activate one of the subcommands. It returns false if
// we did not. This information is used so that the top-level parser can return
// usage or help information if no subcommand activates. This particular Run is
// the run for the main `deploy` subcommand. This always requires a frontend to
// deploy to the cluster, but if you don't want a graph, you can use the `empty`
// frontend. The engine backend is agnostic to which frontend is deployed, in
// fact, you can deploy with multiple different frontends, one after another, on
// the same engine.
func (obj *DeployArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
var name string
var args interface{}
if cmd := obj.DeployEmpty; cmd != nil {
name = emptyGAPI.Name
args = cmd
}
if cmd := obj.DeployLang; cmd != nil {
name = langGAPI.Name
args = cmd
}
if cmd := obj.DeployYaml; cmd != nil {
name = yamlGAPI.Name
args = cmd
}
program, version := cliUtil.SafeProgram(c.App.Name), c.App.Version
var flags cliUtil.Flags
var debug bool
if val, exists := c.App.Metadata["flags"]; exists {
if f, ok := val.(cliUtil.Flags); ok {
flags = f
debug = flags.Debug
// XXX: workaround https://github.com/alexflint/go-arg/issues/239
if l := len(obj.Seeds); name == "" && l > 1 {
elem := obj.Seeds[l-2] // second to last element
if elem == emptyGAPI.Name || elem == langGAPI.Name || elem == yamlGAPI.Name {
return false, cliUtil.CliParseError(cliUtil.MissingEquals) // consistent errors
}
}
fn, exists := gapi.RegisteredGAPIs[name]
if !exists {
return false, nil // did not activate
}
gapiObj := fn()
program, version := data.Program, data.Version
Logf := func(format string, v ...interface{}) {
log.Printf("deploy: "+format, v...)
}
cliUtil.Hello(program, version, flags) // say hello!
// TODO: consider adding a timeout based on an args.Timeout flag ?
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
cliUtil.Hello(program, version, data.Flags) // say hello!
defer Logf("goodbye!")
var hash, pHash string
if !cliContext.Bool("no-git") {
if !obj.NoGit {
wd, err := os.Getwd()
if err != nil {
return errwrap.Wrapf(err, "could not get current working directory")
return false, errwrap.Wrapf(err, "could not get current working directory")
}
repo, err := git.PlainOpen(wd)
if err != nil {
return errwrap.Wrapf(err, "could not open git repo")
return false, errwrap.Wrapf(err, "could not open git repo")
}
head, err := repo.Head()
if err != nil {
return errwrap.Wrapf(err, "could not read git HEAD")
return false, errwrap.Wrapf(err, "could not read git HEAD")
}
hash = head.Hash().String() // current commit id
@@ -84,32 +129,32 @@ func deploy(c *cli.Context, name string, gapiObj gapi.GAPI) error {
}
commits, err := repo.Log(lo)
if err != nil {
return errwrap.Wrapf(err, "could not read git log")
return false, errwrap.Wrapf(err, "could not read git log")
}
if _, err := commits.Next(); err != nil { // skip over HEAD
return errwrap.Wrapf(err, "could not read HEAD in git log") // weird!
return false, errwrap.Wrapf(err, "could not read HEAD in git log") // weird!
}
commit, err := commits.Next()
if err == nil { // errors are okay, we might be empty
pHash = commit.Hash.String() // previous commit id
}
Logf("previous deploy hash: %s", pHash)
if cliContext.Bool("force") {
if obj.Force {
pHash = "" // don't check this :(
}
if hash == "" {
return errwrap.Wrapf(err, "could not get git deploy hash")
return false, errwrap.Wrapf(err, "could not get git deploy hash")
}
}
uniqueid := uuid.New() // panic's if it can't generate one :P
etcdClient := client.NewClientFromSeedsNamespace(
cliContext.StringSlice("seeds"), // endpoints
obj.Seeds, // endpoints
lib.NS,
)
if err := etcdClient.Init(); err != nil {
return errwrap.Wrapf(err, "client Init failed")
return false, errwrap.Wrapf(err, "client Init failed")
}
defer func() {
err := errwrap.Wrapf(etcdClient.Close(), "client Close failed")
@@ -121,13 +166,13 @@ func deploy(c *cli.Context, name string, gapiObj gapi.GAPI) error {
simpleDeploy := &deployer.SimpleDeploy{
Client: etcdClient,
Debug: debug,
Debug: data.Flags.Debug,
Logf: func(format string, v ...interface{}) {
Logf("deploy: "+format, v...)
},
}
if err := simpleDeploy.Init(); err != nil {
return errwrap.Wrapf(err, "deploy Init failed")
return false, errwrap.Wrapf(err, "deploy Init failed")
}
defer func() {
err := errwrap.Wrapf(simpleDeploy.Close(), "deploy Close failed")
@@ -138,9 +183,9 @@ func deploy(c *cli.Context, name string, gapiObj gapi.GAPI) error {
}()
// get max id (from all the previous deploys)
max, err := simpleDeploy.GetMaxDeployID(context.TODO())
max, err := simpleDeploy.GetMaxDeployID(ctx)
if err != nil {
return errwrap.Wrapf(err, "error getting max deploy id")
return false, errwrap.Wrapf(err, "error getting max deploy id")
}
// find the latest id
var id = max + 1 // next id
@@ -152,44 +197,49 @@ func deploy(c *cli.Context, name string, gapiObj gapi.GAPI) error {
Metadata: lib.MetadataPrefix + fmt.Sprintf("/deploy/%d-%s", id, uniqueid),
DataPrefix: lib.StoragePrefix,
Debug: debug,
Debug: data.Flags.Debug,
Logf: func(format string, v ...interface{}) {
Logf("fs: "+format, v...)
},
}
cliInfo := &gapi.CliInfo{
CliContext: c, // don't pass in the parent context
info := &gapi.Info{
Args: args,
Flags: &gapi.Flags{
Noop: obj.Noop,
Sema: obj.Sema,
//Update: obj.Update,
},
Fs: etcdFs,
Debug: debug,
Debug: data.Flags.Debug,
Logf: func(format string, v ...interface{}) {
// TODO: is this a sane prefix to use here?
log.Printf("cli: "+format, v...)
},
}
deploy, err := gapiObj.Cli(cliInfo)
deploy, err := gapiObj.Cli(info)
if err != nil {
return errwrap.Wrapf(err, "cli parse error")
return false, cliUtil.CliParseError(err) // consistent errors
}
if deploy == nil { // not used
return fmt.Errorf("not enough information specified")
return false, fmt.Errorf("not enough information specified")
}
// redundant
deploy.Noop = cliContext.Bool("noop")
deploy.Sema = cliContext.Int("sema")
deploy.Noop = obj.Noop
deploy.Sema = obj.Sema
str, err := deploy.ToB64()
if err != nil {
return errwrap.Wrapf(err, "encoding error")
return false, errwrap.Wrapf(err, "encoding error")
}
// this nominally checks the previous git hash matches our expectation
if err := simpleDeploy.AddDeploy(context.TODO(), id, hash, pHash, &str); err != nil {
return errwrap.Wrapf(err, "could not create deploy id `%d`", id)
if err := simpleDeploy.AddDeploy(ctx, id, hash, pHash, &str); err != nil {
return false, errwrap.Wrapf(err, "could not create deploy id `%d`", id)
}
Logf("success, id: %d", id)
return nil
return true, nil
}

View File

@@ -1,74 +0,0 @@
// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cli
import (
"fmt"
"log"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/gapi"
"github.com/urfave/cli/v2"
)
// get is the cli target to run code/import downloads.
func get(c *cli.Context, name string, gapiObj gapi.GAPI) error {
cliContext := c.Lineage()[1]
if cliContext == nil {
return fmt.Errorf("could not get cli context")
}
program, version := cliUtil.SafeProgram(c.App.Name), c.App.Version
var flags cliUtil.Flags
var debug bool
if val, exists := c.App.Metadata["flags"]; exists {
if f, ok := val.(cliUtil.Flags); ok {
flags = f
debug = flags.Debug
}
}
cliUtil.Hello(program, version, flags) // say hello!
gettable, ok := gapiObj.(gapi.GettableGAPI)
if !ok {
// this is a programming bug as this should not get called...
return fmt.Errorf("the `%s` GAPI does not implement: %s", name, gapi.CommandGet)
}
getInfo := &gapi.GetInfo{
CliContext: c, // don't pass in the parent context
Noop: cliContext.Bool("noop"),
Sema: cliContext.Int("sema"),
Update: cliContext.Bool("update"),
Debug: debug,
Logf: func(format string, v ...interface{}) {
// TODO: is this a sane prefix to use here?
log.Printf(name+": "+format, v...)
},
}
if err := gettable.Get(getInfo); err != nil {
return err // no need to errwrap here
}
log.Printf("%s: success!", name)
return nil
}

View File

@@ -18,6 +18,7 @@
package cli
import (
"context"
"fmt"
"log"
"os"
@@ -27,50 +28,140 @@ import (
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/gapi"
emptyGAPI "github.com/purpleidea/mgmt/gapi/empty"
langGAPI "github.com/purpleidea/mgmt/lang/gapi"
"github.com/purpleidea/mgmt/lib"
"github.com/purpleidea/mgmt/util"
"github.com/purpleidea/mgmt/util/errwrap"
yamlGAPI "github.com/purpleidea/mgmt/yamlgraph"
"github.com/spf13/afero"
"github.com/urfave/cli/v2"
)
// run is the main run target.
func run(c *cli.Context, name string, gapiObj gapi.GAPI) error {
cliContext := c.Lineage()[1] // these are the flags from `run`
if cliContext == nil {
return fmt.Errorf("could not get cli context")
// RunArgs is the CLI parsing structure and type of the parsed result. This
// particular one contains all the common flags for the `run` subcommand which
// all frontends can use.
type RunArgs struct {
// useful for testing multiple instances on same machine
Hostname *string `arg:"--hostname" help:"hostname to use"`
Prefix *string `arg:"--prefix,env:MGMT_PREFIX" help:"specify a path to the working prefix directory"`
TmpPrefix bool `arg:"--tmp-prefix" help:"request a pseudo-random, temporary prefix to be used"`
AllowTmpPrefix bool `arg:"--allow-tmp-prefix" help:"allow creation of a new temporary prefix if main prefix is unavailable"`
NoWatch bool `arg:"--no-watch" help:"do not update graph under any switch events"`
NoStreamWatch bool `arg:"--no-stream-watch" help:"do not update graph on stream switch events"`
NoDeployWatch bool `arg:"--no-deploy-watch" help:"do not change deploys after an initial deploy"`
Noop bool `arg:"--noop" help:"globally force all resources into no-op mode"`
Sema int `arg:"--sema" default:"-1" help:"globally add a semaphore to downloads with this lock count"`
Graphviz string `arg:"--graphviz" help:"output file for graphviz data"`
GraphvizFilter string `arg:"--graphviz-filter" help:"graphviz filter to use"`
ConvergedTimeout int `arg:"--converged-timeout,env:MGMT_CONVERGED_TIMEOUT" default:"-1" help:"after approximately this many seconds without activity, we're considered to be in a converged state"`
ConvergedTimeoutNoExit bool `arg:"--converged-timeout-no-exit" help:"don't exit on converged-timeout"`
ConvergedStatusFile string `arg:"--converged-status-file" help:"file to append the current converged state to, mostly used for testing"`
MaxRuntime uint `arg:"--max-runtime,env:MGMT_MAX_RUNTIME" help:"exit after a maximum of approximately this many seconds"`
// if empty, it will startup a new server
Seeds []string `arg:"--seeds,env:MGMT_SEEDS" help:"default etc client endpoint"`
// port 2379 and 4001 are common
ClientURLs []string `arg:"--client-urls,env:MGMT_CLIENT_URLS" help:"list of URLs to listen on for client traffic"`
// port 2380 and 7001 are common
// etcd now uses --peer-urls
ServerURLs []string `arg:"--server-urls,env:MGMT_SERVER_URLS" help:"list of URLs to listen on for server (peer) traffic"`
// port 2379 and 4001 are common
AdvertiseClientURLs []string `arg:"--advertise-client-urls,env:MGMT_ADVERTISE_CLIENT_URLS" help:"list of URLs to listen on for client traffic"`
// port 2380 and 7001 are common
// etcd now uses --advertise-peer-urls
AdvertiseServerURLs []string `arg:"--advertise-server-urls,env:MGMT_ADVERTISE_SERVER_URLS" help:"list of URLs to listen on for server (peer) traffic"`
IdealClusterSize int `arg:"--ideal-cluster-size,env:MGMT_IDEAL_CLUSTER_SIZE" default:"-1" help:"ideal number of server peers in cluster; only read by initial server"`
NoServer bool `arg:"--no-server" help:"do not start embedded etcd server (do not promote from client to peer)"`
NoNetwork bool `arg:"--no-network,env:MGMT_NO_NETWORK" help:"run single node instance without clustering or opening tcp ports to the outside"`
NoPgp bool `arg:"--no-pgp" help:"don't create pgp keys"`
PgpKeyPath *string `arg:"--pgp-key-path" help:"path for instance key pair"`
PgpIdentity *string `arg:"--pgp-identity" help:"default identity used for generation"`
Prometheus bool `arg:"--prometheus" help:"start a prometheus instance"`
PrometheusListen string `arg:"--prometheus-listen" help:"specify prometheus instance binding"`
RunEmpty *emptyGAPI.Args `arg:"subcommand:empty" help:"run empty payload"`
RunLang *langGAPI.Args `arg:"subcommand:lang" help:"run lang (mcl) payload"`
RunYaml *yamlGAPI.Args `arg:"subcommand:yaml" help:"run yaml graph payload"`
}
// Run executes the correct subcommand. It errors if there's ever an error. It
// returns true if we did activate one of the subcommands. It returns false if
// we did not. This information is used so that the top-level parser can return
// usage or help information if no subcommand activates. This particular Run is
// the run for the main `run` subcommand. This always requires a frontend to
// start the engine, but if you don't want a graph, you can use the `empty`
// frontend. The engine backend is agnostic to which frontend is running, in
// fact, you can deploy with multiple different frontends, one after another, on
// the same engine.
func (obj *RunArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
var name string
var args interface{}
if cmd := obj.RunEmpty; cmd != nil {
name = emptyGAPI.Name
args = cmd
}
if cmd := obj.RunLang; cmd != nil {
name = langGAPI.Name
args = cmd
}
if cmd := obj.RunYaml; cmd != nil {
name = yamlGAPI.Name
args = cmd
}
// XXX: workaround https://github.com/alexflint/go-arg/issues/239
lists := [][]string{
obj.Seeds,
obj.ClientURLs,
obj.ServerURLs,
obj.AdvertiseClientURLs,
obj.AdvertiseServerURLs,
}
for _, list := range lists {
if l := len(list); name == "" && l > 1 {
elem := list[l-2] // second to last element
if elem == emptyGAPI.Name || elem == langGAPI.Name || elem == yamlGAPI.Name {
return false, cliUtil.CliParseError(cliUtil.MissingEquals) // consistent errors
}
}
}
fn, exists := gapi.RegisteredGAPIs[name]
if !exists {
return false, nil // did not activate
}
gapiObj := fn()
main := &lib.Main{}
main.Program, main.Version = cliUtil.SafeProgram(c.App.Name), c.App.Version
var flags cliUtil.Flags
if val, exists := c.App.Metadata["flags"]; exists {
if f, ok := val.(cliUtil.Flags); ok {
flags = f
main.Flags = lib.Flags{
Debug: f.Debug,
Verbose: f.Verbose,
}
}
main.Program, main.Version = data.Program, data.Version
main.Flags = lib.Flags{
Debug: data.Flags.Debug,
Verbose: data.Flags.Verbose,
}
Logf := func(format string, v ...interface{}) {
log.Printf("main: "+format, v...)
}
cliUtil.Hello(main.Program, main.Version, flags) // say hello!
cliUtil.Hello(main.Program, main.Version, data.Flags) // say hello!
defer Logf("goodbye!")
if h := cliContext.String("hostname"); cliContext.IsSet("hostname") && h != "" {
main.Hostname = &h
}
if s := cliContext.String("prefix"); cliContext.IsSet("prefix") && s != "" {
main.Prefix = &s
}
main.TmpPrefix = cliContext.Bool("tmp-prefix")
main.AllowTmpPrefix = cliContext.Bool("allow-tmp-prefix")
main.Hostname = obj.Hostname
main.Prefix = obj.Prefix
main.TmpPrefix = obj.TmpPrefix
main.AllowTmpPrefix = obj.AllowTmpPrefix
// create a memory backed temporary filesystem for storing runtime data
mmFs := afero.NewMemMapFs()
@@ -78,8 +169,14 @@ func run(c *cli.Context, name string, gapiObj gapi.GAPI) error {
standaloneFs := &util.AferoFs{Afero: afs}
main.DeployFs = standaloneFs
cliInfo := &gapi.CliInfo{
CliContext: c, // don't pass in the parent context
info := &gapi.Info{
Args: args,
Flags: &gapi.Flags{
Hostname: obj.Hostname,
Noop: obj.Noop,
Sema: obj.Sema,
//Update: obj.Update,
},
Fs: standaloneFs,
Debug: main.Flags.Debug,
@@ -88,12 +185,16 @@ func run(c *cli.Context, name string, gapiObj gapi.GAPI) error {
},
}
deploy, err := gapiObj.Cli(cliInfo)
deploy, err := gapiObj.Cli(info)
if err != nil {
return errwrap.Wrapf(err, "cli parse error")
return false, cliUtil.CliParseError(err) // consistent errors
}
if c.Bool("only-unify") && deploy == nil {
return nil // we end early
if cmd := obj.RunLang; cmd != nil && cmd.OnlyUnify && deploy == nil {
return true, nil // we end early
}
if cmd := obj.RunLang; cmd != nil && cmd.OnlyDownload && deploy == nil {
return true, nil // we end early
}
main.Deploy = deploy
if main.Deploy == nil {
@@ -102,47 +203,41 @@ func run(c *cli.Context, name string, gapiObj gapi.GAPI) error {
log.Printf("main: no frontend selected (no GAPI activated)")
}
main.NoWatch = cliContext.Bool("no-watch")
main.NoStreamWatch = cliContext.Bool("no-stream-watch")
main.NoDeployWatch = cliContext.Bool("no-deploy-watch")
main.NoWatch = obj.NoWatch
main.NoStreamWatch = obj.NoStreamWatch
main.NoDeployWatch = obj.NoDeployWatch
main.Noop = cliContext.Bool("noop")
main.Sema = cliContext.Int("sema")
main.Graphviz = cliContext.String("graphviz")
main.GraphvizFilter = cliContext.String("graphviz-filter")
main.ConvergedTimeout = cliContext.Int64("converged-timeout")
main.ConvergedTimeoutNoExit = cliContext.Bool("converged-timeout-no-exit")
main.ConvergedStatusFile = cliContext.String("converged-status-file")
main.MaxRuntime = uint(cliContext.Int("max-runtime"))
main.Noop = obj.Noop
main.Sema = obj.Sema
main.Graphviz = obj.Graphviz
main.GraphvizFilter = obj.GraphvizFilter
main.ConvergedTimeout = obj.ConvergedTimeout
main.ConvergedTimeoutNoExit = obj.ConvergedTimeoutNoExit
main.ConvergedStatusFile = obj.ConvergedStatusFile
main.MaxRuntime = obj.MaxRuntime
main.Seeds = cliContext.StringSlice("seeds")
main.ClientURLs = cliContext.StringSlice("client-urls")
main.ServerURLs = cliContext.StringSlice("server-urls")
main.AdvertiseClientURLs = cliContext.StringSlice("advertise-client-urls")
main.AdvertiseServerURLs = cliContext.StringSlice("advertise-server-urls")
main.IdealClusterSize = cliContext.Int("ideal-cluster-size")
main.NoServer = cliContext.Bool("no-server")
main.NoNetwork = cliContext.Bool("no-network")
main.Seeds = obj.Seeds
main.ClientURLs = obj.ClientURLs
main.ServerURLs = obj.ServerURLs
main.AdvertiseClientURLs = obj.AdvertiseClientURLs
main.AdvertiseServerURLs = obj.AdvertiseServerURLs
main.IdealClusterSize = obj.IdealClusterSize
main.NoServer = obj.NoServer
main.NoNetwork = obj.NoNetwork
main.NoPgp = cliContext.Bool("no-pgp")
main.NoPgp = obj.Prometheus
main.PgpKeyPath = obj.PgpKeyPath
main.PgpIdentity = obj.PgpIdentity
if kp := cliContext.String("pgp-key-path"); cliContext.IsSet("pgp-key-path") {
main.PgpKeyPath = &kp
}
if us := cliContext.String("pgp-identity"); cliContext.IsSet("pgp-identity") {
main.PgpIdentity = &us
}
main.Prometheus = cliContext.Bool("prometheus")
main.PrometheusListen = cliContext.String("prometheus-listen")
main.Prometheus = obj.Prometheus
main.PrometheusListen = obj.PrometheusListen
if err := main.Validate(); err != nil {
return err
return false, err
}
if err := main.Init(); err != nil {
return err
return false, err
}
// install the exit signal handler
@@ -200,10 +295,13 @@ func run(c *cli.Context, name string, gapiObj gapi.GAPI) error {
log.Printf("main: Close: %+v", err)
}
if reterr == nil {
return err
return false, err
}
reterr = errwrap.Append(reterr, err)
}
return reterr
if reterr != nil {
return false, reterr
}
return true, nil
}

View File

@@ -20,9 +20,29 @@ package util
import (
"strings"
"github.com/purpleidea/mgmt/util/errwrap"
)
// Error is a constant error type that implements error.
type Error string
// Error fulfills the error interface of this type.
func (e Error) Error() string { return string(e) }
const (
// MissingEquals means we probably hit the parsing bug.
// XXX: see: https://github.com/alexflint/go-arg/issues/239
MissingEquals = Error("missing equals sign for list element")
)
// CliParseError returns a consistent error if we have a CLI parsing issue.
func CliParseError(err error) error {
return errwrap.Wrapf(err, "cli parse error")
}
// Flags are some constant flags which are used throughout the program.
// TODO: Unify this with Debug and Logf ?
type Flags struct {
Debug bool // add additional log messages
Verbose bool // add extra log message output
@@ -33,6 +53,7 @@ type Data struct {
Program string
Version string
Copying string
Tagline string
Flags Flags
Args []string // os.Args usually
}