Proxy the referrer's API to upstream registry#22746
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #22746 +/- ##
===========================================
+ Coverage 45.36% 65.90% +20.53%
===========================================
Files 244 1074 +830
Lines 13333 116555 +103222
Branches 2719 2935 +216
===========================================
+ Hits 6049 76815 +70766
- Misses 6983 35480 +28497
- Partials 301 4260 +3959
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
0abb982 to
5399759
Compare
7d09aa3 to
518166e
Compare
518166e to
0a412af
Compare
0a412af to
1936a24
Compare
Signed-off-by: stonezdj <stonezdj@gmail.com>
1936a24 to
335e879
Compare
There was a problem hiding this comment.
Pull request overview
Adds support for proxy-cache projects to proxy the OCI Distribution Spec 1.1 Referrers API (/v2/<name>/referrers/<digest>) to the upstream registry, addressing Harbor issue #20808.
Changes:
- Add
ListReferrerssupport across the registry client/adapter/proxy remote layers. - Introduce a per-project metadata flag (
proxy_referrer_api) and wire a new middleware to proxy referrers requests to upstream (with caching for unhealthy upstream scenarios). - Persist a new
sourcefield on accessories (DB + model) to distinguish locally-created vs proxycache-created accessories.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/testing/pkg/registry/fake_registry_client.go | Extends mock registry client with ListReferrers. |
| src/testing/controller/proxy/remote_interface.go | Extends proxy remote interface mock with ListReferrers. |
| src/server/v2.0/handler/project_metadata.go | Allows proxy_referrer_api in project metadata validation. |
| src/server/registry/route.go | Adds ProxyReferrerMiddleware to the referrers route. |
| src/server/middleware/subject/subject.go | Adds accessory Source assignment (local vs proxycache). |
| src/server/middleware/repoproxy/proxy.go | Implements referrers proxying + caching + header forwarding. |
| src/server/middleware/repoproxy/proxy_test.go | Adds tests for referrers proxy helpers/middleware (currently placeholders). |
| src/pkg/registry/client.go | Adds registry client ListReferrers + URL builder. |
| src/pkg/registry/client_test.go | Adds tests for ListReferrers and buildReferrersURL. |
| src/pkg/registry/auth/bearer/scope.go | Adds referrers URL parsing for token scope generation. |
| src/pkg/registry/auth/bearer/scope_test.go | Adds test coverage for referrers scope parsing. |
| src/pkg/reg/adapter/adapter.go | Extends ArtifactRegistry interface with ListReferrers. |
| src/pkg/project/models/project.go | Adds Project.ProxyReferrerAPI() helper. |
| src/pkg/project/models/pro_meta.go | Adds ProMetaProxyReferrerAPI metadata key constant. |
| src/pkg/accessory/model/accessory.go | Adds Source to accessory API/data model. |
| src/pkg/accessory/manager.go | Maps Source through accessory manager read/write paths. |
| src/pkg/accessory/dao/model.go | Adds source ORM column to accessory DAO model. |
| src/controller/replication/transfer/image/transfer_test.go | Updates fake registry to satisfy new interface method. |
| src/controller/replication/flow/mock_adapter_test.go | Updates mock adapter with ListReferrers. |
| src/controller/proxy/remote.go | Adds ListReferrers to proxy remote helper interface/impl. |
| src/controller/proxy/controller.go | Exports GetRemoteRepo for reuse. |
| src/controller/proxy/controller_test.go | Updates tests to use exported GetRemoteRepo. |
| make/migrations/postgresql/0180_2.15.0_schema.up.sql | Adds artifact_accessory.source column. |
| api/v2.0/swagger.yaml | Updates description for proxy_referrer_api metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if err := orm.WithTransaction(func(ctx context.Context) error { | ||
| source := sourceLocal | ||
| securityCtx, exist := security.FromContext(ctx) | ||
| if !exist && securityCtx != nil && securityCtx.GetUsername() == proxycachesecret.ProxyCacheService { |
There was a problem hiding this comment.
The proxy cache source detection is inverted: security.FromContext returns (nil, false) when no security context is present, so the condition if !exist && securityCtx != nil ... can never be true. This prevents accData.Source from ever being set to proxycache even when the request is made by proxycachesecret.ProxyCacheService. Flip the condition to check exist (and then compare username) before setting sourceProxyCache.
| if !exist && securityCtx != nil && securityCtx.GetUsername() == proxycachesecret.ProxyCacheService { | |
| if exist && securityCtx != nil && securityCtx.GetUsername() == proxycachesecret.ProxyCacheService { |
| WriteProxyHeaders(w, headerMap) | ||
| w.WriteHeader(http.StatusOK) | ||
| b, err := json.Marshal(index) | ||
| if err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
proxyReferrerGet writes headers/status (WriteProxyHeaders + WriteHeader(200)) before json.Marshal(index) can fail. If marshaling fails, the middleware falls back to next.ServeHTTP, but the response has already started, risking a double-write/corrupted response. Marshal first (or otherwise ensure no further handler runs after headers are written), then write headers/status/body.
| WriteProxyHeaders(w, headerMap) | |
| w.WriteHeader(http.StatusOK) | |
| b, err := json.Marshal(index) | |
| if err != nil { | |
| return err | |
| } | |
| b, err := json.Marshal(index) | |
| if err != nil { | |
| return err | |
| } | |
| WriteProxyHeaders(w, headerMap) | |
| w.WriteHeader(http.StatusOK) |
| // TestProxyReferrerMiddlewareNonProxyProject tests that non-proxy projects bypass the middleware | ||
| func TestProxyReferrerMiddlewareNonProxyProject(t *testing.T) { | ||
| // Create a non-proxy project | ||
| proj := &proModels.Project{ | ||
| ProjectID: 1, | ||
| Name: "library", | ||
| RegistryID: 0, // 0 indicates non-proxy project | ||
| } | ||
|
|
||
| // Create context with artifact info | ||
| ctx := context.Background() | ||
| artInfo := lib.ArtifactInfo{ | ||
| ProjectName: "library", | ||
| Repository: "library/hello-world", | ||
| Reference: "latest", | ||
| Digest: "sha256:abc123", | ||
| } | ||
| ctx = lib.WithArtifactInfo(ctx, artInfo) | ||
|
|
||
| // Create request | ||
| req := httptest.NewRequest("GET", "/v2/library/hello-world/referrers", nil) | ||
| req = req.WithContext(ctx) | ||
|
|
||
| // Create response writer | ||
| _ = httptest.NewRecorder() | ||
|
|
||
| // Track if next handler was called | ||
| _ = false | ||
| nextHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | ||
| rw.WriteHeader(http.StatusOK) | ||
| }) | ||
|
|
||
| // Create the middleware - we need to temporarily replace the project controller | ||
| middleware := ProxyReferrerMiddleware() | ||
|
|
||
| // Apply middleware | ||
| handler := middleware(nextHandler) | ||
|
|
||
| // This will fail without mocking, but demonstrates the test structure | ||
| // In a real scenario, we'd need to mock the project controller | ||
| t.Run("non-proxy project should call next handler", func(t *testing.T) { | ||
| // The test here demonstrates the expected behavior | ||
| // In practice, you would mock the project.Ctl.GetByName to return the non-proxy project | ||
| require.NotNil(t, handler) | ||
| require.False(t, proj.IsProxy()) | ||
| _ = req | ||
| }) |
There was a problem hiding this comment.
These new middleware tests don't exercise behavior: they never call handler.ServeHTTP and only assert on local variables (with comments like "This will fail without mocking"). As written they will pass even if ProxyReferrerMiddleware is broken. Either implement proper mocking (e.g., patch project.Ctl/registry.Ctl and validate response/next-handler invocation) or remove these placeholder tests.
| // Create request | ||
| req := httptest.NewRequest("GET", "/v2/library/hello-world/referrers", nil) | ||
| req = req.WithContext(ctx) | ||
|
|
||
| // Create response writer | ||
| _ = httptest.NewRecorder() | ||
|
|
||
| // Track if next handler was called | ||
| _ = false | ||
| nextHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | ||
| rw.WriteHeader(http.StatusOK) | ||
| }) | ||
|
|
||
| // Create the middleware - we need to temporarily replace the project controller | ||
| middleware := ProxyReferrerMiddleware() | ||
|
|
||
| // Apply middleware | ||
| handler := middleware(nextHandler) | ||
|
|
||
| // This will fail without mocking, but demonstrates the test structure | ||
| // In a real scenario, we'd need to mock the project controller | ||
| t.Run("non-proxy project should call next handler", func(t *testing.T) { | ||
| // The test here demonstrates the expected behavior | ||
| // In practice, you would mock the project.Ctl.GetByName to return the non-proxy project | ||
| require.NotNil(t, handler) | ||
| require.False(t, proj.IsProxy()) | ||
| _ = req | ||
| }) | ||
| } | ||
|
|
||
| // TestProxyReferrerMiddlewareProjectNotFound tests error handling when project is not found | ||
| func TestProxyReferrerMiddlewareProjectNotFound(t *testing.T) { | ||
| // Create context with artifact info | ||
| ctx := context.Background() | ||
| artInfo := lib.ArtifactInfo{ | ||
| ProjectName: "unknown", | ||
| Repository: "unknown/image", | ||
| Reference: "latest", | ||
| Digest: "sha256:abc123", | ||
| } | ||
| ctx = lib.WithArtifactInfo(ctx, artInfo) | ||
|
|
||
| // Create request | ||
| req := httptest.NewRequest("GET", "/v2/unknown/image/referrers", nil) | ||
| req = req.WithContext(ctx) |
There was a problem hiding this comment.
The request paths used in these tests (e.g. .../referrers without /:reference) don't match the actual routed referrers endpoint (/v2/*/referrers/:reference). Even after adding mocks, these tests won't reflect real requests unless they include a digest/reference segment.
| func (c *client) ListReferrers(repository, ref string, rawQuery string) (*v1.Index, map[string][]string, error) { | ||
| remoteURL := buildReferrersURL(c.url, repository, ref, rawQuery) | ||
| req, err := http.NewRequest(http.MethodGet, remoteURL, nil) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| log.Debugf("upstream url %v", remoteURL) | ||
|
|
||
| if c.authorizer == nil { | ||
| log.Debug("registry client authorizer is nil") | ||
| } | ||
|
|
||
| resp, err := c.do(req) | ||
| if err != nil { |
There was a problem hiding this comment.
ListReferrers doesn’t set an Accept header. Other registry client calls (e.g. manifest fetch) explicitly set Accept for content negotiation; referrers should similarly request the OCI index media type so upstream registries that enforce Accept don’t return 406/unsupported responses.
| // HTTP Status check | ||
| if resp.StatusCode != http.StatusOK { | ||
| if resp.StatusCode == http.StatusNotFound { | ||
| return nil, nil, errors.New(nil).WithCode(errors.NotFoundCode). | ||
| WithMessagef("referrers for %s:%s not found", repository, ref) | ||
| } | ||
| body, _ := io.ReadAll(resp.Body) | ||
| return nil, nil, fmt.Errorf("upstream returned status %d: %s", resp.StatusCode, string(body)) | ||
| } | ||
|
|
There was a problem hiding this comment.
ListReferrers calls c.do(req), which already converts any non-2xx response into an error and returns resp=nil. The subsequent resp.StatusCode checks and error-body reads are therefore unreachable and can be removed/simplified (e.g., rely on errors.IsNotFoundErr(err) from c.do instead of a second status check).
| // HTTP Status check | |
| if resp.StatusCode != http.StatusOK { | |
| if resp.StatusCode == http.StatusNotFound { | |
| return nil, nil, errors.New(nil).WithCode(errors.NotFoundCode). | |
| WithMessagef("referrers for %s:%s not found", repository, ref) | |
| } | |
| body, _ := io.ReadAll(resp.Body) | |
| return nil, nil, fmt.Errorf("upstream returned status %d: %s", resp.StatusCode, string(body)) | |
| } |
This reverts commit 30ba1b3. Signed-off-by: stonezdj <stone.zhang@broadcom.com>
This reverts commit 30ba1b3. Signed-off-by: stonezdj <stone.zhang@broadcom.com>
This reverts commit 30ba1b3. Signed-off-by: stonezdj <stone.zhang@broadcom.com>
Signed-off-by: stonezdj <stonezdj@gmail.com> Signed-off-by: sias32 <sias.32@yandex.ru>
Revert "Proxy the referrer's API to upstream registry (goharbor#22746)" This reverts commit 30ba1b3. Signed-off-by: stonezdj <stone.zhang@broadcom.com> Signed-off-by: sias32 <sias.32@yandex.ru>
Signed-off-by: stonezdj <stonezdj@gmail.com> Signed-off-by: sias32 <sias.32@yandex.ru>
Revert "Proxy the referrer's API to upstream registry (goharbor#22746)" This reverts commit 30ba1b3. Signed-off-by: stonezdj <stone.zhang@broadcom.com> Signed-off-by: sias32 <sias.32@yandex.ru>
Signed-off-by: stonezdj <stonezdj@gmail.com> Signed-off-by: sias32 <sias320@gmail.com>
Revert "Proxy the referrer's API to upstream registry (goharbor#22746)" This reverts commit 30ba1b3. Signed-off-by: stonezdj <stone.zhang@broadcom.com> Signed-off-by: sias32 <sias320@gmail.com>
Thank you for contributing to Harbor!
Comprehensive Summary of your change
Issue being fixed
Fixes #20808
Please indicate you've done the following: