Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project Doctor #158

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 8 additions & 52 deletions commands/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ package commands

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/phase2/rig/util"
"github.com/urfave/cli"
)

Expand Down Expand Up @@ -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...)
}
Expand Down Expand Up @@ -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- ")
Expand Down
22 changes: 19 additions & 3 deletions commands/project_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

// ProjectScript is the struct for project defined commands
type ProjectScript struct {
Id string
Alias string
Description string
Run []string
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
}
}

Expand Down
213 changes: 213 additions & 0 deletions commands/project_doctor.go
Original file line number Diff line number Diff line change
@@ -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)},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some potential shell versions assuming you are ok with the sync volume being hard coded in outrigger.yml.

test -z $(docker container ps -aq --filter 'name=^/projectname-sync$') should tell you if the container exists but not much about if it is running.

test -z $(docker container ps -q --filter 'name=^/projectname-sync$') dropping the -a flag should let you know if it is running but can't tell you anything about the processes inside it. I don't think that is different than what the return code from docker top is doing for you but I'm not positive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is docker container ps never has a failing exit code, whereas docker top does.

After creating these examples of conditions, I've since gone on to work on have a more hard-coded set of checks around project sync, I hope to have a PR ready later today/tomorrow.

With all the issues around unison, I want to be able to tell people to upgrade rig and have a comprehensive solution in place for all projects using unison.

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{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add a test to the effect of ps aux | grep unison | $syncName to check for the process on the local machine?

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)},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to be the exact same test as the one that checks to see if the container is running?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it should be docker volume ls $syncName which is pretty easy to do as a shell test (assuming the volume name would get hard coded in outrigger.yml).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on a PR which adds a rig project sync:name to get that.

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}
}
Loading