diff --git a/cmd/argo-watcher/main.go b/cmd/argo-watcher/main.go index 54361f70..7e175ef1 100644 --- a/cmd/argo-watcher/main.go +++ b/cmd/argo-watcher/main.go @@ -14,13 +14,13 @@ var errorInvalidMode = errors.New("invalid mode") func runWatcher(serverFlag, clientFlag bool) error { // start server if requested if serverFlag && !clientFlag { - serverWatcher() + runServer() return nil } // start client if requested if clientFlag && !serverFlag { - client.ClientWatcher() + client.Run() return nil } diff --git a/cmd/argo-watcher/server.go b/cmd/argo-watcher/server.go index 7d70579e..12e1cfaa 100644 --- a/cmd/argo-watcher/server.go +++ b/cmd/argo-watcher/server.go @@ -28,7 +28,7 @@ func initLogs(logLevel string, logFormat string) { } } -func serverWatcher() { +func runServer() { // initialize serverConfig serverConfig, err := config.NewServerConfig() if err != nil { diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index e7e58eef..99793529 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -1,5 +1,15 @@ package helpers +import ( + "fmt" + "net/http" + "net/http/httputil" + "strings" +) + +// Contains is a simple utility function that checks if a given string (s) exists in a slice of strings (slice). +// It iterates through the elements in the slice and returns true if it finds a match, +// indicating that the string exists in the slice; otherwise, it returns false, indicating that the string is not present. func Contains(slice []string, s string) bool { for _, item := range slice { if item == s { @@ -9,6 +19,10 @@ func Contains(slice []string, s string) bool { return false } +// ImagesContains checks whether a given list of images contains a specific image. +// It takes into account an optional registry proxy and checks both the image with +// and without the proxy to ensure compatibility with mutating webhooks. +// The function returns true if the image is found in the list, considering the proxy if specified; otherwise, it returns false. func ImagesContains(images []string, image string, registryProxy string) bool { if registryProxy != "" { imageWithProxy := registryProxy + "/" + image @@ -19,3 +33,36 @@ func ImagesContains(images []string, image string, registryProxy string) bool { return Contains(images, image) } } + +// CurlCommandFromRequest generates a cURL command string from an HTTP request, +// including the request method, headers, request body, and URL. +// It handles any errors during the process and returns the formatted cURL command or an error if encountered. +func CurlCommandFromRequest(request *http.Request) (string, error) { + clonedRequest, err := httputil.DumpRequest(request, true) + if err != nil { + return "", err + } + + cmd := "curl -X " + request.Method + + // Iterate over request headers and add them to the cURL command + for key, values := range request.Header { + for _, value := range values { + cmd += fmt.Sprintf(" -H '%s: %s'", key, value) + } + } + + // Add request body to cURL command + if len(clonedRequest) > 0 { + // Exclude the request line and headers when adding the body + body := string(clonedRequest[strings.Index(string(clonedRequest), "\r\n\r\n")+4:]) + if len(body) > 0 { + cmd += " -d '" + body + "'" + } + } + + // Add request URL to cURL command + cmd += " '" + request.URL.String() + "'" + + return cmd, nil +} diff --git a/internal/helpers/helpers_test.go b/internal/helpers/helpers_test.go index 8fdf8153..b8ca2bdc 100644 --- a/internal/helpers/helpers_test.go +++ b/internal/helpers/helpers_test.go @@ -2,6 +2,9 @@ package helpers import ( "fmt" + "net/http" + "sort" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -57,3 +60,34 @@ func TestImageContains(t *testing.T) { assert.Equal(t, test.expected, ImagesContains(test.images, test.image, test.registryProxy), testErrorMsg) } } + +func TestCurlCommandFromRequest(t *testing.T) { + // Create a sample HTTP request with a non-empty request body + requestBody := `{"key": "value"}` + request, _ := http.NewRequest("POST", "https://example.com/api", strings.NewReader(requestBody)) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("Authorization", "Bearer Token123") + request.Header.Add("X-Custom-Header", "CustomValue") + + // Create the expected cURL command + expectedCurl := `curl -X POST -H 'Authorization: Bearer Token123' -H 'Content-Type: application/json' -H 'X-Custom-Header: CustomValue' -d '{"key": "value"}' 'https://example.com/api'` + + // Call the function to get the actual cURL command + actualCurl, err := CurlCommandFromRequest(request) + assert.NoError(t, err) + + // Split the cURL commands by space + expectedParts := strings.Fields(expectedCurl) + actualParts := strings.Fields(actualCurl) + + // Sort the headers alphabetically, excluding the first part (curl command and method) + sort.Strings(expectedParts[3:]) + sort.Strings(actualParts[3:]) + + // Reconstruct the cURL commands with sorted headers + sortedExpectedCurl := strings.Join(expectedParts, " ") + sortedActualCurl := strings.Join(actualParts, " ") + + // Compare the expected and actual cURL commands + assert.Equal(t, sortedExpectedCurl, sortedActualCurl) +} diff --git a/internal/models/task.go b/internal/models/task.go index 3e7f614d..e902db26 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -20,7 +20,7 @@ type Task struct { Images []Image `json:"images" binding:"required"` Status string `json:"status,omitempty"` StatusReason string `json:"status_reason,omitempty"` - Validated bool + Validated bool `json:"validated,omitempty"` } // ListImages returns a list of strings representing the images of the task. diff --git a/pkg/client/client.go b/pkg/client/client.go index 1da1f849..79e8a379 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -3,6 +3,7 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" "io" "log" @@ -12,39 +13,55 @@ import ( "strings" "time" + "github.com/shini4i/argo-watcher/internal/helpers" + "github.com/shini4i/argo-watcher/internal/models" ) type Watcher struct { - baseUrl string - client *http.Client + baseUrl string + client *http.Client + debugMode bool } var ( - tag = os.Getenv("IMAGE_TAG") + tag = os.Getenv("IMAGE_TAG") + debug, _ = strconv.ParseBool(os.Getenv("DEBUG")) ) -func (watcher *Watcher) addTask(task models.Task, token string) string { - body, err := json.Marshal(task) +func (watcher *Watcher) addTask(task models.Task, token string) (string, error) { + // Marshal the task into JSON + requestBody, err := json.Marshal(task) if err != nil { - panic(err) + return "", err } url := fmt.Sprintf("%s/api/v1/tasks", watcher.baseUrl) - request, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + + // Create a new HTTP request with the JSON responseBody + request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(requestBody)) if err != nil { - panic(err) + return "", err } request.Header.Set("Content-Type", "application/json; charset=UTF-8") + // Set the deploy token header if provided if token != "" { request.Header.Set("ARGO_WATCHER_DEPLOY_TOKEN", token) } + // Print the equivalent cURL command for troubleshooting + if curlCommand, err := helpers.CurlCommandFromRequest(request); err != nil { + log.Printf("Couldn't get cURL command. Got the following error: %s", err) + } else if watcher.debugMode { + log.Printf("Adding task to argo-watcher. Equivalent cURL command: %s\n", curlCommand) + } + + // Send the HTTP request response, err := watcher.client.Do(request) if err != nil { - panic(err) + return "", err } defer func(Body io.ReadCloser) { @@ -54,37 +71,36 @@ func (watcher *Watcher) addTask(task models.Task, token string) string { } }(response.Body) - body, err = io.ReadAll(response.Body) + responseBody, err := io.ReadAll(response.Body) if err != nil { - panic(err) + return "", err } - if response.StatusCode != 202 { - fmt.Printf("Something went wrong on argo-watcher side. Got the following response code %d\n", response.StatusCode) - fmt.Printf("Body: %s\n", string(body)) - os.Exit(1) + // Check the HTTP status code for success + if response.StatusCode != http.StatusAccepted { + errMsg := fmt.Sprintf("Something went wrong on argo-watcher side. Got the following response code %d", response.StatusCode) + return "", errors.New(errMsg) } var accepted models.TaskStatus - err = json.Unmarshal(body, &accepted) + err = json.Unmarshal(responseBody, &accepted) if err != nil { - panic(err) + return "", err } - return accepted.Id + return accepted.Id, nil } -func (watcher *Watcher) getTaskStatus(id string) *models.TaskStatus { +func (watcher *Watcher) getTaskStatus(id string) (*models.TaskStatus, error) { url := fmt.Sprintf("%s/api/v1/tasks/%s", watcher.baseUrl, id) request, err := http.NewRequest("GET", url, nil) if err != nil { - log.Print(err) - os.Exit(1) + return nil, err } response, err := watcher.client.Do(request) if err != nil { - panic(err) + return nil, err } defer func(Body io.ReadCloser) { @@ -94,21 +110,43 @@ func (watcher *Watcher) getTaskStatus(id string) *models.TaskStatus { } }(response.Body) - body, _ := io.ReadAll(response.Body) - - if response.StatusCode != 200 { - fmt.Printf("Received non 200 status code (%d)\n", response.StatusCode) - fmt.Printf("Body: %s\n", string(body)) - os.Exit(1) + if response.StatusCode != http.StatusOK { + log.Printf("Received non-200 status code (%d)\n", response.StatusCode) + body, _ := io.ReadAll(response.Body) + log.Printf("Body: %s\n", string(body)) + return nil, fmt.Errorf("received non-200 status code: %d", response.StatusCode) } var taskStatus models.TaskStatus - err = json.Unmarshal(body, &taskStatus) - if err != nil { - panic(err) + if err := json.NewDecoder(response.Body).Decode(&taskStatus); err != nil { + return nil, err } - return &taskStatus + return &taskStatus, nil +} + +func (watcher *Watcher) waitForDeployment(id, appName string) error { + for { + taskInfo, err := watcher.getTaskStatus(id) + if err != nil { + return err + } + + switch taskInfo.Status { + case models.StatusFailedMessage: + return fmt.Errorf("The deployment has failed, please check logs.\n%s", taskInfo.StatusReason) + case models.StatusInProgressMessage: + log.Println("Application deployment is in progress...") + time.Sleep(15 * time.Second) + case models.StatusAppNotFoundMessage: + return fmt.Errorf("Application %s does not exist.\n%s", appName, taskInfo.StatusReason) + case models.StatusArgoCDUnavailableMessage: + return fmt.Errorf("ArgoCD is unavailable. Please investigate.\n%s", taskInfo.StatusReason) + case models.StatusDeployedMessage: + log.Printf("The deployment of %s version is done.\n", tag) + return nil + } + } } func getImagesList() []models.Image { @@ -122,12 +160,13 @@ func getImagesList() []models.Image { return images } -func ClientWatcher() { +func Run() { images := getImagesList() watcher := Watcher{ - baseUrl: strings.TrimSuffix(os.Getenv("ARGO_WATCHER_URL"), "/"), - client: &http.Client{}, + baseUrl: strings.TrimSuffix(os.Getenv("ARGO_WATCHER_URL"), "/"), + client: &http.Client{}, + debugMode: debug, } task := models.Task{ @@ -137,11 +176,9 @@ func ClientWatcher() { Images: images, } - debug, _ := strconv.ParseBool(os.Getenv("DEBUG")) - deployToken := os.Getenv("ARGO_WATCHER_DEPLOY_TOKEN") - if debug { + if watcher.debugMode { fmt.Printf("Got the following configuration:\n"+ "ARGO_WATCHER_URL: %s\n"+ "ARGO_APP: %s\n"+ @@ -155,33 +192,19 @@ func ClientWatcher() { } } - fmt.Printf("Waiting for %s app to be running on %s version.\n", task.App, tag) + log.Printf("Waiting for %s app to be running on %s version.\n", task.App, tag) - id := watcher.addTask(task, deployToken) + id, err := watcher.addTask(task, deployToken) + if err != nil { + log.Printf("Couldn't add task. Got the following error: %s", err) + os.Exit(1) + } + // Giving Argo-Watcher some time to process the task time.Sleep(5 * time.Second) -loop: - for { - switch taskInfo := watcher.getTaskStatus(id); taskInfo.Status { - case models.StatusFailedMessage: - fmt.Println("The deployment has failed, please check logs.") - fmt.Println(taskInfo.StatusReason) - os.Exit(1) - case models.StatusInProgressMessage: - fmt.Println("Application deployment is in progress...") - time.Sleep(15 * time.Second) - case models.StatusAppNotFoundMessage: - fmt.Printf("Application %s does not exist.\n", task.App) - fmt.Println(taskInfo.StatusReason) - os.Exit(1) - case models.StatusArgoCDUnavailableMessage: - fmt.Println("ArgoCD is unavailable. Please investigate.") - fmt.Println(taskInfo.StatusReason) - os.Exit(1) - case models.StatusDeployedMessage: - fmt.Printf("The deployment of %s version is done.\n", tag) - break loop - } + if err := watcher.waitForDeployment(id, task.App); err != nil { + log.Println(err) + os.Exit(1) } } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 602d1750..ed062ab7 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -5,11 +5,11 @@ import ( "fmt" "net/http" "net/http/httptest" - "reflect" "strings" "testing" "github.com/shini4i/argo-watcher/internal/models" + "github.com/stretchr/testify/assert" ) var ( @@ -24,7 +24,7 @@ var ( ) func addTaskHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { + if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) _, err := w.Write([]byte(`Method not allowed`)) if err != nil { @@ -106,39 +106,30 @@ func TestAddTask(t *testing.T) { }, } - id := client.addTask(task, "") - - if id != expected.Id { - t.Errorf("Expected id %s, got %s", expected.Id, id) - } + taskId, err := client.addTask(task, "") + assert.NoError(t, err) + assert.Equal(t, expected.Id, taskId) } func TestGetTaskStatus(t *testing.T) { - messageTemplate := "Expected status %s, got %s" + task, err := client.getTaskStatus(taskId) + assert.NoError(t, err) + assert.Equal(t, models.StatusDeployedMessage, task.Status) - status := client.getTaskStatus(taskId).Status - if status != models.StatusDeployedMessage { - t.Errorf(messageTemplate, models.StatusDeployedMessage, status) - } + task, err = client.getTaskStatus(appNotFoundId) + assert.NoError(t, err) + assert.Equal(t, models.StatusAppNotFoundMessage, task.Status) - status = client.getTaskStatus(appNotFoundId).Status - if status != models.StatusAppNotFoundMessage { - t.Errorf(messageTemplate, models.StatusAppNotFoundMessage, status) - } + task, err = client.getTaskStatus(argocdUnavailableId) + assert.NoError(t, err) + assert.Equal(t, models.StatusArgoCDUnavailableMessage, task.Status) - status = client.getTaskStatus(argocdUnavailableId).Status - if status != models.StatusArgoCDUnavailableMessage { - t.Errorf(messageTemplate, models.StatusArgoCDUnavailableMessage, status) - } - - status = client.getTaskStatus(failedTaskId).Status - if status != models.StatusFailedMessage { - t.Errorf(messageTemplate, models.StatusFailedMessage, status) - } + task, err = client.getTaskStatus(failedTaskId) + assert.NoError(t, err) + assert.Equal(t, models.StatusFailedMessage, task.Status) } func TestGetImagesList(t *testing.T) { - tag = testVersion expectedList := []models.Image{ @@ -156,7 +147,5 @@ func TestGetImagesList(t *testing.T) { images := getImagesList() - if !reflect.DeepEqual(images, expectedList) { - t.Errorf("Expected list %v, got %v", expectedList, images) - } + assert.Equal(t, expectedList, images) }