This commit is contained in:
martin 2025-09-29 15:13:13 +02:00 committed by GitHub
commit abb3a20e2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 346 additions and 27 deletions

View File

@ -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
}

View File

@ -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<id>\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<id>\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")
})
}

View File

@ -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 {

View File

@ -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)
})
}

View File

@ -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"
}
]

View File

@ -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"
}
]

View File

@ -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"
}
]