From 81e06a2a2204c24e6d4dfcbd7ad81b6fc9dec6aa Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Tue, 17 Dec 2024 14:04:23 -0800 Subject: [PATCH 1/6] Add "run" command to report check-ins --- README.md | 16 ++++ cmd/run.go | 143 ++++++++++++++++++++++++++++ cmd/run_test.go | 247 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 cmd/run.go create mode 100644 cmd/run_test.go diff --git a/README.md b/README.md index 48bb070..91c97dd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,22 @@ Optional flags: - `-v, --revision`: Revision being deployed - `-u, --user`: Local username of the person deploying +### Run Command + +Report a check-in to Honeybadger using either an ID or a slug: + +```bash +# Using ID +hb run --id check-123 + +# Using slug +hb run --slug daily-backup +``` + +Required flags (one of): +- `-i, --id`: Check-in ID to report +- `-s, --slug`: Check-in slug to report + ## Development Pull requests are welcome. If you're adding a new feature, please [submit an issue](https://github.com/honeybadger-io/cli/issues/new) as a preliminary step; that way you can be (moderately) sure that your pull request will be accepted. diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..69cef24 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + checkInID string + slug string +) + +type checkInPayload struct { + CheckIn struct { + Status string `json:"status"` + Duration int `json:"duration,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + ExitCode int `json:"exit_code"` + } `json:"check_in"` +} + +// runCmd represents the run command +var runCmd = &cobra.Command{ + Use: "run [command]", + Short: "Run a command and report its status to Honeybadger", + Long: `Run a command and report its status to Honeybadger's Reporting API. +This command executes the provided command, captures its output and execution time, +and reports the results using either a check-in ID or slug. + +Example: + hb run --id check-123 -- /usr/local/bin/backup.sh + hb run --slug daily-backup -- pg_dump -U postgres mydb > backup.sql`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiKey := viper.GetString("api_key") + if apiKey == "" { + return fmt.Errorf("API key is required. Set it using --api-key flag or HONEYBADGER_API_KEY environment variable") + } + + if checkInID == "" && slug == "" { + return fmt.Errorf("either check-in ID (--id) or slug (--slug) is required") + } + if checkInID != "" && slug != "" { + return fmt.Errorf("cannot specify both check-in ID and slug") + } + + // Prepare command execution + command := args[0] + var cmdArgs []string + if len(args) > 1 { + cmdArgs = args[1:] + } + + execCmd := exec.Command(command, cmdArgs...) + var stdout, stderr bytes.Buffer + execCmd.Stdout = &stdout + execCmd.Stderr = &stderr + + // Execute command and measure duration + startTime := time.Now() + err := execCmd.Run() + duration := int(time.Since(startTime).Seconds()) + + // Prepare payload + payload := checkInPayload{} + payload.CheckIn.Duration = duration + payload.CheckIn.Stdout = stdout.String() + payload.CheckIn.Stderr = stderr.String() + payload.CheckIn.ExitCode = 0 // Default to 0 for success + + if err != nil { + payload.CheckIn.Status = "error" + if exitErr, ok := err.(*exec.ExitError); ok { + payload.CheckIn.ExitCode = exitErr.ExitCode() + } else { + // For non-exit errors (like command not found), use -1 + payload.CheckIn.ExitCode = -1 + } + } else { + payload.CheckIn.Status = "success" + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("error marshaling payload: %w", err) + } + + apiEndpoint := viper.GetString("endpoint") + var url string + if checkInID != "" { + url = fmt.Sprintf("%s/v1/check_in/%s", apiEndpoint, checkInID) + } else { + url = fmt.Sprintf("%s/v1/check_in/%s/%s", apiEndpoint, apiKey, slug) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, body) + } + + // Print command output to user's terminal + if stdout.Len() > 0 { + os.Stdout.Write(stdout.Bytes()) + } + if stderr.Len() > 0 { + os.Stderr.Write(stderr.Bytes()) + } + + fmt.Printf("\nCheck-in successfully reported to Honeybadger (duration: %ds)\n", duration) + return nil + }, +} + +func init() { + rootCmd.AddCommand(runCmd) + runCmd.Flags().StringVarP(&checkInID, "id", "i", "", "Check-in ID to report") + runCmd.Flags().StringVarP(&slug, "slug", "s", "", "Check-in slug to report") +} diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..4e8c30a --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,247 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunCommand(t *testing.T) { + // Save original values + originalClient := http.DefaultClient + originalEnvAPIKey := os.Getenv("HONEYBADGER_API_KEY") + defer func() { + // Restore original values after test + http.DefaultClient = originalClient + if err := os.Setenv("HONEYBADGER_API_KEY", originalEnvAPIKey); err != nil { + t.Errorf("error restoring environment variable: %v", err) + } + }() + + // Unset environment variable for tests + if err := os.Unsetenv("HONEYBADGER_API_KEY"); err != nil { + t.Errorf("error unsetting environment variable: %v", err) + } + + // Create a test script + var scriptExt string + var scriptContent string + if runtime.GOOS == "windows" { + scriptExt = ".bat" + scriptContent = `@echo off +echo Hello, stdout! +echo Error message! 1>&2 +exit 0 +` + } else { + scriptExt = ".sh" + scriptContent = `#!/bin/sh +echo "Hello, stdout!" +echo "Error message!" >&2 +exit 0 +` + } + + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "test"+scriptExt) + err := os.WriteFile(scriptPath, []byte(scriptContent), 0700) + require.NoError(t, err) + + // Create a failing script + failingScriptPath := filepath.Join(tmpDir, "failing-test"+scriptExt) + var failingScriptContent string + if runtime.GOOS == "windows" { + failingScriptContent = `@echo off +echo Hello from failing script! +exit 42 +` + } else { + failingScriptContent = `#!/bin/sh +echo "Hello from failing script!" +exit 42 +` + } + err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0700) + require.NoError(t, err) + + tests := []struct { + name string + args []string + apiKey string + expectedPath string + expectedStatus int + expectedError bool + validateBody func(*testing.T, checkInPayload) + }{ + { + name: "successful command execution with ID", + args: []string{"--id", "check-123", scriptPath}, + apiKey: "test-api-key", + expectedPath: "/v1/check_in/check-123", + expectedStatus: http.StatusOK, + expectedError: false, + validateBody: func(t *testing.T, payload checkInPayload) { + assert.Equal(t, "success", payload.CheckIn.Status) + assert.Contains(t, payload.CheckIn.Stdout, "Hello, stdout!") + assert.Contains(t, payload.CheckIn.Stderr, "Error message!") + assert.GreaterOrEqual(t, payload.CheckIn.Duration, 0) + assert.Equal(t, 0, payload.CheckIn.ExitCode) + }, + }, + { + name: "successful command execution with slug", + args: []string{"--slug", "daily-backup", scriptPath}, + apiKey: "test-api-key", + expectedPath: "/v1/check_in/test-api-key/daily-backup", + expectedStatus: http.StatusOK, + expectedError: false, + validateBody: func(t *testing.T, payload checkInPayload) { + assert.Equal(t, "success", payload.CheckIn.Status) + assert.Contains(t, payload.CheckIn.Stdout, "Hello, stdout!") + assert.Contains(t, payload.CheckIn.Stderr, "Error message!") + assert.GreaterOrEqual(t, payload.CheckIn.Duration, 0) + assert.Equal(t, 0, payload.CheckIn.ExitCode) + }, + }, + { + name: "missing api key", + args: []string{"--id", "check-123", "echo", "test"}, + apiKey: "", + expectedError: true, + }, + { + name: "missing both id and slug", + args: []string{"echo", "test"}, + apiKey: "test-api-key", + expectedError: true, + }, + { + name: "both id and slug specified", + args: []string{"--id", "check-123", "--slug", "daily-backup", "echo", "test"}, + apiKey: "test-api-key", + expectedError: true, + }, + { + name: "failing command with exit code", + args: []string{"--id", "check-123", failingScriptPath}, + apiKey: "test-api-key", + expectedPath: "/v1/check_in/check-123", + expectedStatus: http.StatusOK, + expectedError: false, + validateBody: func(t *testing.T, payload checkInPayload) { + assert.Equal(t, "error", payload.CheckIn.Status) + assert.Contains(t, payload.CheckIn.Stdout, "Hello from failing script!") + assert.Empty(t, payload.CheckIn.Stderr) + assert.GreaterOrEqual(t, payload.CheckIn.Duration, 0) + assert.Equal(t, 42, payload.CheckIn.ExitCode) + }, + }, + { + name: "non-existent command", + args: []string{"--id", "check-123", "nonexistent-command"}, + apiKey: "test-api-key", + expectedPath: "/v1/check_in/check-123", + expectedStatus: http.StatusOK, + expectedError: false, + validateBody: func(t *testing.T, payload checkInPayload) { + assert.Equal(t, "error", payload.CheckIn.Status) + assert.Empty(t, payload.CheckIn.Stdout) + assert.Empty(t, payload.CheckIn.Stderr) + assert.GreaterOrEqual(t, payload.CheckIn.Duration, 0) + assert.Equal(t, -1, payload.CheckIn.ExitCode) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + assert.Equal(t, "POST", r.Method) + assert.Equal(t, tt.expectedPath, r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + if tt.apiKey == "invalid-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Verify payload + if tt.validateBody != nil { + var payload checkInPayload + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + tt.validateBody(t, payload) + } + + w.WriteHeader(tt.expectedStatus) + })) + defer server.Close() + + // Override the default HTTP client + http.DefaultClient = server.Client() + + // Reset viper config + viper.Reset() + // Disable environment variable loading + viper.AutomaticEnv() + viper.SetEnvPrefix("HONEYBADGER") + if tt.apiKey != "" { + viper.Set("api_key", tt.apiKey) + } + viper.Set("endpoint", server.URL) + + // Create a new command for each test to avoid flag conflicts + cmd := &cobra.Command{Use: "run"} + cmd.Flags().StringVarP(&checkInID, "id", "i", "", "Check-in ID to report") + cmd.Flags().StringVarP(&slug, "slug", "s", "", "Check-in slug to report") + cmd.RunE = runCmd.RunE + + // Execute command + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckInPayloadConstruction(t *testing.T) { + // Create and populate payload + payload := checkInPayload{} + payload.CheckIn.Status = "success" + payload.CheckIn.Duration = 42 + payload.CheckIn.Stdout = "standard output" + payload.CheckIn.Stderr = "error output" + payload.CheckIn.ExitCode = 0 + + // Marshal to JSON + jsonData, err := json.Marshal(payload) + assert.NoError(t, err) + + // Unmarshal back to verify structure + var decoded checkInPayload + err = json.Unmarshal(jsonData, &decoded) + assert.NoError(t, err) + + // Verify fields + assert.Equal(t, "success", decoded.CheckIn.Status) + assert.Equal(t, 42, decoded.CheckIn.Duration) + assert.Equal(t, "standard output", decoded.CheckIn.Stdout) + assert.Equal(t, "error output", decoded.CheckIn.Stderr) + assert.Equal(t, 0, decoded.CheckIn.ExitCode) +} From de662755dd1c54f80a25e28cc4d54bc2f4869a11 Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Tue, 17 Dec 2024 14:41:42 -0800 Subject: [PATCH 2/6] Handle lint issues --- cmd/run.go | 8 ++++---- cmd/run_test.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index 69cef24..f4bc8d7 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -61,7 +61,7 @@ Example: cmdArgs = args[1:] } - execCmd := exec.Command(command, cmdArgs...) + execCmd := exec.Command(command, cmdArgs...) // nolint:gosec var stdout, stderr bytes.Buffer execCmd.Stdout = &stdout execCmd.Stderr = &stderr @@ -115,7 +115,7 @@ Example: if err != nil { return fmt.Errorf("failed to send request: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() // nolint:errcheck // Check response status if resp.StatusCode != http.StatusOK { @@ -125,10 +125,10 @@ Example: // Print command output to user's terminal if stdout.Len() > 0 { - os.Stdout.Write(stdout.Bytes()) + os.Stdout.Write(stdout.Bytes()) // nolint:errcheck,gosec } if stderr.Len() > 0 { - os.Stderr.Write(stderr.Bytes()) + os.Stderr.Write(stderr.Bytes()) // nolint:errcheck,gosec } fmt.Printf("\nCheck-in successfully reported to Honeybadger (duration: %ds)\n", duration) diff --git a/cmd/run_test.go b/cmd/run_test.go index 4e8c30a..2a1a640 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -53,7 +53,7 @@ exit 0 tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "test"+scriptExt) - err := os.WriteFile(scriptPath, []byte(scriptContent), 0700) + err := os.WriteFile(scriptPath, []byte(scriptContent), 0600) require.NoError(t, err) // Create a failing script @@ -70,7 +70,7 @@ echo "Hello from failing script!" exit 42 ` } - err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0700) + err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0600) require.NoError(t, err) tests := []struct { From 0428e46271dd0c8a00708afb693788a8a2531212 Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Tue, 17 Dec 2024 14:42:17 -0800 Subject: [PATCH 3/6] Update docs --- CHANGELOG.md | 1 + README.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4715839..bf56c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added support for running commands and reporting their status - Made API endpoint configurable ## [0.1.0] - 2024-12-09 diff --git a/README.md b/README.md index 91c97dd..a61ed6e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,13 @@ To run tests locally: go test ./... ``` +To build and test local binaries: + +```bash +go build -o ./hb +./hb run --id check-123 -- /usr/local/bin/backup.sh +``` + ### To contribute your code: 1. Fork it. From 8a075afe4a4b4c7383ea5e9f2b60792bcaabf1f8 Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Tue, 17 Dec 2024 14:46:47 -0800 Subject: [PATCH 4/6] Fix tests --- cmd/run_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/run_test.go b/cmd/run_test.go index 2a1a640..4e8c30a 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -53,7 +53,7 @@ exit 0 tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "test"+scriptExt) - err := os.WriteFile(scriptPath, []byte(scriptContent), 0600) + err := os.WriteFile(scriptPath, []byte(scriptContent), 0700) require.NoError(t, err) // Create a failing script @@ -70,7 +70,7 @@ echo "Hello from failing script!" exit 42 ` } - err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0600) + err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0700) require.NoError(t, err) tests := []struct { From 8165f873e7a7ceb5cab813ad892dffece1e12dbb Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Tue, 17 Dec 2024 14:49:25 -0800 Subject: [PATCH 5/6] The generated scripts need to be executable --- cmd/run_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/run_test.go b/cmd/run_test.go index 4e8c30a..c7d8aff 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -53,7 +53,7 @@ exit 0 tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "test"+scriptExt) - err := os.WriteFile(scriptPath, []byte(scriptContent), 0700) + err := os.WriteFile(scriptPath, []byte(scriptContent), 0700) // nolint:gosec require.NoError(t, err) // Create a failing script @@ -70,7 +70,7 @@ echo "Hello from failing script!" exit 42 ` } - err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0700) + err = os.WriteFile(failingScriptPath, []byte(failingScriptContent), 0700) // nolint:gosec require.NoError(t, err) tests := []struct { From e7fb97c3ba860feebb53baa43d1f0dfdbb5a33db Mon Sep 17 00:00:00 2001 From: Benjamin Curtis Date: Fri, 14 Feb 2025 09:40:34 -0800 Subject: [PATCH 6/6] Add command info to the readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a61ed6e..86521d6 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,14 @@ Optional flags: ### Run Command -Report a check-in to Honeybadger using either an ID or a slug: +Run a command and report its status to Honeybadger using either an ID or a slug: ```bash # Using ID -hb run --id check-123 +hb run --id check-123 -- command # Using slug -hb run --slug daily-backup +hb run --slug daily-backup -- command ``` Required flags (one of):