diff --git a/.gitignore b/.gitignore index 3add76b..2f2d403 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ # vendor/ dist/ +notify-to-slack diff --git a/Makefile b/Makefile index 46874d1..f02c5f6 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ all-linux: $(filter dist/$(APP_NAME)-linux-%,$(TARGETS)) # Build notify-to-slack for the current machine $(APP_NAME): $(GOFILES) @echo Version: $(VERSION) - go build -ldflags '-X "main.version=VERSION"' -o $(APP_NAME) + go build -ldflags '-X "main.version=$(VERSION)"' -o $(APP_NAME) .PHONY: build build: $(APP_NAME) @@ -30,7 +30,7 @@ test: go test -coverprofile=coverage.out go tool cover -func=coverage.out @go tool cover -func=coverage.out | awk -v target=80.0% \ - '/^total:/ { print "Total coverage: " $3 " Minimum coverage: " target; if ($3+0.0 >= target+0.0) print "ok"; else { print "fail"; exit 1; } }' + '/^total:/ { print "Total coverage: " $$3 " Minimum coverage: " target; if ($$3+0.0 >= target+0.0) print "ok"; else { print "fail"; exit 1; } }' # Installs pre-commit hooks .PHONY: install-hooks @@ -52,9 +52,9 @@ clean: $(TARGETS): $(GOFILES) mkdir -p ./dist GOOS=$(word 2, $(subst -, ,$(@))) GOARCH=$(word 3, $(subst -, ,$(@))) CGO_ENABLED=0 \ - go build -ldflags '-X "main.version=VERSION"' -a -installsuffix nocgo \ - -o @ + go build -ldflags '-X "main.version=$(VERSION)"' -a -installsuffix nocgo \ + -o $@ .PHONY: $(TARGET_ALIAS) $(TARGET_ALIAS): - $(MAKE) $(addprefix dist/,@) + $(MAKE) $(addprefix dist/,$@) diff --git a/figures/figures.go b/figures/figures.go new file mode 100644 index 0000000..9b1efea --- /dev/null +++ b/figures/figures.go @@ -0,0 +1,107 @@ +package figures + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +var ConfigFileNotFoundErr = errors.New("config file for provided path not found") + +// fileExists checks if a file exists at a given path +func fileExists(path string) bool { + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } + + return true +} + +type figuration struct { + applicationName string +} + +// NewFiguration creates a new figuration for the provided application +func NewFiguration(applicationName string) figuration { + return figuration{ + applicationName: applicationName, + } +} + +// GetConfigFilePath returns the path of a given file within the UserConfigDir. +func (fig figuration) GetConfigFilePath(filename string) (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("error getting current config: %w", err) + } + + // Get or make config dir path + configDir = filepath.Join(configDir, fig.applicationName) + _ = os.MkdirAll(configDir, 0o755) + + // Get the path to the provided file name within the config directory + configFilePath := filepath.Join(configDir, filename) + + return configFilePath, err +} + +// ReadConfig populates a provided struct with the data in config file of a given name +func (fig figuration) ReadConfig(name string, data interface{}) error { + configPath, err := fig.GetConfigFilePath(name) + if err != nil { + return err + } + + if !fileExists(configPath) { + return fmt.Errorf("error reading config from file %s: %w", name, ConfigFileNotFoundErr) + } + + content, err := ioutil.ReadFile(configPath) + if err != nil { + return fmt.Errorf("error reading config from file: %w", err) + } + + err = json.Unmarshal(content, data) + if err != nil { + return fmt.Errorf("failed parsing json from config file: %w", err) + } + + return nil +} + +// WriteConfig writes data from the provided struct to the config file with the provided name +func (fig figuration) WriteConfig(name string, data interface{}) error { + configPath, err := fig.GetConfigFilePath(name) + if err != nil { + return err + } + + contents, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed converting config to json: %w", err) + } + + if err = ioutil.WriteFile(configPath, contents, 0o600); err != nil { + return fmt.Errorf("error writing config to file: %w", err) + } + + return nil +} + +type Config interface { + ApplicationName() string + Filename() string +} + +// ReadConfig populates the provided struct matching the Config interface with config values from the user's config store +func ReadConfig(config Config) error { + return NewFiguration(config.ApplicationName()).ReadConfig(config.Filename(), config) +} + +// WriteConfig writes the provided struct matching the Config interface to the users config store +func WriteConfig(config Config) error { + return NewFiguration(config.ApplicationName()).WriteConfig(config.Filename(), config) +} diff --git a/go.mod b/go.mod index 97f5743..91de0cb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,13 @@ -module /iamthefij/notify-to-slack +module git.iamthefij.com/iamthefij/notify-to-slack go 1.17 require ( + github.com/karrick/golf v1.4.0 + github.com/slack-go/slack v0.10.0 +) + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + github.com/pkg/errors v0.8.0 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b8cfa31 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/karrick/golf v1.4.0 h1:9i9HnUh7uCyUFJhIqg311HBibw4f2pbGldi0ZM2FhaQ= +github.com/karrick/golf v1.4.0/go.mod h1:qGN0IhcEL+IEgCXp00RvH32UP59vtwc8w5YcIdArNRk= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.10.0 h1:L16Eqg3QZzRKGXIVsFSZdJdygjOphb2FjRUwH6VrFu8= +github.com/slack-go/slack v0.10.0/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/main.go b/main.go index 12e5dc3..3b6a135 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,183 @@ package main -var ( - // version of notify-to-slack being run - version = "dev" +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "time" + + "github.com/karrick/golf" + "github.com/slack-go/slack" + + "git.iamthefij.com/iamthefij/notify-to-slack/figures" ) -func main() { - showVersion := flag.Bool("version", false, "Display the version of minitor and exit") - flag.Parse() +var ( + applicationName = "notify-to-slack" + version = "dev" + // hookURL = "https://hooks.slack.com/services/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" +) - // Print version if flag is provided - if *showVersion { - fmt.Println("notify-to-slack version:", version) +type Config struct { + HookURL string +} - return +func (_ Config) ApplicationName() string { + return applicationName +} + +func (_ Config) Filename() string { + return "config" +} + +// ShellCommand takes a string and constructs a command that executs within `sh` +func ShellCommand(command string) *exec.Cmd { + shellCommand := []string{"sh", "-c", strings.TrimSpace(command)} + + return exec.Command(shellCommand[0], shellCommand[1:]...) +} + +func maybeHostname() string { + if hostname, err := os.Hostname(); err != nil { + return "unknown hostname" + } else { + return hostname + } +} + +func maybeUsername() string { + if currentUser, err := user.Current(); err != nil { + return "unknown user" + } else { + return currentUser.Username + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Reads in and does stuff\n") + configPath, err := figures.NewFiguration(applicationName).GetConfigFilePath("") + if err == nil { + fmt.Fprintf(os.Stderr, "Config directory is %s\n", configPath) + } + golf.PrintDefaults() +} + +func printVersion() { + // fmt.Fprintf(os.Stderr, "%s: at version %s\n", os.Args[0], version) + fmt.Fprintf(os.Stderr, "%s: at version %s\n", os.Args[0], version) +} + +func main() { + showHelp := golf.BoolP('h', "help", false, "show usage and exit") + showVersion := golf.BoolP('V', "version", false, "show version and exit") + lastStatus := golf.IntP('s', "status", -1, "last command status") + command := golf.StringP('c', "command", "", "execute command in a sub shell and publish results") + atChannel := golf.BoolP('a', "at-channel", false, "add @channel to the message") + channel := golf.StringP('l', "channel", "", "select channel to send message to, if not the default for your integration") + hookURL := golf.StringP('u', "hook-url", "", "set the webhook URL to use") + golf.Parse() + + if *showHelp { + printUsage() + return + } + if *showVersion { + printVersion() + return + } + + // Read the configuration + config := Config{} + err := figures.ReadConfig(&config) + + // User set a new hook url + if *hookURL != "" { + config.HookURL = *hookURL + err = figures.WriteConfig(config) + if err != nil { + panic(fmt.Sprintf("Could not write to config file: %s", err)) + } + } else { + if err != nil && !errors.Is(err, figures.ConfigFileNotFoundErr) { + panic(fmt.Sprintf("Error attempting to read the config file. %s", err)) + } + if config.HookURL == "" { + panic("You have not set a hook url. Try running with --hook-url ") + } + } + + attachments := []slack.Attachment{} + + // Execute nested command + if *command != "" { + c := ShellCommand(*command) + var footer string + output, err := c.CombinedOutput() + color := "good" + if err != nil { + color = "danger" + if exitError, ok := err.(*exec.ExitError); ok { + footer = fmt.Sprintf("status %d", exitError.ExitCode()) + } else { + footer = fmt.Sprintf("unknown status %s", err) + } + } + + attachments = append(attachments, slack.Attachment{ + Color: color, + AuthorName: maybeUsername(), + AuthorSubname: maybeHostname(), + Text: string(output), + Footer: footer, + Ts: json.Number(strconv.FormatInt(time.Now().Unix(), 10)), + }) + } + + // Get message text from provided arguments + args := golf.Args() + message := strings.Join(args, " ") + + // Build status attachment if a status was provided + if *lastStatus >= 0 { + color := "good" + if *lastStatus > 0 { + color = "danger" + } + + attachments = append(attachments, slack.Attachment{ + Color: color, + AuthorName: maybeUsername(), + AuthorSubname: maybeHostname(), + Text: message, + Footer: fmt.Sprintf("status %d", *lastStatus), + Ts: json.Number(strconv.FormatInt(time.Now().Unix(), 10)), + }) + + // Empty out message to avoid duplicating in the message content + message = "" + + } + + // Maybe prepend an @channel + if *atChannel { + message = strings.Join([]string{"", message}, " ") + } + + msg := slack.WebhookMessage{ + Attachments: attachments, + Username: "cli-noti", + Text: message, + Channel: *channel, + } + + err = slack.PostWebhook(config.HookURL, &msg) + if err != nil { + fmt.Println(err) } }