diff --git a/src/pkg/reg/adapter/gitlab/adapter.go b/src/pkg/reg/adapter/gitlab/adapter.go index 8b5aa72e31..0119f4ffb6 100644 --- a/src/pkg/reg/adapter/gitlab/adapter.go +++ b/src/pkg/reg/adapter/gitlab/adapter.go @@ -15,6 +15,7 @@ package gitlab import ( + "errors" "net/url" "strings" @@ -228,6 +229,7 @@ func existPatterns(path string, patterns []string) bool { correct := false if len(patterns) > 0 { for _, pathPattern := range patterns { + log.Debug("Checking pathPattern: ", pathPattern, " against path: ", path) if ok, _ := util.Match(strings.ToLower(pathPattern), strings.ToLower(path)); ok { correct = true break @@ -238,3 +240,76 @@ func existPatterns(path string, patterns []string) bool { } return correct } + +func (a *adapter) getProjectsByRepositoryName(repository string) ([]*Project, error) { + repositoriesToTry := []string{repository} + + components := strings.Split(repository, "/") + for i := len(components) - 1; i >= 2; i-- { + repositoriesToTry = append(repositoriesToTry, strings.Join(components[:i], "/")) + } + + for _, repo := range repositoriesToTry { + projects, err := a.clientGitlabAPI.getProjectsByName(url.QueryEscape(repo)) + if err != nil { + continue + } + if len(projects) > 0 { + return projects, nil + } + } + + return []*Project{}, nil +} + +func (a *adapter) DeleteManifest(repository, reference string) error { + log.Errorf("DeleteManifest called with repository: %s, reference: %s", repository, reference) + + projects, err := a.getProjectsByRepositoryName(repository) + if err != nil { + log.Errorf("Failed to get projects by pattern %s: %v", repository, err) + } + if len(projects) == 0 { + log.Errorf("No projects found for pattern %s", repository) + return errors.New("no projects found") + } + projectID := projects[0].ID + + log.Debugf("Project ID: %d", projectID) + + repositories, err := a.clientGitlabAPI.getRepositories(projectID) + if err != nil { + log.Errorf("Failed to get repositories for project %s: %v", repository, err) + } + if len(repositories) == 0 { + log.Errorf("No repositories found for project %s", repository) + return errors.New("no repositories found") + } + + // Filter by hand because the API does not support filtering by repository name + repositoryID := int64(-1) + for _, repo := range repositories { + if repo.Path == repository { + log.Debugf("Found repository ID: %d for path: %s", repositoryID, repo.Path) + repositoryID = repo.ID + break + } + log.Debugf("Skipping repository path=%s and id=%d", repo.Path, repo.ID) + } + + if repositoryID == -1 { + log.Errorf("No repository found for path %s", repository) + return errors.New("no repository found") + } + + log.Debugf("Deleting tag %s from repository %s with ID %d", reference, repository, repositoryID) + + err = a.clientGitlabAPI.deleteTag(projectID, repositoryID, reference) + if err != nil { + log.Errorf("Failed to delete tag %s from repository %s with ID %d: %v", reference, repository, repositoryID, err) + return err + } + log.Debugf("Tag %s deleted successfully from repository %s with ID %d", reference, repository, repositoryID) + + return nil +} diff --git a/src/pkg/reg/adapter/gitlab/adapter_test.go b/src/pkg/reg/adapter/gitlab/adapter_test.go index ccda60b537..5f3272d001 100644 --- a/src/pkg/reg/adapter/gitlab/adapter_test.go +++ b/src/pkg/reg/adapter/gitlab/adapter_test.go @@ -6,7 +6,6 @@ import ( "net/http/httptest" "os" "regexp" - "strconv" "testing" "github.com/stretchr/testify/assert" @@ -28,11 +27,12 @@ func mustWriteHTTPResponse(t *testing.T, w io.Writer, fixturePath string) { } func getServer(t *testing.T) *httptest.Server { mux := http.NewServeMux() + mux.HandleFunc("/v2/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Www-Authenticate", "Bearer realm=\"http://"+r.Host+"/jwt/auth\",service=\"container_registry\"") w.WriteHeader(http.StatusUnauthorized) - }) + mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) { search := r.URL.Query().Get("search") w.Header().Set("X-Next-Page", "") @@ -40,40 +40,42 @@ func getServer(t *testing.T) *httptest.Server { switch search { case "library/dev-docker", "library", "library/", "dev-docker/", "dev-docker": mustWriteHTTPResponse(t, w, "testdata/projects/dev-docker.json") - break case "", "library/dockers": mustWriteHTTPResponse(t, w, "testdata/projects/all.json") - break default: - w.Header().Set("X-Next-Page", "") w.Write([]byte(`[]`)) - break } - }) - for projectID := 1; projectID <= 5; projectID++ { - mux.HandleFunc("/api/v4/projects/"+strconv.Itoa(projectID)+"/registry/repositories", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/v4/projects/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + reRepo := regexp.MustCompile(`/api/v4/projects/(\d+)/registry/repositories/?$`) + if match := reRepo.FindStringSubmatch(path); match != nil { + projectID := match[1] w.Header().Set("X-Next-Page", "") - re := regexp.MustCompile(`projects/(?P\d+)/registry`) - match := re.FindStringSubmatch(r.RequestURI) - mustWriteHTTPResponse(t, w, "testdata/repositories/"+match[1]+".json") - - }) - for repositoryID := 1; repositoryID <= 5; repositoryID++ { - mux.HandleFunc("/api/v4/projects/"+strconv.Itoa(projectID)+"/registry/repositories/"+strconv.Itoa(repositoryID)+"1/tags", func(w http.ResponseWriter, r *http.Request) { - - w.Header().Set("X-Next-Page", "") - re := regexp.MustCompile(`repositories/(?P\d+)/tags`) - match := re.FindStringSubmatch(r.RequestURI) - mustWriteHTTPResponse(t, w, "testdata/tags/"+match[1]+".json") - - }) - + mustWriteHTTPResponse(t, w, "testdata/repositories/"+projectID+".json") + return } - } - server := httptest.NewServer(mux) - return server + + reTags := regexp.MustCompile(`/api/v4/projects/(\d+)/registry/repositories/(\d+)/tags$`) + if match := reTags.FindStringSubmatch(path); match != nil { + repoID := match[2] + w.Header().Set("X-Next-Page", "") + mustWriteHTTPResponse(t, w, "testdata/tags/"+repoID+".json") + return + } + + reDeleteTag := regexp.MustCompile(`/api/v4/projects/(\d+)/registry/repositories/(\d+)/tags/([^/]+)$`) + if match := reDeleteTag.FindStringSubmatch(path); match != nil { + w.WriteHeader(http.StatusNoContent) + return + } + + http.NotFound(w, r) + }) + + return httptest.NewServer(mux) } func getAdapter(t *testing.T) adp.Adapter { @@ -132,3 +134,36 @@ func TestFetchImages(t *testing.T) { require.Equal(t, 1, len(resources)) require.Equal(t, 2, len(resources[0].Metadata.Vtags)) } + +func TestDeleteManifest(t *testing.T) { + assertions := assert.New(t) + ad := getAdapter(t) + adapter := ad.(*adapter) + + t.Run("successful deletion", func(t *testing.T) { + err := adapter.DeleteManifest("library/dockers", "harbor") + require.Nil(t, err) + }) + + t.Run("successful deletion 1 level nested registry", func(t *testing.T) { + err := adapter.DeleteManifest("library/dockers/harbor", "v0.1.1") + require.Nil(t, err) + }) + + t.Run("successful deletion with dev-docker", func(t *testing.T) { + err := adapter.DeleteManifest("library/dev-docker", "latest") + require.Nil(t, err) + }) + + t.Run("no projects found", func(t *testing.T) { + err := adapter.DeleteManifest("nonexistent/repository", "v1.0.0") + require.NotNil(t, err) + assertions.Contains(err.Error(), "no projects found") + }) + + t.Run("no repository found in project", func(t *testing.T) { + err := adapter.DeleteManifest("library/nonexistent-repo", "v1.0.0") + require.NotNil(t, err) + assertions.Contains(err.Error(), "no projects found") + }) +} diff --git a/src/pkg/reg/adapter/gitlab/client.go b/src/pkg/reg/adapter/gitlab/client.go index ad08e18c30..617fbf25f6 100644 --- a/src/pkg/reg/adapter/gitlab/client.go +++ b/src/pkg/reg/adapter/gitlab/client.go @@ -89,6 +89,7 @@ func (c *Client) getProjectsByName(name string) ([]*Project, error) { } return projects, nil } + func (c *Client) getRepositories(projectID int64) ([]*Repository, error) { var repositories []*Repository urlAPI := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories?per_page=50", c.url, projectID) @@ -109,6 +110,26 @@ func (c *Client) getTags(projectID int64, repositoryID int64) ([]*Tag, error) { return tags, nil } +func (c *Client) deleteTag(projectID int64, repositoryID int64, tagName string) error { + urlAPI := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories/%d/tags/%s", c.url, projectID, repositoryID, tagName) + req, err := c.newRequest(http.MethodDelete, urlAPI, nil) + if err != nil { + return err + } + resp, err := c.client.Do(req) + if err != nil { + log.Errorf("Failed to delete tag: %v", err) + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to delete tag with status code %d: %s", resp.StatusCode, string(data)) + } + log.Debugf("Successfully deleted tag in project %d and repository %d", projectID, repositoryID) + return nil +} + // GetAndIteratePagination iterates the pagination header and returns all resources // The parameter "v" must be a pointer to a slice func (c *Client) GetAndIteratePagination(endpoint string, v any) error { diff --git a/src/pkg/reg/adapter/gitlab/client_test.go b/src/pkg/reg/adapter/gitlab/client_test.go index 254f4b7232..f7a03ce1b0 100644 --- a/src/pkg/reg/adapter/gitlab/client_test.go +++ b/src/pkg/reg/adapter/gitlab/client_test.go @@ -84,3 +84,158 @@ func TestProjects(t *testing.T) { require.Nil(t, e) assert.Equal(t, 1, len(projects)) } + +func TestDeleteTag(t *testing.T) { + t.Run("successful deletion", func(t *testing.T) { + server := test.NewServer(&test.RequestHandlerMapping{ + Method: http.MethodDelete, + Pattern: "/api/v4/projects/123/registry/repositories/456/tags/v1.0.0", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }) + defer server.Close() + + client := &Client{ + url: server.URL, + username: "test", + token: "test", + client: common_http.NewClient( + &http.Client{ + Transport: common_http.GetHTTPTransport(common_http.WithInsecure(true)), + }), + } + + err := client.deleteTag(123, 456, "v1.0.0") + require.Nil(t, err) + }) + + t.Run("deletion with 204 status", func(t *testing.T) { + server := test.NewServer(&test.RequestHandlerMapping{ + Method: http.MethodDelete, + Pattern: "/api/v4/projects/123/registry/repositories/456/tags/v1.0.0", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }) + defer server.Close() + + client := &Client{ + url: server.URL, + username: "test", + token: "test", + client: common_http.NewClient( + &http.Client{ + Transport: common_http.GetHTTPTransport(common_http.WithInsecure(true)), + }), + } + + err := client.deleteTag(123, 456, "v1.0.0") + require.Nil(t, err) + }) + + t.Run("deletion with 404 not found", func(t *testing.T) { + server := test.NewServer(&test.RequestHandlerMapping{ + Method: http.MethodDelete, + Pattern: "/api/v4/projects/123/registry/repositories/456/tags/v1.0.0", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message": "Tag not found"}`)) + }, + }) + defer server.Close() + + client := &Client{ + url: server.URL, + username: "test", + token: "test", + client: common_http.NewClient( + &http.Client{ + Transport: common_http.GetHTTPTransport(common_http.WithInsecure(true)), + }), + } + + err := client.deleteTag(123, 456, "v1.0.0") + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to delete tag with status code 404") + assert.Contains(t, err.Error(), "Tag not found") + }) + + t.Run("deletion with 403 forbidden", func(t *testing.T) { + server := test.NewServer(&test.RequestHandlerMapping{ + Method: http.MethodDelete, + Pattern: "/api/v4/projects/123/registry/repositories/456/tags/v1.0.0", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message": "Access denied"}`)) + }, + }) + defer server.Close() + + client := &Client{ + url: server.URL, + username: "test", + token: "test", + client: common_http.NewClient( + &http.Client{ + Transport: common_http.GetHTTPTransport(common_http.WithInsecure(true)), + }), + } + + err := client.deleteTag(123, 456, "v1.0.0") + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to delete tag with status code 403") + assert.Contains(t, err.Error(), "Access denied") + }) + + t.Run("deletion with 500 internal server error", func(t *testing.T) { + server := test.NewServer(&test.RequestHandlerMapping{ + Method: http.MethodDelete, + Pattern: "/api/v4/projects/123/registry/repositories/456/tags/v1.0.0", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"message": "Internal server error"}`)) + }, + }) + defer server.Close() + + client := &Client{ + url: server.URL, + username: "test", + token: "test", + client: common_http.NewClient( + &http.Client{ + Transport: common_http.GetHTTPTransport(common_http.WithInsecure(true)), + }), + } + + err := client.deleteTag(123, 456, "v1.0.0") + require.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to delete tag with status code 500") + assert.Contains(t, err.Error(), "Internal server error") + }) + + t.Run("tag name with special characters", func(t *testing.T) { + server := test.NewServer(&test.RequestHandlerMapping{ + Method: http.MethodDelete, + Pattern: "/api/v4/projects/123/registry/repositories/456/tags/v1.0.0-alpha.1", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }) + defer server.Close() + + client := &Client{ + url: server.URL, + username: "test", + token: "test", + client: common_http.NewClient( + &http.Client{ + Transport: common_http.GetHTTPTransport(common_http.WithInsecure(true)), + }), + } + + err := client.deleteTag(123, 456, "v1.0.0-alpha.1") + require.Nil(t, err) + }) +} diff --git a/src/pkg/reg/adapter/gitlab/testdata/repositories/1.json b/src/pkg/reg/adapter/gitlab/testdata/repositories/1.json index 49d75f7d20..682565dadf 100644 --- a/src/pkg/reg/adapter/gitlab/testdata/repositories/1.json +++ b/src/pkg/reg/adapter/gitlab/testdata/repositories/1.json @@ -6,5 +6,19 @@ "project_id": 1, "location": "registry.gitlab.com/library/dockers", "created_at": "2019-01-17T09:47:07.504Z" + }, + { + "id": 12, + "name": "", + "path": "library/dockers/harbor", + "project_id": 1, + "location": "registry.gitlab.com/library/dockers/harbor" + }, + { + "id": 13, + "name": "", + "path": "library/dockers/harbor/jobservice", + "project_id": 1, + "location": "registry.gitlab.com/library/dockers/harbor/jobservice" } ] \ No newline at end of file diff --git a/src/pkg/reg/adapter/gitlab/testdata/tags/12.json b/src/pkg/reg/adapter/gitlab/testdata/tags/12.json new file mode 100644 index 0000000000..2b7cd43636 --- /dev/null +++ b/src/pkg/reg/adapter/gitlab/testdata/tags/12.json @@ -0,0 +1,12 @@ +[ + { + "name": "latest", + "path": "library/docker/harbor:latest", + "location": "registry.gitlab.com/library/docker/harbor:latest" + }, + { + "name": "v1.0.0", + "path": "library/docker/harbor:v1.0.0", + "location": "registry.gitlab.com/library/docker/harbor:v1.0.0" + } +] \ No newline at end of file diff --git a/src/pkg/reg/adapter/gitlab/testdata/tags/13.json b/src/pkg/reg/adapter/gitlab/testdata/tags/13.json new file mode 100644 index 0000000000..bc14225be2 --- /dev/null +++ b/src/pkg/reg/adapter/gitlab/testdata/tags/13.json @@ -0,0 +1,7 @@ +[ + { + "name": "v0.1.2", + "path": "library/docker/harbor/jobservice:v0.1.2", + "location": "registry.gitlab.com/library/docker/harbor/jobservice:v0.1.2" + } +] \ No newline at end of file