From fda54d59b87aee0a7561389be09d34f33175c324 Mon Sep 17 00:00:00 2001 From: Leon Schmidt Date: Sun, 11 Jan 2026 23:50:21 +0100 Subject: [PATCH] Auto-link container images to repository (#10617) Implements auto-linking container images from the package registry to a repository (closes #2823). This might ease implementing #2699 in the future. Specifically, auto-linking happens on package creation and NOT when publishing updates to the same package. This should prevent "relinking" a manually unlinked package when publishing an update. Linking is performed either via the the Docker label `` (as described here: https://codeberg.org/forgejo/forgejo/issues/2823#issuecomment-8163866) or by naming the image like the repository (supports nested image names). ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - ~~[ ] in their respective `*_test.go` for unit tests.~~ _(Not required, since only already tested functions were used)_ - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - ~~I added test coverage for JavaScript changes...~~ _(No changes to JavaScript code)_ ### Documentation - [X] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change: https://codeberg.org/forgejo/docs/pulls/1666 - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [X] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10617 Reviewed-by: Mathieu Fenniak Co-authored-by: Leon Schmidt Co-committed-by: Leon Schmidt --- modules/packages/container/metadata.go | 12 +- routers/api/packages/container/blob.go | 7 +- routers/api/packages/container/manifest.go | 125 +++++++++- .../api_packages_container_test.go | 219 +++++++++++++++++- 4 files changed, 351 insertions(+), 12 deletions(-) diff --git a/modules/packages/container/metadata.go b/modules/packages/container/metadata.go index 6cac77b7ff..4f332783ba 100644 --- a/modules/packages/container/metadata.go +++ b/modules/packages/container/metadata.go @@ -16,11 +16,12 @@ import ( ) const ( - PropertyRepository = "container.repository" - PropertyDigest = "container.digest" - PropertyMediaType = "container.mediatype" - PropertyManifestTagged = "container.manifest.tagged" - PropertyManifestReference = "container.manifest.reference" + PropertyRepository = "container.repository" + PropertyRepositoryAutolinkingPending = "container.repository.autolinking-pending" + PropertyDigest = "container.digest" + PropertyMediaType = "container.mediatype" + PropertyManifestTagged = "container.manifest.tagged" + PropertyManifestReference = "container.manifest.reference" DefaultPlatform = "linux/amd64" @@ -63,6 +64,7 @@ type Metadata struct { Labels map[string]string `json:"labels,omitempty"` ImageLayers []string `json:"layer_creation,omitempty"` Manifests []*Manifest `json:"manifests,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` } type Manifest struct { diff --git a/routers/api/packages/container/blob.go b/routers/api/packages/container/blob.go index 0e07b03c0c..29de375842 100644 --- a/routers/api/packages/container/blob.go +++ b/routers/api/packages/container/blob.go @@ -105,6 +105,7 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI LowerName: strings.ToLower(pi.Name), } var err error + if p, err = packages_model.TryInsertPackage(ctx, p); err != nil { if err == packages_model.ErrDuplicatePackage { created = false @@ -116,7 +117,11 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI if created { if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(pi.Owner.LowerName+"/"+pi.Name)); err != nil { - log.Error("Error setting package property: %v", err) + log.Error("Error setting package property %s: %v", container_module.PropertyRepository, err) + return err + } + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepositoryAutolinkingPending, "yes"); err != nil { + log.Error("Error setting package property %s: %v", container_module.PropertyRepositoryAutolinkingPending, err) return err } } diff --git a/routers/api/packages/container/manifest.go b/routers/api/packages/container/manifest.go index 428e7605a6..545bfb9f15 100644 --- a/routers/api/packages/container/manifest.go +++ b/routers/api/packages/container/manifest.go @@ -8,17 +8,20 @@ import ( "errors" "fmt" "io" + "net/url" "os" "strings" "forgejo.org/models/db" packages_model "forgejo.org/models/packages" container_model "forgejo.org/models/packages/container" + repo_model "forgejo.org/models/repo" user_model "forgejo.org/models/user" "forgejo.org/modules/json" "forgejo.org/modules/log" packages_module "forgejo.org/modules/packages" container_module "forgejo.org/modules/packages/container" + "forgejo.org/modules/setting" "forgejo.org/modules/util" notify_service "forgejo.org/services/notify" packages_service "forgejo.org/services/packages" @@ -117,6 +120,7 @@ func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *p if err != nil { return err } + metadata.Annotations = manifest.Annotations blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers)) @@ -320,6 +324,7 @@ func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, met LowerName: strings.ToLower(mci.Image), } var err error + if p, err = packages_model.TryInsertPackage(ctx, p); err != nil { if err == packages_model.ErrDuplicatePackage { created = false @@ -331,9 +336,32 @@ func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, met if created { if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(mci.Owner.LowerName+"/"+mci.Image)); err != nil { - log.Error("Error setting package property: %v", err) + log.Error("Error setting package property %s: %v", container_module.PropertyRepository, err) return nil, err } + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepositoryAutolinkingPending, "yes"); err != nil { + log.Error("Error setting package property %s: %v", container_module.PropertyRepositoryAutolinkingPending, err) + return nil, err + } + } + + // Check if auto-linking is required (this only happens after creation of package (not version!)) + autolinkRequiredProps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepositoryAutolinkingPending) + if err != nil { + log.Error("Error getting package properties %s: %v", container_module.PropertyRepositoryAutolinkingPending, err) + return nil, err + } + if len(autolinkRequiredProps) > 0 { + autolinkRequiredProp := autolinkRequiredProps[0] + if autolinkRequiredProp != nil && autolinkRequiredProp.Value == "yes" { // check if auto-link is required (this prevents re-auto-linking on new versions, since the property is not set there) + if _, err := tryAutoLink(ctx, p, mci.Owner.LowerName, mci.Image, metadata, mci.Creator); err != nil { + log.Error("Auto-linking failed for package %d: %v", p.ID, err) + } + // remove property regardless of success/failure to keep behavior consistent and prevent retries on re-runs. + if err := packages_model.DeletePropertyByName(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepositoryAutolinkingPending); err != nil { + return nil, err + } + } } metadata.IsTagged = mci.IsTagged @@ -481,3 +509,98 @@ func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *pack return pb, !exists, manifestDigest, err } + +// Attempty to link a package to a repository in the following order of precedence: by annotation, by label and finally by image name. +// If it fails, it returns false, nil. Only actual errors are returned, so don't use the err return only to determine if the linking was performed. +func tryAutoLink(ctx context.Context, p *packages_model.Package, imageOwner, imageName string, metadata *container_module.Metadata, doer *user_model.User) (linked bool, err error) { + // We can use the same function for linking by annotation as is used for + // linking by label, since the field has the exact same structure + if linkedByAnnotation, err := tryAutolinkByLabel(ctx, p, metadata.Annotations, doer); err != nil { + return false, err + } else if linkedByAnnotation { + log.Info("Image %s/%s was auto-linked by annotation", imageOwner, imageName) + return true, nil + } + + if linkedByLabel, err := tryAutolinkByLabel(ctx, p, metadata.Labels, doer); err != nil { + return false, err + } else if linkedByLabel { + log.Info("Image %s/%s was auto-linked by label", imageOwner, imageName) + return true, nil + } + + if linkedByName, err := tryAutolinkByImageName(ctx, p, imageOwner, imageName, doer); err != nil { + return false, err + } else if linkedByName { + log.Info("Image %s/%s was auto-linked by image name", imageOwner, imageName) + return true, nil + } + + return false, nil +} + +// Tries to link a package to a repository by label from metadata. +// If it fails, it returns false, nil. Only actual errors are returned, so don't use the err return to determine if the linking was performed. +func tryAutolinkByLabel(ctx context.Context, p *packages_model.Package, labels map[string]string, doer *user_model.User) (linked bool, err error) { + if labels == nil { + return false, nil + } + + labelRepo, ok := labels["org.opencontainers.image.source"] + if !ok { + return false, nil + } + + u, err := url.Parse(labelRepo) + if err != nil { + log.Warn("Failed to extract label value org.opencontainers.image.source: value is not in format '{host}/{owner}/{repo}' (is: %s)", labelRepo) + return false, nil // we do not return an error here, since a malformed label should simply be ignored + } + + fullBasePath := fmt.Sprintf("%s://%s/", u.Scheme, u.Host) + if setting.AppURL != fullBasePath { + log.Warn("Failed to extract label value org.opencontainers.image.source: host does not match Forgejo AppURL (is: %s, want: %s)", fullBasePath, setting.AppURL) + return false, nil + } + + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(pathParts) != 2 { + log.Warn("Failed to extract label value org.opencontainers.image.source: value is not in format '{host}/{owner}/{repo}' (is: %s)", labelRepo) + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, pathParts[0], pathParts[1]) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + return false, err // this is a legit error + } + return false, nil + } + + if err := packages_service.LinkToRepository(ctx, p, repository, doer); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { + return false, nil // we don't want an error case if the user does not have write access to the repo they have write access to + } + return false, err + } + return true, nil +} + +// Tries to link a package to a repository by its name (using {owner}/{repo}[/...]). +// If it fails, it returns false, nil. Only actual errors are returned, so don't use the err return to determine if the linking was performed. +func tryAutolinkByImageName(ctx context.Context, p *packages_model.Package, imageOwner, imageName string, doer *user_model.User) (linked bool, err error) { + repoName := strings.SplitN(imageName, "/", 2)[0] // [0] = repo; [1] = remainer (no need to check length since SplitN always returns at least one element) + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, imageOwner, repoName) + if err != nil { + if !repo_model.IsErrRepoNotExist(err) { + return false, err // this is a legit error + } + return false, nil + } + if err := packages_service.LinkToRepository(ctx, p, repository, doer); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { + return false, nil // we don't want an error case if the user does not have write access to the repo they have write access to + } + return false, err + } + return true, nil +} diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index b961415feb..6ab0933931 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -7,8 +7,10 @@ import ( "bytes" "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "net/http" + "strconv" "strings" "sync" "testing" @@ -17,12 +19,15 @@ import ( "forgejo.org/models/db" packages_model "forgejo.org/models/packages" container_model "forgejo.org/models/packages/container" + repo_model "forgejo.org/models/repo" "forgejo.org/models/unittest" user_model "forgejo.org/models/user" + "forgejo.org/modules/git" container_module "forgejo.org/modules/packages/container" "forgejo.org/modules/setting" api "forgejo.org/modules/structs" "forgejo.org/modules/test" + packages_service "forgejo.org/services/packages" "forgejo.org/tests" oci "github.com/opencontainers/image-spec/specs-go/v1" @@ -62,20 +67,20 @@ func TestPackageContainer(t *testing.T) { unknownDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000" - blobDigest := "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" blobContent, _ := base64.StdEncoding.DecodeString(`H4sIAAAJbogA/2IYBaNgFIxYAAgAAP//Lq+17wAEAAA=`) + blobDigest := "sha256:" + sha256Hash(string(blobContent)) - configDigest := "sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d" configContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}` + configDigest := "sha256:" + sha256Hash(configContent) - manifestDigest := "sha256:4f10484d1c1bb13e3956b4de1cd42db8e0f14a75be1617b60f2de3cd59c803c6" manifestContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}` + manifestDigest := "sha256:" + sha256Hash(manifestContent) - untaggedManifestDigest := "sha256:4305f5f5572b9a426b88909b036e52ee3cf3d7b9c1b01fac840e90747f56623d" untaggedManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageManifest + `","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}` + untaggedManifestDigest := "sha256:" + sha256Hash(untaggedManifestContent) - indexManifestDigest := "sha256:bab112d6efb9e7f221995caaaa880352feb5bd8b1faf52fae8d12c113aa123ec" indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + manifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}},{"mediaType":"` + oci.MediaTypeImageManifest + `","digest":"` + untaggedManifestDigest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}}]}` + indexManifestDigest := "sha256:" + sha256Hash(indexManifestContent) anonymousToken := "" readUserToken := "" @@ -906,4 +911,208 @@ func TestPackageContainer(t *testing.T) { }) session.MakeRequest(t, req, http.StatusSeeOther) }) + + t.Run("AutoLinking", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // create repo which is used for auto-linking + repo := createTestRepositoryWithPackageRegistry(t, user, "autolink-repo") + + // Test repo for the private user, used to test unauthorized auto-linking. + // We don't need the repo object, but the name is used in the annotation pushed in the test. + _ = createTestRepositoryWithPackageRegistry(t, privateUser, "autolink-repo") + + // some paths to push to + urlExistingRepo := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, repo.Name) + nameNonexistingRepo1 := "nonexisting-repo" + urlNonexistingRepo1 := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, nameNonexistingRepo1) + nameNonexistingRepo2 := "another-nonexisting-repo" + urlNonexistingRepo2 := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, nameNonexistingRepo2) + nameNonexistingRepo3 := "secret-repo" + urlNonexistingRepo3 := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, nameNonexistingRepo3) + nameNonexistingRepo4 := "more-repo-names-generator" + urlNonexistingRepo4 := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, nameNonexistingRepo4) + nameExistingRepoNested := "nested-image1" + urlExistingRepoNested := fmt.Sprintf("%sv2/%s/%s/%s", setting.AppURL, user.Name, repo.Name, nameExistingRepoNested) + + // variable to hold an auto-linked package, which will be unlinked again in a later test + var linkedPackage *packages_model.Package + + t.Run("PushToArbitraryRepo", func(t *testing.T) { + // Upload blobs and manifest + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo1, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo1, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlNonexistingRepo1, "v1"), strings.NewReader(manifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) + + p, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, nameNonexistingRepo1) + require.NoError(t, err) + require.Equal(t, nameNonexistingRepo1, p.Name) // just to make sure we have grabbed the correct package + assert.Equal(t, int64(0), p.RepoID) + }) + + t.Run("PushToExisingRepo", func(t *testing.T) { + // Upload blobs and manifest which should create a package with tag "v1" + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlExistingRepo, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlExistingRepo, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlExistingRepo, "v1"), strings.NewReader(manifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) + + // get the resulting package + p, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, repo.Name) + require.NoError(t, err) + require.Equal(t, repo.Name, p.Name) // just to make sure we have grabbed the correct package + assert.Equal(t, repo.ID, p.RepoID) + linkedPackage = p // store auto-linked package for the next test + }) + + t.Run("PushToExistingRepoNested", func(t *testing.T) { + // Upload blobs and manifest which should create a package with tag "v1" + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlExistingRepoNested, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlExistingRepoNested, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlExistingRepoNested, "v1"), strings.NewReader(manifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) + + // get the resulting package + p, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, repo.Name+"/"+nameExistingRepoNested) + require.NoError(t, err) + require.Equal(t, repo.Name+"/"+nameExistingRepoNested, p.Name) // just to make sure we have grabbed the correct package + assert.Equal(t, repo.ID, p.RepoID) + }) + + t.Run("PushVersionToUnlinkedRepo", func(t *testing.T) { + // unlink previously auto-linked package + require.NoError(t, + packages_service.UnlinkFromRepository(t.Context(), linkedPackage, user), + ) + // test if correctly unlinked + checkPackageForUnlinked, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, repo.Name) + require.NoError(t, err) + require.Equal(t, int64(0), checkPackageForUnlinked.RepoID) + + // push updated version (e.g. tag v2) + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlExistingRepo, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlExistingRepo, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlExistingRepo, "v2"), strings.NewReader(manifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) + + // test if still unlinked + checkPackageForStillUnlinked, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, repo.Name) + require.NoError(t, err) + assert.Equal(t, int64(0), checkPackageForStillUnlinked.RepoID) + }) + + t.Run("PushWithLabel", func(t *testing.T) { + // Pushes to non-existing path but tries to link using an image label. + + // same as configContent, but with the added label in config: "org.opencontainers.image.source": "{AppURL}/user2/autolink-repo" + configWithOpenContainersSourceLabelContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Labels":{"org.opencontainers.image.source":"` + setting.AppURL + `user2/autolink-repo"},"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}` + configWithOpenContainersSourceLabelDigest := "sha256:" + sha256Hash(configWithOpenContainersSourceLabelContent) + manifestWithOpenContainersSourceLabelContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"` + configWithOpenContainersSourceLabelDigest + `","size":` + strconv.Itoa(len(configWithOpenContainersSourceLabelContent)) + `},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"` + blobDigest + `","size":32}]}` + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo2, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo2, configWithOpenContainersSourceLabelDigest), strings.NewReader(configWithOpenContainersSourceLabelContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlNonexistingRepo2, "v1"), strings.NewReader(manifestWithOpenContainersSourceLabelContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) + + p, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, nameNonexistingRepo2) + require.NoError(t, err) + require.Equal(t, nameNonexistingRepo2, p.Name) // just to make sure we have grabbed the correct package + assert.Equal(t, repo.ID, p.RepoID) + }) + + t.Run("PushWithAnnotation", func(t *testing.T) { + // Pushes to non-existing path but tries to link using a push annotation in the manifest. + + // same as configContent, but with the added annotation directly within the manifest: "org.opencontainers.image.source": "{AppURL}/user2/autolink-repo" + manifestWithOpenContainersSourceAnnotationContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"` + configDigest + `","size":` + strconv.Itoa(len(configContent)) + `},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"` + blobDigest + `","size":32}],"annotations":{"org.opencontainers.image.source":"` + setting.AppURL + `user2/autolink-repo"}}` + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo3, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo3, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlNonexistingRepo3, "v1"), strings.NewReader(manifestWithOpenContainersSourceAnnotationContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) + + p, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, nameNonexistingRepo3) + require.NoError(t, err) + require.Equal(t, nameNonexistingRepo3, p.Name) // just to make sure we have grabbed the correct package + assert.Equal(t, repo.ID, p.RepoID) + }) + + t.Run("PushWithAnnotationNoPermissions", func(t *testing.T) { + // This tests pushes a manifest as user2, but tries to link to an existing repo of user31. + // This should fail silently with the created package not automatically getting linked. + + // same as configContent above (also uses blob[Digest/Content]), but with an added annotation to auto-link to a repo of the private user: "org.opencontainers.image.source": "{AppURL}/user31/autolink-repo" + manifestWithOpenContainersSourceAnnotationPrivateUserContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"` + configDigest + `","size":` + strconv.Itoa(len(configContent)) + `},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"` + blobDigest + `","size":32}],"annotations":{"org.opencontainers.image.source":"` + setting.AppURL + `user31/autolink-repo"}}` + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo4, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", urlNonexistingRepo4, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", urlNonexistingRepo4, "v1"), strings.NewReader(manifestWithOpenContainersSourceAnnotationPrivateUserContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusCreated) // wrongly annotated pushes still get pushed, but not auto linked + + p, err := packages_model.GetPackageByName(t.Context(), user.ID, packages_model.TypeContainer, nameNonexistingRepo4) + require.NoError(t, err) + require.Equal(t, nameNonexistingRepo4, p.Name) // just to make sure we have grabbed the correct package + assert.Equal(t, int64(0), p.RepoID) // ensure not linked + }) + }) +} + +func createTestRepositoryWithPackageRegistry(t *testing.T, user *user_model.User, name string) *repo_model.Repository { + ctx := NewAPITestContext(t, user.Name, name, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepo", doAPICreateRepository(ctx, nil, git.Sha1ObjectFormat, func(t *testing.T, r api.Repository) { + require.True(t, r.HasPackages) + })) + + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, name) + require.NoError(t, err) + + return repo +} + +func sha256Hash(in string) string { + sum := sha256.Sum256([]byte(in)) + return hex.EncodeToString(sum[:]) }