diff --git a/commands/project.go b/commands/project.go index 56021ee..135689a 100644 --- a/commands/project.go +++ b/commands/project.go @@ -2,12 +2,8 @@ package commands import ( "fmt" - "os" - "os/exec" - "path/filepath" "strings" - "github.com/phase2/rig/util" "github.com/urfave/cli" ) @@ -36,6 +32,9 @@ func (cmd *Project) Commands() []cli.Command { sync := ProjectSync{} command.Subcommands = append(command.Subcommands, sync.Commands()...) + doctor := ProjectDoctor{} + command.Subcommands = append(command.Subcommands, doctor.Commands()...) + if subcommands := cmd.GetScriptsAsSubcommands(command.Subcommands); subcommands != nil { command.Subcommands = append(command.Subcommands, subcommands...) } @@ -84,61 +83,18 @@ func (cmd *Project) Run(c *cli.Context) error { } key := strings.TrimPrefix(c.Command.Name, "run:") - if script, ok := cmd.Config.Scripts[key]; ok { - cmd.out.Verbose("Initializing project script '%s': %s", key, script.Description) - cmd.addCommandPath() - dir := filepath.Dir(cmd.Config.Path) - - // Concat the commands together adding the args to this command as args to the last step - scriptCommands := strings.Join(script.Run, cmd.GetCommandSeparator()) + " " + strings.Join(c.Args(), " ") - - shellCmd := cmd.GetCommand(scriptCommands) - shellCmd.Dir = dir - cmd.out.Verbose("Script execution - Working Directory: %s", dir) - - cmd.out.Verbose("Executing '%s' as '%s'", key, scriptCommands) - if exitCode := util.PassthruCommand(shellCmd); exitCode != 0 { + if script, ok := cmd.Config.Scripts[key]; !ok { + return cmd.Failure(fmt.Sprintf("Unrecognized script '%s'", key), "SCRIPT-NOT-FOUND", 12) + } else { + eval := ProjectEval{cmd.out, cmd.Config} + if exitCode := eval.ProjectScriptRun(script, c.Args()); exitCode != 0 { return cmd.Failure(fmt.Sprintf("Failure running project script '%s'", key), "COMMAND-ERROR", exitCode) } - } else { - return cmd.Failure(fmt.Sprintf("Unrecognized script '%s'", key), "SCRIPT-NOT-FOUND", 12) } return cmd.Success("") } -// GetCommand constructs a command to execute a configured script. -// @see https://github.com/medhoover/gom/blob/staging/config/command.go -func (cmd *Project) GetCommand(val string) *exec.Cmd { - if util.IsWindows() { - /* #nosec */ - return exec.Command("cmd", "/c", val) - } - - /* #nosec */ - return exec.Command("sh", "-c", val) -} - -// GetCommandSeparator returns the command separator based on platform. -func (cmd *Project) GetCommandSeparator() string { - if util.IsWindows() { - return " & " - } - - return " && " -} - -// addCommandPath overrides the PATH environment variable for further shell executions. -// This is used on POSIX systems for lookup of scripts. -func (cmd *Project) addCommandPath() { - binDir := cmd.Config.Bin - if binDir != "" { - cmd.out.Verbose("Script execution - Adding to $PATH: %s", binDir) - path := os.Getenv("PATH") - os.Setenv("PATH", fmt.Sprintf("%s%c%s", binDir, os.PathListSeparator, path)) - } -} - // ScriptRunHelp generates help details based on script configuration. func (cmd *Project) ScriptRunHelp(script *ProjectScript) string { help := fmt.Sprintf("\n\nSCRIPT STEPS:\n\t- ") diff --git a/commands/project_config.go b/commands/project_config.go index 813875d..eabf79e 100644 --- a/commands/project_config.go +++ b/commands/project_config.go @@ -14,6 +14,7 @@ import ( // ProjectScript is the struct for project defined commands type ProjectScript struct { + Id string Alias string Description string Run []string @@ -27,9 +28,9 @@ type Sync struct { // ProjectConfig is the struct for the outrigger.yml file type ProjectConfig struct { - File string - Path string - + File string + Path string + Doctor map[string]*Condition Scripts map[string]*ProjectScript Sync *Sync Namespace string @@ -79,6 +80,7 @@ func FindProjectConfigFilePath() (string, error) { // NewProjectConfigFromFile creates a new ProjectConfig from the specified file. // @todo do not use the logger here, instead return errors. // Use of the logger here initializes it in non-verbose mode. +// nolint: gocyclo func NewProjectConfigFromFile(filename string) (*ProjectConfig, error) { logger := util.Logger() filepath, _ := filepath.Abs(filename) @@ -108,6 +110,20 @@ func NewProjectConfigFromFile(filename string) (*ProjectConfig, error) { for id, script := range config.Scripts { if script != nil && script.Description == "" { config.Scripts[id].Description = fmt.Sprintf("Configured operation for '%s'", id) + config.Scripts[id].Id = id + } + } + + for id, condition := range config.Doctor { + if condition != nil { + config.Doctor[id].Id = id + if config.Doctor[id].Severity != "" { + if _, ok := util.IndexOfString(SeverityList(), config.Doctor[id].Severity); !ok { + logger.Channel.Error.Fatalf("Invalid severity (%s) for doctor condition (%s) in %s", config.Doctor[id].Severity, id, filename) + } + } else { + config.Doctor[id].Severity = "info" + } } } diff --git a/commands/project_doctor.go b/commands/project_doctor.go new file mode 100644 index 0000000..7518f89 --- /dev/null +++ b/commands/project_doctor.go @@ -0,0 +1,213 @@ +package commands + +import ( + "errors" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/urfave/cli" +) + +type ProjectDoctor struct { + BaseCommand + Config *ProjectConfig +} + +const ( + ConditionSeverityINFO string = "info" + ConditionSeverityWARNING string = "warning" + ConditionSeverityERROR string = "error" +) + +type Condition struct { + Id string + Name string + Test []string + Diagnosis string + Prescription string + Severity string +} + +type ConditionCollection map[string]*Condition + +func (cmd *ProjectDoctor) Commands() []cli.Command { + cmd.Config = NewProjectConfig() + + diagnose := cli.Command{ + Name: "doctor:diagnose", + Aliases: []string{"doctor"}, + Usage: "Run to evaluate project-level environment problems.", + Description: "This command validates known problems with the project environment. The rules can be extended via the 'doctor' section of the project configuration.", + Before: cmd.Before, + Action: cmd.RunAnalysis, + } + + compendium := cli.Command{ + Name: "doctor:conditions", + Aliases: []string{"doctor:list"}, + Usage: "Learn all the rules applied by the doctor:diagnose command.", + Description: "Display all the conditions for which the doctor:diagnose command will check.", + Before: cmd.Before, + Action: cmd.RunCompendium, + } + + return []cli.Command{diagnose, compendium} +} + +// RunAnalysis controls the doctor/diagnosis process. +func (cmd *ProjectDoctor) RunAnalysis(ctx *cli.Context) error { + fmt.Println("Project doctor evaluates project-specific environment issues.") + fmt.Println("You will find most of the checks defined in your Outrigger Project configuration (e.g., outrigger.yml)") + fmt.Println("These checks are not comprehensive, this is intended to automate common environment troubleshooting steps.") + fmt.Println() + compendium, _ := cmd.GetConditionCollection() + if err := cmd.AnalyzeConditionList(compendium); err != nil { + // Directly returning the framework error to skip the expanded help. + // A failing state is self-descriptive. + return cli.NewExitError(fmt.Sprintf("%v", err), 1) + } + + return nil +} + +// RunCompendium lists all conditions to be checked in the analysis. +func (cmd *ProjectDoctor) RunCompendium(ctx *cli.Context) error { + compendium, _ := cmd.GetConditionCollection() + cmd.out.Info("There are %d conditions in the repertoire.", len(compendium)) + fmt.Println(compendium) + + return nil +} + +// AnalyzeConditionList checks each registered condition against environment state. +func (cmd *ProjectDoctor) AnalyzeConditionList(conditions ConditionCollection) error { + var returnVal error + + failing := ConditionCollection{} + for _, condition := range conditions { + cmd.out.Spin(fmt.Sprintf("Examining project environment for %s", condition.Name)) + if found := cmd.Analyze(condition); !found { + cmd.out.Info("Not Affected by: %s [%s]", condition.Name, condition.Id) + } else { + switch condition.Severity { + case ConditionSeverityWARNING: + cmd.out.Warning("Condition Detected: %s [%s]", condition.Name, condition.Id) + failing[condition.Id] = condition + break + case ConditionSeverityERROR: + cmd.out.Error("Condition Detected: %s [%s]", condition.Name, condition.Id) + failing[condition.Id] = condition + if returnVal == nil { + returnVal = errors.New("Diagnosis found at least one failing condition.") + } + break + default: + cmd.out.Info("Condition Detected: %s [%s]", condition.Name, condition.Id) + } + } + } + + if len(failing) > 0 { + color.Red("\nThere were %d problems identified out of %d checked.\n", len(failing), len(conditions)) + fmt.Println(failing) + } + + return returnVal +} + +// GetConditionCollection assembles a list of all conditions. +func (cmd *ProjectDoctor) GetConditionCollection() (ConditionCollection, error) { + conditions := cmd.Config.Doctor + + // @TODO move these to outrigger.yml once we have pure shell facilities. + eval := ProjectEval{cmd.out, cmd.Config} + sync := ProjectSync{} + syncName := sync.GetVolumeName(cmd.Config, eval.GetWorkingDirectory()) + + // @todo we should have a way to determine if the project wants to use sync. + item1 := &Condition{ + Id: "sync-container-not-running", + Name: "Sync Container Not Working", + Test: []string{fmt.Sprintf("$(id=$(docker container ps -aq --filter 'name=^/%s$'); docker top $id &>/dev/null)", syncName)}, + Diagnosis: "The Sync container for this project is not available.", + Prescription: "Run 'rig project sync:start' before beginning work. This command may be included in other project-specific tasks.", + Severity: ConditionSeverityWARNING, + } + if _, ok := conditions["sync-container-not-running"]; !ok { + conditions["sync-container-not-running"] = item1 + } + + item2 := &Condition{ + Id: "sync-volume-missing", + Name: "Sync Volume is Missing", + Test: []string{fmt.Sprintf("$(id=$(docker container ps -aq --filter 'name=^/%s$'); docker top $id &>/dev/null)", syncName)}, + Diagnosis: "The Sync volume for this project is missing.", + Prescription: "Run 'rig project sync:start' before beginning work. This command may be included in other project-specific tasks.", + Severity: ConditionSeverityWARNING, + } + if _, ok := conditions["sync-volume-missing"]; !ok { + conditions["sync-volume-missing"] = item2 + } + + return conditions, nil +} + +// Analyze if a given condition criteria is met. +func (cmd *ProjectDoctor) Analyze(c *Condition) bool { + eval := ProjectEval{cmd.out, cmd.Config} + script := &ProjectScript{c.Id, "", c.Name, c.Test} + + if _, exitCode, err := eval.ProjectScriptResult(script, []string{}); err != nil { + cmd.out.Verbose("Condition '%s' analysis failed: (%d)", c.Id, exitCode) + cmd.out.Verbose("Error: %s", err.Error()) + return true + } + + return false +} + +// String converts a ConditionCollection to a string. +// @TODO use a good string concatenation technique, unlike this. +func (cc ConditionCollection) String() string { + str := "" + for _, condition := range cc { + str = fmt.Sprintf(fmt.Sprintf("%s\n%s\n", str, condition)) + } + return fmt.Sprintf(fmt.Sprintf("%s\n", str)) +} + +// String converts a Condition to a string. +func (c Condition) String() string { + return fmt.Sprintf("%s (%s)\n\tDESCRIPTION: %s\n\tSOLUTION: %s\n\t[%s]", + headline(c.Name), + severityFormat(c.Severity), + c.Diagnosis, + c.Prescription, + c.Id) +} + +func headline(value string) string { + h := color.New(color.Bold, color.Underline).SprintFunc() + return h(value) +} + +func severityFormat(severity string) string { + + switch severity { + case ConditionSeverityWARNING: + yellow := color.New(color.FgYellow).SprintFunc() + return yellow(strings.ToUpper(severity)) + case ConditionSeverityERROR: + red := color.New(color.FgRed).SprintFunc() + return red(strings.ToUpper(severity)) + } + + cyan := color.New(color.FgCyan).SprintFunc() + return cyan(strings.ToUpper(severity)) +} + +// SeverityList supplies the valid conditions as an array. +func SeverityList() []string { + return []string{ConditionSeverityINFO, ConditionSeverityWARNING, ConditionSeverityERROR} +} diff --git a/commands/project_eval.go b/commands/project_eval.go new file mode 100644 index 0000000..b5f4f02 --- /dev/null +++ b/commands/project_eval.go @@ -0,0 +1,84 @@ +package commands + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/phase2/rig/util" +) + +type ProjectEval struct { + out *util.RigLogger + config *ProjectConfig +} + +// ProjectScriptRun takes a ProjectScript configuration and executes it per +// the definition of the project script and bonus arguments from the extra parameter. +// Commands are run from the directory context of the project if available. +// Use ProjectScriptRun to run a comman for potential user interaction. +func (p *ProjectEval) ProjectScriptRun(script *ProjectScript, extra []string) int { + p.out.Verbose("Initializing project script '%s': %s", script.Id, script.Description) + p.addCommandPath() + dir := p.GetWorkingDirectory() + shellCmd := p.GetCommand(script.Run, extra, dir) + p.out.Verbose("Evaluating Script '%s'", script.Id) + return util.PassthruCommand(shellCmd) +} + +// ProjectScriptResult matches ProjectScriptRun, but returns the data from the +// command execution instead of "streaming" the result to the terminal. +func (p *ProjectEval) ProjectScriptResult(script *ProjectScript, extra []string) (string, int, error) { + p.out.Verbose("Initializing project script '%s': %s", script.Id, script.Description) + p.addCommandPath() + dir := p.GetWorkingDirectory() + shellCmd := p.GetCommand(script.Run, extra, dir) + p.out.Verbose("Evaluating Script '%s'", script.Id) + return util.CaptureCommand(shellCmd) +} + +// GetCommand constructs a command to execute a configured script. +// @see https://github.com/medhoover/gom/blob/staging/config/command.go +func (p *ProjectEval) GetCommand(steps, extra []string, workingDirectory string) *exec.Cmd { + // Concat the commands together adding the args to this command as args to the last step + scriptCommands := strings.Join(steps, p.getCommandSeparator()) + " " + strings.Join(extra, " ") + + var command *exec.Cmd + if util.IsWindows() { + /* #nosec */ + command = exec.Command("cmd", "/c", scriptCommands) + } else { + /* #nosec */ + command = exec.Command("sh", "-c", scriptCommands) + } + command.Dir = workingDirectory + + return command +} + +// GetWorkingDirectory retrieves the working directory for project commands. +func (p *ProjectEval) GetWorkingDirectory() string { + return filepath.Dir(p.config.Path) +} + +// getCommandSeparator returns the command separator based on platform. +func (p *ProjectEval) getCommandSeparator() string { + if util.IsWindows() { + return " & " + } + + return " && " +} + +// addCommandPath overrides the PATH environment variable for further shell executions. +// This is used on POSIX systems for lookup of scripts. +func (p *ProjectEval) addCommandPath() { + binDir := p.config.Bin + if binDir != "" { + p.out.Verbose("Adding project bin directory to $PATH: %s", binDir) + path := os.Getenv("PATH") + os.Setenv("PATH", fmt.Sprintf("%s%c%s", binDir, os.PathListSeparator, path)) + } +} diff --git a/examples/outrigger.example.yml b/examples/outrigger.example.yml index 7a546ea..378d6c6 100644 --- a/examples/outrigger.example.yml +++ b/examples/outrigger.example.yml @@ -88,4 +88,35 @@ sync: - "Name crazy-big-file.log" - "Path vendor/" - "Path build/logs" - - "Regex build/backups/.*\\.sql" \ No newline at end of file + - "Regex build/backups/.*\\.sql" + +# Configuration of doctor behavior. +doctor: + automatic-success: + name: Always Passing + diagnosis: Nothing will ever be wrong. + prescription: No action will need to be taken. + test: + - exit 0 + severity: info + automatic-fyi: + name: FYI + diagnosis: We found a thing but it's ok. + prescription: No action needs to be taken. + test: + - exit 1 + severity: info + automatic-warning: + name: Always Warning + diagnosis: This always concerns us. + prescription: Edit your outrigger.yml. + test: + - exit 1 + severity: warning + automatic-failure: + name: Always Failing + diagnosis: Everything is always wrong. + prescription: Edit your outrigger.yml. + test: + - exit 1 + severity: error diff --git a/util/shell_exec.go b/util/shell_exec.go index 2318ad5..f632e34 100644 --- a/util/shell_exec.go +++ b/util/shell_exec.go @@ -57,7 +57,7 @@ func PassthruCommand(cmd *exec.Cmd) (exitCode int) { cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin - bin := Executor{cmd} + bin := Convert(cmd) err := bin.Run() if err != nil { @@ -81,6 +81,37 @@ func PassthruCommand(cmd *exec.Cmd) (exitCode int) { return } +// CaptureCommand is similar to PassthruCommand except it intercepts all output. +// It is primarily used to evaluate shell commands for success/failure states. +// +// Derived from: http://stackoverflow.com/a/40770011/38408 +func CaptureCommand(cmd *exec.Cmd) (string, int, error) { + bin := Convert(cmd) + + result, err := bin.Output() + + var exitCode int + if err != nil { + // Try to get the exit code. + if exitError, ok := err.(*exec.ExitError); ok { + ws := exitError.Sys().(syscall.WaitStatus) + exitCode = ws.ExitStatus() + } else { + // This will happen (in OSX) if `name` is not available in $PATH, + // in this situation, exit code could not be get, and stderr will be + // empty string very likely, so we use the default fail code, and format err + // to string and set to stderr + exitCode = defaultFailedCode + } + } else { + // Success, exitCode should be 0. + ws := cmd.ProcessState.Sys().(syscall.WaitStatus) + exitCode = ws.ExitStatus() + } + + return string(result), exitCode, err +} + // Execute executes the provided command, it also can sspecify if the output should be forced to print to the console func (x Executor) Execute(forceOutput bool) error { x.cmd.Stderr = os.Stderr @@ -148,15 +179,17 @@ func (x Executor) Log(tag string) { func (x Executor) String() string { context := "" if x.cmd.Dir != "" { - context = fmt.Sprintf("(WD: %s", x.cmd.Dir) + context = fmt.Sprintf("WD: %s", x.cmd.Dir) } if x.cmd.Env != nil { env := strings.Join(x.cmd.Env, " ") if context == "" { - context = fmt.Sprintf("(Env: %s", env) + context = fmt.Sprintf("(Env: %s)", env) } else { - context = fmt.Sprintf("%s, Env: %s)", context, env) + context = fmt.Sprintf("(%s, Env: %s)", context, env) } + } else { + context = fmt.Sprintf("(%s)", context) } return fmt.Sprintf("%s %s %s", x.cmd.Path, strings.Join(x.cmd.Args[1:], " "), context)