diff --git a/go.mod b/go.mod index 6f8714093..30ed9f028 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,6 @@ require ( k8s.io/klog/v2 v2.120.1 k8s.io/utils v0.0.0-20240423183400-0849a56e8f22 maistra.io/istio-operator v0.0.0-20240217080932-98753cb28cd7 - oras.land/oras-go v1.2.4 sigs.k8s.io/controller-runtime v0.18.0 sigs.k8s.io/external-dns v0.14.0 sigs.k8s.io/gateway-api v1.1.0 @@ -173,6 +172,7 @@ require ( k8s.io/component-base v0.30.0 // indirect k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 // indirect k8s.io/kubectl v0.29.1 // indirect + oras.land/oras-go v1.2.4 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect sigs.k8s.io/kustomize/kyaml v0.16.0 // indirect diff --git a/quay/quay_overflow.go b/quay/quay_overflow.go index e45463ddb..d113bcd93 100644 --- a/quay/quay_overflow.go +++ b/quay/quay_overflow.go @@ -13,22 +13,23 @@ import ( "time" "golang.org/x/exp/maps" - "oras.land/oras-go/pkg/registry/remote" ) const ( // Max number of entries returned as specified in Quay API docs for listing tags - pageLimit = 100 + pageLimit = 100 + accessTokenEnvKey = "ACCESS_TOKEN" ) var ( - accessToken = os.Getenv("ACCESS_TOKEN") + accessToken = os.Getenv(accessTokenEnvKey) preserveSubstrings = []string{ "latest", // Preserve semver release branch or semver tag regex - release-vX.Y.Z(-rc1) or vX.Y.Z(-rc1) // Based on https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string "^(v|release-v)(?P0|[1-9]\\d*)\\.(?P0|[1-9]\\d*)\\.(?P0|[1-9]\\d*)(?:-(?P(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+(?P[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", } + client = &http.Client{Timeout: 5 * time.Second} ) // Tag represents a tag in the repository. @@ -46,12 +47,11 @@ type TagsResponse struct { func main() { repo := flag.String("repo", "kuadrant/kuadrant-operator", "Repository name") - baseURL := flag.String("base-url", "https://quay.io/api/v1/repository/", "Base API URL") + baseURL := flag.String("base-url", "https://quay.io/api/v1/repository", "Base API URL") dryRun := flag.Bool("dry-run", true, "Dry run") batchSize := flag.Int("batch-size", 50, "Batch size for deletion. API calls might get rate limited at large values") flag.Parse() - client := &http.Client{} logger := log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime) if accessToken == "" { @@ -62,7 +62,7 @@ func main() { // Fetch tags from the API logger.Println("Fetching tags from Quay") - tags, err := fetchTags(client, baseURL, repo) + tags, err := fetchTags(baseURL, repo, accessToken) if err != nil { logger.Fatalln("Error fetching tags:", err) } @@ -90,18 +90,18 @@ func main() { go func(tagName string) { defer wg.Done() - if dryRun != nil && *dryRun { + if *dryRun { logger.Printf("DRY RUN - Successfully deleted tag: %s\n", tagName) } else { - if err := deleteTag(client, baseURL, repo, accessToken, tagName); err != nil { + if err := deleteTag(baseURL, repo, accessToken, tagName); err != nil { logger.Println(err) - } else { - logger.Printf("Successfully deleted tag: %s\n", tagName) } + + logger.Printf("Successfully deleted tag: %s\n", tagName) } }(tagName) - delete(tagsToDelete, tagName) // Remove deleted tag from remainingTags + delete(tagsToDelete, tagName) // Remove deleted tag from tagsToDelete i++ } @@ -115,7 +115,7 @@ func main() { // fetchTags retrieves the tags from the repository using the Quay.io API. // https://docs.quay.io/api/swagger/#!/tag/listRepoTags -func fetchTags(client remote.Client, baseURL, repo *string) ([]Tag, error) { +func fetchTags(baseURL, repo *string, accessToken string) ([]Tag, error) { if baseURL == nil || repo == nil { return nil, fmt.Errorf("baseURL or repo required") } @@ -124,14 +124,14 @@ func fetchTags(client remote.Client, baseURL, repo *string) ([]Tag, error) { i := 1 for { - url := fmt.Sprintf("%s%s/tag/?page=%d&limit=%d", *baseURL, *repo, i, pageLimit) + url := fmt.Sprintf("%s/%s/tag/?page=%d&limit=%d", *baseURL, *repo, i, pageLimit) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } // Required for private repos - req.Header.Add("Authorization", "Bearer "+accessToken) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) // Execute the request resp, err := client.Do(req) @@ -142,7 +142,10 @@ func fetchTags(client remote.Client, baseURL, repo *string) ([]Tag, error) { // Handle possible non-200 status codes if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } return nil, fmt.Errorf("error: received status code %d\nBody: %s", resp.StatusCode, string(body)) } @@ -175,18 +178,18 @@ func fetchTags(client remote.Client, baseURL, repo *string) ([]Tag, error) { // deleteTag sends a DELETE request to remove the specified tag from the repository // Returns nil if successful, error otherwise // https://docs.quay.io/api/swagger/#!/tag/deleteFullTag -func deleteTag(client remote.Client, baseURL, repo *string, accessToken, tagName string) error { +func deleteTag(baseURL, repo *string, accessToken, tagName string) error { if baseURL == nil || repo == nil { return fmt.Errorf("baseURL or repo required") } - url := fmt.Sprintf("%s%s/tag/%s", *baseURL, *repo, tagName) + url := fmt.Sprintf("%s/%s/tag/%s", *baseURL, *repo, tagName) req, err := http.NewRequest("DELETE", url, nil) if err != nil { return fmt.Errorf("error creating DELETE request: %s", err) } - req.Header.Add("Authorization", "Bearer "+accessToken) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) resp, err := client.Do(req) if err != nil { @@ -198,11 +201,14 @@ func deleteTag(client remote.Client, baseURL, repo *string, accessToken, tagName return nil } - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } return fmt.Errorf("Failed to delete tag %s: Status code %d\nBody: %s\n", tagName, resp.StatusCode, string(body)) } -// filterTags takes a slice of tags and preserves string regex and returns two maps: one for tags to delete and one for remaining tags. +// filterTags takes a slice of tags and preserves string regex and returns two maps: one for tags to delete and one for preserved tags. func filterTags(tags []Tag, preserveSubstrings []string) (map[string]struct{}, map[string]struct{}, error) { tagsToDelete := make(map[string]struct{}) preservedTags := make(map[string]struct{}) @@ -219,7 +225,7 @@ func filterTags(tags []Tag, preserveSubstrings []string) (map[string]struct{}, m for _, tag := range tags { // Tags that have an expiration set are ignored as they could be historical tags that have already expired - // i.e. when an existing tag is updated, the previous tag of the same name is expired and is returned when listing + // i.e. when an existing tag is updated, the previous tag of the same name is expired and is still returned when listing // the tags if tag.Expiration != "" { continue diff --git a/quay/quay_overflow_test.go b/quay/quay_overflow_test.go index 82847d573..7bea2057c 100644 --- a/quay/quay_overflow_test.go +++ b/quay/quay_overflow_test.go @@ -1,52 +1,34 @@ package main import ( - "bytes" - "errors" - "io" + "fmt" "net/http" + "net/http/httptest" "slices" "strings" "testing" "time" - - "oras.land/oras-go/pkg/registry/remote" ) -var _ remote.Client = &MockHTTPClient{} - -type MockHTTPClient struct { - wantErr bool - mutateFn func(res *http.Response) -} - -func (m MockHTTPClient) Do(_ *http.Request) (*http.Response, error) { - if m.wantErr { - return nil, errors.New("oops") - } - - resp := &http.Response{} - if m.mutateFn != nil { - m.mutateFn(resp) - } - - return resp, nil -} - var ( - testBaseUrl = "https://quay.io/api/v1/" - testRepo = "testOrg/kuadrant-operator" + testBaseUrl = "https://quay.io/api/v1/" + testRepo = "testOrg/kuadrant-operator" + testAccessToken = "fake_access_token" ) func Test_fetchTags(t *testing.T) { - t.Run("test error making request", func(t *testing.T) { - tags, err := fetchTags(&MockHTTPClient{wantErr: true}, &testBaseUrl, &testRepo) + t.Run("test error for non-200 status codes", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + tags, err := fetchTags(&server.URL, &testRepo, testAccessToken) if err == nil { t.Error("error expected") } - if err.Error() != "error making request: oops" { + if !strings.Contains(err.Error(), "error: received status code 500") { t.Errorf("error expected, got %s", err.Error()) } @@ -55,17 +37,18 @@ func Test_fetchTags(t *testing.T) { } }) - t.Run("test error for non-200 status codes", func(t *testing.T) { - tags, err := fetchTags(&MockHTTPClient{mutateFn: func(res *http.Response) { - res.Status = string(rune(400)) - res.Body = io.NopCloser(bytes.NewReader(nil)) - }}, &testBaseUrl, &testRepo) + t.Run("test error parsing json", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("{notTags: error}")) + })) + defer server.Close() + tags, err := fetchTags(&server.URL, &testRepo, testAccessToken) if err == nil { t.Error("error expected") } - if strings.Contains(err.Error(), "tags, error: received status code 400") { + if !strings.Contains(err.Error(), "error unmarshalling response:") { t.Errorf("error expected, got %s", err.Error()) } @@ -74,23 +57,15 @@ func Test_fetchTags(t *testing.T) { } }) - t.Run("test error parsing json", func(t *testing.T) { - tags, err := fetchTags(&MockHTTPClient{mutateFn: func(res *http.Response) { - res.Status = string(rune(200)) - res.Body = io.NopCloser(bytes.NewReader([]byte("{notTags: error}"))) - }}, &testBaseUrl, &testRepo) - - if err == nil { - t.Error("error expected") - } - - if strings.Contains(err.Error(), "error unmarshalling response:") { - t.Errorf("error expected, got %s", err.Error()) - } + t.Run("test bearer token is added to header", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", testAccessToken) { + t.Errorf("unexpected authorization header: %v", r.Header.Get("Authorization")) + } + })) + defer server.Close() - if tags != nil { - t.Error("expected nil tags") - } + _, _ = fetchTags(&server.URL, &testRepo, testAccessToken) }) t.Run("test successful response with tags", func(t *testing.T) { @@ -102,10 +77,13 @@ func Test_fetchTags(t *testing.T) { ] }` - tags, err := fetchTags(&MockHTTPClient{mutateFn: func(res *http.Response) { - res.StatusCode = http.StatusOK - res.Body = io.NopCloser(bytes.NewReader([]byte(mockJSONResponse))) - }}, &testBaseUrl, &testRepo) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(mockJSONResponse)) + })) + defer server.Close() + + tags, err := fetchTags(&server.URL, &testRepo, testAccessToken) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -130,7 +108,7 @@ func Test_fetchTags(t *testing.T) { }) t.Run("test error nil baseUrl", func(t *testing.T) { - _, err := fetchTags(&MockHTTPClient{}, nil, &testRepo) + _, err := fetchTags(nil, &testRepo, testAccessToken) if err == nil { t.Fatal("error expected") } @@ -141,7 +119,7 @@ func Test_fetchTags(t *testing.T) { }) t.Run("test error nil repo", func(t *testing.T) { - _, err := fetchTags(&MockHTTPClient{}, &testBaseUrl, nil) + _, err := fetchTags(&testBaseUrl, nil, testAccessToken) if err == nil { t.Fatal("error expected") } @@ -154,57 +132,68 @@ func Test_fetchTags(t *testing.T) { func Test_deleteTag(t *testing.T) { t.Run("test successful delete", func(t *testing.T) { - client := &MockHTTPClient{mutateFn: func(res *http.Response) { - res.StatusCode = http.StatusNoContent - res.Body = io.NopCloser(bytes.NewReader(nil)) - }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() - err := deleteTag(client, &testBaseUrl, &testRepo, "fake_access_token", "v1.0.0") + err := deleteTag(&server.URL, &testRepo, testAccessToken, "v1.0.0") if err != nil { - t.Error("expected successful delete, got error") + t.Errorf("expected successful delete, got error: %s", err.Error()) } }) t.Run("test delete with error response", func(t *testing.T) { - client := &MockHTTPClient{mutateFn: func(res *http.Response) { - res.StatusCode = http.StatusInternalServerError - res.Body = io.NopCloser(bytes.NewReader([]byte("internal server error"))) - }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() - err := deleteTag(client, &testBaseUrl, &testRepo, "fake_access_token", "v1.0.0") + err := deleteTag(&server.URL, &testRepo, testAccessToken, "v1.0.0") if err == nil { t.Error("expected failure, got success") } - }) - t.Run("test error making delete request", func(t *testing.T) { - client := &MockHTTPClient{wantErr: true} + if !strings.Contains(err.Error(), "Failed to delete tag v1.0.0: Status code 500") { + t.Errorf("error expected, got %s", err.Error()) + } + }) - err := deleteTag(client, &testBaseUrl, &testRepo, "fake_access_token", "v1.0.0") + t.Run("test bearer token is added to header", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", testAccessToken) { + t.Errorf("unexpected authorization header: %v", r.Header.Get("Authorization")) + } + })) + defer server.Close() - if err == nil { - t.Error("expected failure, got success") - } + _ = deleteTag(&server.URL, &testRepo, testAccessToken, "v1.0.0") }) t.Run("test error nil baseUrl", func(t *testing.T) { - client := &MockHTTPClient{} - err := deleteTag(client, nil, &testRepo, "fake_access_token", "v1.0.0") + err := deleteTag(nil, &testRepo, testAccessToken, "v1.0.0") if err == nil { t.Error("expected failure, got success") } + + if err.Error() != "baseURL or repo required" { + t.Errorf("error expected, got %s", err.Error()) + } }) t.Run("test error nil repo", func(t *testing.T) { - client := &MockHTTPClient{} - err := deleteTag(client, &testBaseUrl, nil, "fake_access_token", "v1.0.0") + err := deleteTag(&testBaseUrl, nil, testAccessToken, "v1.0.0") if err == nil { t.Error("expected failure, got success") } + + if err.Error() != "baseURL or repo required" { + t.Errorf("error expected, got %s", err.Error()) + } }) }