Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attempt to retrieve manifest from local repo for manifest requests to proxy #21123

Closed
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 89 additions & 22 deletions src/controller/proxy/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ type Controller interface {
// UseLocalBlob check if the blob should use local copy
UseLocalBlob(ctx context.Context, art lib.ArtifactInfo) bool
// UseLocalManifest check manifest should use local copy
UseLocalManifest(ctx context.Context, art lib.ArtifactInfo, remote RemoteInterface) (bool, *ManifestList, error)
UseLocalManifest(ctx context.Context, art lib.ArtifactInfo, remote RemoteInterface) (bool, Manifests, error)
// ProxyBlob proxy the blob request to the remote server, p is the proxy project
// art is the ArtifactInfo which includes the digest of the blob
ProxyBlob(ctx context.Context, p *proModels.Project, art lib.ArtifactInfo) (int64, io.ReadCloser, error)
Expand Down Expand Up @@ -142,18 +142,56 @@ func (c *controller) UseLocalBlob(ctx context.Context, art lib.ArtifactInfo) boo
return exist
}

// Manifests is an interface implemented by Manifest and ManifestList
type Manifests interface {
Content() []byte
Digest() string
ContentType() string
}

// Manifest ...
type Manifest struct {
content []byte
digest string
contentType string
}

func (m *Manifest) Content() []byte {
return m.content
}

func (m *Manifest) Digest() string {
return m.digest
}

func (m *Manifest) ContentType() string {
return m.contentType
}

// ManifestList ...
type ManifestList struct {
Content []byte
Digest string
ContentType string
content []byte
digest string
contentType string
}

func (m *ManifestList) Content() []byte {
return m.content
}

func (m *ManifestList) Digest() string {
return m.digest
}

func (m *ManifestList) ContentType() string {
return m.contentType
}

// UseLocalManifest check if these manifest could be found in local registry,
// the return error should be nil when it is not found in local and need to delegate to remote registry
// the return error should be NotFoundError when it is not found in remote registry
// the error will be captured by framework and return 404 to client
func (c *controller) UseLocalManifest(ctx context.Context, art lib.ArtifactInfo, remote RemoteInterface) (bool, *ManifestList, error) {
func (c *controller) UseLocalManifest(ctx context.Context, art lib.ArtifactInfo, remote RemoteInterface) (bool, Manifests, error) {
a, err := c.local.GetManifest(ctx, art)
if err != nil {
return false, nil, err
Expand All @@ -175,35 +213,64 @@ func (c *controller) UseLocalManifest(ctx context.Context, art lib.ArtifactInfo,
return false, nil, errors.NotFoundError(fmt.Errorf("repo %v, tag %v not found", art.Repository, art.Tag))
}

// Use digest to retrieve manifest, digest is more stable than tag, because tag could be updated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what you changed in this part is already covered previous line 202

Copy link
Author

@raphaelzoellner raphaelzoellner Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous line 202 is the return of a manifest list if it could be fetched from the cache.

return true, &ManifestList{content, string(desc.Digest), contentType}, nil

But since manifests are not cached (in redis) this logs a not found error in previous line 190 for GET .../manifests/{tag} requests referencing a tag.

log.Debugf("Digest is not found in manifest list cache, key=cache:%v", manifestListKey(art.Repository, art))

and returns false (when a is nil, because the artifact with the matching digest in the local repository must not have an associated tag) and without a manifest in line 194.
return a != nil && string(desc.Digest) == a.Digest, nil, nil

This then leads to the manifest not being served from the local repository

if useLocal {
if man != nil {
w.Header().Set(contentLength, fmt.Sprintf("%v", len(man.Content)))
w.Header().Set(contentType, man.ContentType)
w.Header().Set(dockerContentDigest, man.Digest)
w.Header().Set(etag, man.Digest)
if r.Method == http.MethodGet {
_, err = w.Write(man.Content)
if err != nil {
return err
}
}
return nil
}
next.ServeHTTP(w, r)
return nil
}
log.Debugf("the tag is %v, digest is %v", art.Tag, art.Digest)
if r.Method == http.MethodHead {
err = proxyManifestHead(ctx, w, proxyCtl, p, art, remote)
} else if r.Method == http.MethodGet {
log.Warningf("Artifact: %v:%v, digest:%v is not found in proxy cache, fetch it from remote repo", art.Repository, art.Tag, art.Digest)
err = proxyManifestGet(ctx, w, proxyCtl, p, art, remote)
}

My intention with this part in new lines 223-234 was to check prior to the manifest list cache if a manifest with the digest already exists in the local repository and if found return it.

Wouldn't this extend the current behavior for that case?

Copy link
Author

@raphaelzoellner raphaelzoellner Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm new to harbor and I think my proposed implementation would skip other middlewares, which might be a problem.

// manifest
root.NewRoute().
Method(http.MethodGet).
Path("/*/manifests/:reference").
Middleware(metric.InjectOpIDMiddleware(metric.ManifestOperationID)).
Middleware(repoproxy.ManifestMiddleware()).
Middleware(contenttrust.ContentTrust()).
Middleware(vulnerable.Middleware()).
HandlerFunc(getManifest)
root.NewRoute().
Method(http.MethodHead).
Path("/*/manifests/:reference").
Middleware(metric.InjectOpIDMiddleware(metric.ManifestOperationID)).
Middleware(repoproxy.ManifestMiddleware()).
Middleware(contenttrust.ContentTrust()).
Middleware(vulnerable.Middleware()).
HandlerFunc(getManifest)

I will close this PR.

I think to solve this one would need to make sure that the initally proxied and cached artifact in the local repository will be referenced by the tag.

// Pass digest to local repo
// Pass digest to the cache key
if len(art.Digest) == 0 {
art.Digest = string(desc.Digest)
}

// attempt to retrieve manifest from local repo
man, dig, err := c.local.PullManifest(art.Repository, art.Digest)
if err == nil {
log.Debugf("Got the manifest for repo: %v with digest: %v", art.Repository, dig)
mediaType, payload, err := man.Payload()
if err != nil {
log.Errorf("Failed to get payload for manifest with digest: %v, error: %v", dig, err)
}
return true, &Manifest{content: payload, digest: dig, contentType: mediaType}, nil
}
log.Errorf("Failed to get manifest from local repo, error: %v", err)

var content []byte
var contentType string
if c.cache == nil {
return a != nil && string(desc.Digest) == a.Digest, nil, nil // digest matches
}
// Pass digest to the cache key, digest is more stable than tag, because tag could be updated
if len(art.Digest) == 0 {
art.Digest = string(desc.Digest)
}

// attempt to retrieve manifest list from cache
err = c.cache.Fetch(ctx, manifestListKey(art.Repository, art), &content)
if err != nil {
if errors.Is(err, cache.ErrNotFound) {
log.Debugf("Digest is not found in manifest list cache, key=cache:%v", manifestListKey(art.Repository, art))
} else {
log.Errorf("Failed to get manifest list from cache, error: %v", err)
if err == nil {
// manifest list was found in cache
err = c.cache.Fetch(ctx, manifestListContentTypeKey(art.Repository, art), &contentType)
if err != nil {
log.Debugf("failed to get the manifest list content type, not use local. error:%v", err)
return false, nil, nil
}
return a != nil && string(desc.Digest) == a.Digest, nil, nil
log.Debugf("Get the manifest list with key=cache:%v", manifestListKey(art.Repository, art))
return true, &ManifestList{content, string(desc.Digest), contentType}, nil
}
err = c.cache.Fetch(ctx, manifestListContentTypeKey(art.Repository, art), &contentType)
if err != nil {
log.Debugf("failed to get the manifest list content type, not use local. error:%v", err)
return false, nil, nil
if errors.Is(err, cache.ErrNotFound) {
log.Debugf("Digest is not found in manifest list cache, key=cache:%v", manifestListKey(art.Repository, art))
} else {
log.Errorf("Failed to get manifest list from cache, error: %v", err)
}
log.Debugf("Get the manifest list with key=cache:%v", manifestListKey(art.Repository, art))
return true, &ManifestList{content, string(desc.Digest), contentType}, nil

// neither manifest was found in local repo nor manifest list was found in cache
return a != nil && string(desc.Digest) == a.Digest, nil, nil
}

func manifestKey(repo string, art lib.ArtifactInfo) string {
// actual redis key format is cache:manifest:<repo name>:<tag> or cache:manifest:<repo name>:sha256:xxxx
return "manifest:" + repo + ":" + getReference(art)
}

func manifestContentTypeKey(rep string, art lib.ArtifactInfo) string {
return manifestKey(rep, art) + ":contenttype"
}

func manifestListKey(repo string, art lib.ArtifactInfo) string {
// actual redis key format is cache:manifestlist:<repo name>:<tag> or cache:manifestlist:<repo name>:sha256:xxxx
// actual redis key format is cache:manifest:<repo name>:<tag> or cache:manifest:<repo name>:sha256:xxxx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manifestListKey is only cache the manfest List, not used to cache a normal manifest

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, updating this comment was a mistake. I will revert it.

return "manifestlist:" + repo + ":" + getReference(art)
}

Expand Down
108 changes: 106 additions & 2 deletions src/controller/proxy/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package proxy

import (
"context"
"fmt"
"io"
"testing"

Expand All @@ -31,6 +32,8 @@ import (
"github.com/goharbor/harbor/src/lib/errors"
proModels "github.com/goharbor/harbor/src/pkg/project/models"
testproxy "github.com/goharbor/harbor/src/testing/controller/proxy"
"github.com/goharbor/harbor/src/testing/lib/cache"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

type localInterfaceMock struct {
Expand Down Expand Up @@ -64,6 +67,17 @@ func (l *localInterfaceMock) PushBlob(localRepo string, desc distribution.Descri
panic("implement me")
}

func (l *localInterfaceMock) PullManifest(repo string, ref string) (distribution.Manifest, string, error) {
args := l.Called(repo, ref)

var d distribution.Manifest
if arg := args.Get(0); arg != nil {
d = arg.(distribution.Manifest)
}

return d, args.String(1), args.Error(2)
}

func (l *localInterfaceMock) PushManifest(repo string, tag string, manifest distribution.Manifest) error {
args := l.Called(repo, tag, manifest)
return args.Error(0)
Expand All @@ -84,7 +98,7 @@ type proxyControllerTestSuite struct {
suite.Suite
local *localInterfaceMock
remote *testproxy.RemoteInterface
ctr Controller
ctr *controller
proj *proModels.Project
}

Expand Down Expand Up @@ -114,12 +128,15 @@ func (p *proxyControllerTestSuite) TestUseLocalManifest_False() {
ctx := context.Background()
dig := "sha256:1a9ec845ee94c202b2d5da74a24f0ed2058318bfa9879fa541efaecba272e86b"
desc := &distribution.Descriptor{Digest: digest.Digest(dig)}
art := lib.ArtifactInfo{Repository: "library/hello-world", Digest: dig}
repo := "library/hello-world"
art := lib.ArtifactInfo{Repository: repo, Digest: dig}
p.remote.On("ManifestExist", mock.Anything, mock.Anything).Return(true, desc, nil)
p.local.On("GetManifest", mock.Anything, mock.Anything).Return(nil, nil)
p.local.On("PullManifest", repo, string(desc.Digest)).Times(1).Return(nil, "", fmt.Errorf("could not pull manifest"))
result, _, err := p.ctr.UseLocalManifest(ctx, art, p.remote)
p.Assert().Nil(err)
p.Assert().False(result)
p.local.AssertExpectations(p.T())
}

func (p *proxyControllerTestSuite) TestUseLocalManifest_429() {
Expand Down Expand Up @@ -157,6 +174,93 @@ func (p *proxyControllerTestSuite) TestUseLocalManifestWithTag_False() {
p.Assert().False(result)
}

func (p *proxyControllerTestSuite) TestUseLocalManifestWithTag_LocalRepoTrueManifest() {
manifest := `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.example.config.v1+json",
"digest": "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
"size": 123
},
"layers": [
{
"mediaType": "application/vnd.example.data.v1.tar+gzip",
"digest": "sha256:e258d248fda94c63753607f7c4494ee0fcbe92f1a76bfdac795c9d84101eb317",
"size": 1234
}
],
"annotations": {
"com.example.key1": "value1"
}
}`
man, desc, err := distribution.UnmarshalManifest(v1.MediaTypeImageManifest, []byte(manifest))
p.Require().NoError(err)
mediaType, payload, err := man.Payload()
p.Require().NoError(err)

ctx := context.Background()
repo := "library/hello-world"
art := lib.ArtifactInfo{Repository: repo, Tag: "latest"}
p.local.On("GetManifest", mock.Anything, mock.Anything).Return(&artifact.Artifact{}, nil)
p.remote.On("ManifestExist", mock.Anything, mock.Anything).Return(true, &desc, nil)
p.local.On("PullManifest", repo, string(desc.Digest)).Times(1).Return(man, string(desc.Digest), nil)

result, manifests, err := p.ctr.UseLocalManifest(ctx, art, p.remote)

p.Assert().NoError(err)
p.Assert().True(result)
p.Assert().NotNil(manifests)
p.Assert().Equal(mediaType, manifests.ContentType())
p.Assert().Equal(string(desc.Digest), manifests.Digest())
p.Assert().Equal(payload, manifests.Content())

p.local.AssertExpectations(p.T())
}

func (p *proxyControllerTestSuite) TestUseLocalManifestWithTag_CacheTrueManifestList() {
c := cache.NewCache(p.T())
p.ctr.cache = c

ctx := context.Background()
repo := "library/hello-world"
art := lib.ArtifactInfo{Repository: repo, Tag: "latest"}
dig := "sha256:1a9ec845ee94c202b2d5da74a24f0ed2058318bfa9879fa541efaecba272e86b"
desc := &distribution.Descriptor{Digest: digest.Digest(dig)}
content := "some content"
contentType := "some content type"
p.local.On("GetManifest", mock.Anything, mock.Anything).Return(&artifact.Artifact{}, nil)
p.remote.On("ManifestExist", mock.Anything, mock.Anything).Return(true, desc, nil)
p.local.On("PullManifest", repo, string(desc.Digest)).Times(1).Return(nil, "", fmt.Errorf("could not pull manifest"))
artInfoWithDigest := art
artInfoWithDigest.Digest = dig
c.On("Fetch", mock.Anything, manifestListKey(art.Repository, artInfoWithDigest), mock.Anything).
Times(1).
Run(func(args mock.Arguments) {
ct := args.Get(2).(*[]byte)
*ct = []byte(content)
}).
Return(nil)
c.On("Fetch", mock.Anything, manifestListContentTypeKey(art.Repository, artInfoWithDigest), mock.Anything).
Times(1).
Run(func(args mock.Arguments) {
ct := args.Get(2).(*string)
*ct = contentType
}).
Return(nil)

result, manifests, err := p.ctr.UseLocalManifest(ctx, art, p.remote)

p.Assert().NoError(err)
p.Assert().True(result)
p.Assert().NotNil(manifests)
p.Assert().Equal(contentType, manifests.ContentType())
p.Assert().Equal(string(desc.Digest), manifests.Digest())
p.Assert().Equal([]byte(content), manifests.Content())

p.local.AssertExpectations(p.T())
}

func (p *proxyControllerTestSuite) TestUseLocalBlob_True() {
ctx := context.Background()
dig := "sha256:1a9ec845ee94c202b2d5da74a24f0ed2058318bfa9879fa541efaecba272e86b"
Expand Down
6 changes: 6 additions & 0 deletions src/controller/proxy/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ type localInterface interface {
GetManifest(ctx context.Context, art lib.ArtifactInfo) (*artifact.Artifact, error)
// PushBlob push blob to local repo
PushBlob(localRepo string, desc distribution.Descriptor, bReader io.ReadCloser) error
// PullManifest pulls manifest from local repo, ref can be digest or tag
PullManifest(repo string, ref string) (distribution.Manifest, string, error)
// PushManifest push manifest to local repo, ref can be digest or tag
PushManifest(repo string, ref string, manifest distribution.Manifest) error
// CheckDependencies check if the manifest's dependency is ready
Expand Down Expand Up @@ -109,6 +111,10 @@ func (l *localHelper) PushBlob(localRepo string, desc distribution.Descriptor, b
return err
}

func (l *localHelper) PullManifest(repo string, ref string) (distribution.Manifest, string, error) {
return l.registry.PullManifest(repo, ref)
}

func (l *localHelper) PushManifest(repo string, ref string, manifest distribution.Manifest) error {
// Make sure there is only one go routing to push current artName to local repo
artName := repo + ":" + ref
Expand Down
10 changes: 5 additions & 5 deletions src/server/middleware/repoproxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,12 @@ func handleManifest(w http.ResponseWriter, r *http.Request, next http.Handler) e
}
if useLocal {
if man != nil {
w.Header().Set(contentLength, fmt.Sprintf("%v", len(man.Content)))
w.Header().Set(contentType, man.ContentType)
w.Header().Set(dockerContentDigest, man.Digest)
w.Header().Set(etag, man.Digest)
w.Header().Set(contentLength, fmt.Sprintf("%v", len(man.Content())))
w.Header().Set(contentType, man.ContentType())
w.Header().Set(dockerContentDigest, man.Digest())
w.Header().Set(etag, man.Digest())
if r.Method == http.MethodGet {
_, err = w.Write(man.Content)
_, err = w.Write(man.Content())
if err != nil {
return err
}
Expand Down