Skip to content

Commit e1d1873

Browse files
authored
Merge pull request #2204 from dgageot/better-oci-refs
Better oci refs
2 parents 12ade90 + 71eaa8f commit e1d1873

6 files changed

Lines changed: 239 additions & 45 deletions

File tree

pkg/config/sources.go

Lines changed: 51 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -110,70 +110,82 @@ func (a ociSource) ParentDir() string {
110110
return ""
111111
}
112112

113-
// Read loads an agent configuration from an OCI artifact
113+
// Read loads an agent configuration from an OCI artifact.
114114
//
115-
// The OCI registry remains the source of truth
116-
// The local content store is used as a cache and fallback only
117-
// A forced re-pull is triggered exclusively when store corruption is detected
115+
// The OCI registry remains the source of truth.
116+
// The local content store is used as a cache and fallback only.
117+
// A forced re-pull is triggered exclusively when store corruption is detected.
118118
func (a ociSource) Read(ctx context.Context) ([]byte, error) {
119119
store, err := content.NewStore()
120120
if err != nil {
121121
return nil, fmt.Errorf("failed to create content store: %w", err)
122122
}
123123

124-
tryLoad := func() ([]byte, error) {
125-
af, err := store.GetArtifact(a.reference)
126-
if err != nil {
127-
return nil, err
124+
// Normalize the reference so that equivalent forms (e.g.
125+
// "agentcatalog/review-pr" and "index.docker.io/agentcatalog/review-pr:latest")
126+
// resolve to the same store key that remote.Pull uses.
127+
storeKey, err := remote.NormalizeReference(a.reference)
128+
if err != nil {
129+
return nil, fmt.Errorf("normalizing OCI reference %s: %w", a.reference, err)
130+
}
131+
132+
// For digest references, the content is immutable. If we already have
133+
// the artifact locally, serve it directly without any network call.
134+
if remote.IsDigestReference(a.reference) {
135+
if data, loadErr := loadArtifact(store, storeKey); loadErr == nil {
136+
slog.Debug("Serving digest-pinned OCI artifact from cache", "ref", a.reference)
137+
return data, nil
128138
}
129-
return []byte(af), nil
130139
}
131140

132-
// Check if we have any local metadata (same as before)
133-
_, metaErr := store.GetArtifactMetadata(a.reference)
134-
hasLocal := metaErr == nil
141+
// Check whether we have a local copy to fall back on.
142+
hasLocal := hasLocalArtifact(store, storeKey)
135143

136-
// Always try normal pull first (preserves pull-interval behavior)
144+
// Pull from registry (checks remote digest, skips download if unchanged).
137145
if _, pullErr := remote.Pull(ctx, a.reference, false); pullErr != nil {
138146
if !hasLocal {
139147
return nil, fmt.Errorf("failed to pull OCI image %s: %w", a.reference, pullErr)
140148
}
141-
142-
slog.Debug(
143-
"Failed to check for OCI reference updates, using cached version",
144-
"ref", a.reference,
145-
"error", pullErr,
146-
)
149+
slog.Debug("Failed to check for OCI reference updates, using cached version",
150+
"ref", a.reference, "error", pullErr)
147151
}
148152

149-
// Try loading from store
150-
data, err := tryLoad()
153+
// Try loading from store.
154+
data, err := loadArtifact(store, storeKey)
151155
if err == nil {
152156
return data, nil
153157
}
154158

155-
// If loading failed due to corruption, force re-pull
156-
if errors.Is(err, content.ErrStoreCorrupted) {
157-
slog.Warn(
158-
"Local OCI store corrupted, forcing re-pull",
159-
"ref", a.reference,
160-
)
159+
// If corrupted, force re-pull and try once more.
160+
if !errors.Is(err, content.ErrStoreCorrupted) {
161+
return nil, fmt.Errorf("failed to load agent from OCI source %s: %w", a.reference, err)
162+
}
161163

162-
if _, pullErr := remote.Pull(ctx, a.reference, true); pullErr != nil {
163-
return nil, fmt.Errorf("failed to force re-pull OCI image %s: %w", a.reference, pullErr)
164-
}
164+
slog.Warn("Local OCI store corrupted, forcing re-pull", "ref", a.reference)
165+
if _, pullErr := remote.Pull(ctx, a.reference, true); pullErr != nil {
166+
return nil, fmt.Errorf("failed to force re-pull OCI image %s: %w", a.reference, pullErr)
167+
}
165168

166-
data, err = tryLoad()
167-
if err == nil {
168-
return data, nil
169-
}
169+
data, err = loadArtifact(store, storeKey)
170+
if err != nil {
171+
return nil, fmt.Errorf("failed to load agent from OCI source %s: %w", a.reference, err)
172+
}
173+
return data, nil
174+
}
175+
176+
// loadArtifact reads the agent YAML from the content store.
177+
func loadArtifact(store *content.Store, storeKey string) ([]byte, error) {
178+
af, err := store.GetArtifact(storeKey)
179+
if err != nil {
180+
return nil, err
170181
}
182+
return []byte(af), nil
183+
}
171184

172-
return nil, fmt.Errorf(
173-
"failed to load agent from OCI source %s: %w",
174-
a.reference,
175-
err,
176-
)
185+
// hasLocalArtifact reports whether the content store has metadata for the given key.
186+
func hasLocalArtifact(store *content.Store, storeKey string) bool {
187+
_, err := store.GetArtifactMetadata(storeKey)
188+
return err == nil
177189
}
178190

179191
// urlSource is used to load an agent configuration from an HTTP/HTTPS URL.

pkg/config/sources_test.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,59 @@ import (
88
"sync/atomic"
99
"testing"
1010

11+
v1 "github.com/google/go-containerregistry/pkg/v1"
12+
"github.com/google/go-containerregistry/pkg/v1/empty"
13+
"github.com/google/go-containerregistry/pkg/v1/mutate"
14+
"github.com/google/go-containerregistry/pkg/v1/static"
1115
"github.com/stretchr/testify/assert"
1216
"github.com/stretchr/testify/require"
1317

18+
"github.com/docker/docker-agent/pkg/content"
1419
"github.com/docker/docker-agent/pkg/environment"
20+
"github.com/docker/docker-agent/pkg/remote"
1521
)
1622

23+
func TestOCISource_DigestReference_ServesFromCache(t *testing.T) {
24+
t.Parallel()
25+
26+
// Create a temporary content store and store a test artifact.
27+
storeDir := t.TempDir()
28+
store, err := content.NewStore(content.WithBaseDir(storeDir))
29+
require.NoError(t, err)
30+
31+
testData := []byte("version: v1\nname: test-agent")
32+
layer := static.NewLayer(testData, "application/yaml")
33+
img, err := mutate.AppendLayers(empty.Image, layer)
34+
require.NoError(t, err)
35+
img = mutate.Annotations(img, map[string]string{
36+
"io.docker.agent.version": "test",
37+
}).(v1.Image)
38+
39+
ref := "test-digest-cache/agent:latest"
40+
digest, err := store.StoreArtifact(img, ref)
41+
require.NoError(t, err)
42+
43+
// Build a digest reference using the stored digest.
44+
digestRef := "test-digest-cache/agent@" + digest
45+
46+
// Read via ociSource. Since the reference is pinned by digest and is
47+
// present in the local store, this must succeed without any network call.
48+
// We override the default store directory via an env-based approach;
49+
// instead, we directly exercise the cache-hit logic by verifying the
50+
// store lookup works with the normalized key.
51+
storeKey, err := remote.NormalizeReference(digestRef)
52+
require.NoError(t, err)
53+
54+
// Verify the store can resolve the digest key directly.
55+
data, err := store.GetArtifact(storeKey)
56+
require.NoError(t, err)
57+
assert.Equal(t, string(testData), data)
58+
59+
// Also verify that IsDigestReference correctly identifies this.
60+
assert.True(t, remote.IsDigestReference(digestRef))
61+
assert.False(t, remote.IsDigestReference(ref))
62+
}
63+
1764
func TestURLSource_Read(t *testing.T) {
1865
t.Parallel()
1966

@@ -204,11 +251,11 @@ func TestURLSource_Read_FallsBackToCacheOnHTTPError(t *testing.T) {
204251
func TestURLSource_Read_UpdatesCacheWhenContentChanges(t *testing.T) {
205252
// Not parallel - uses shared cache directory
206253

207-
var content atomic.Value
208-
content.Store("initial content update")
254+
var serverContent atomic.Value
255+
serverContent.Store("initial content update")
209256

210257
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211-
currentContent := content.Load().(string)
258+
currentContent := serverContent.Load().(string)
212259
etag := `"etag-` + currentContent + `"`
213260

214261
if r.Header.Get("If-None-Match") == etag {
@@ -239,7 +286,7 @@ func TestURLSource_Read_UpdatesCacheWhenContentChanges(t *testing.T) {
239286
assert.Equal(t, "initial content update", string(data))
240287

241288
// Change content
242-
content.Store("updated content update")
289+
serverContent.Store("updated content update")
243290

244291
// Second read should get new content
245292
data, err = source.Read(t.Context())

pkg/content/store.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,18 @@ func (s *Store) DeleteArtifact(identifier string) error {
267267
// resolveIdentifier resolves a user-provided identifier (digest or reference)
268268
// into a concrete content digest stored in the local artifact store.
269269
func (s *Store) resolveIdentifier(identifier string) (string, error) {
270-
// If the identifier is already a digest, we can return it directly.
271-
// This bypasses the refs lookup entirely.
270+
// If the identifier is already a bare digest, return it directly.
272271
if strings.HasPrefix(identifier, "sha256:") {
273272
return identifier, nil
274273
}
275274

275+
// If the identifier is a digest reference (e.g. "repo@sha256:abc..."),
276+
// extract and return the digest portion directly. Digest references
277+
// are content-addressable, so the digest alone identifies the artifact.
278+
if i := strings.LastIndex(identifier, "@sha256:"); i >= 0 {
279+
return identifier[i+1:], nil
280+
}
281+
276282
// If no tag is provided, default to ":latest".
277283
// This mirrors standard OCI reference semantics.
278284
if !strings.Contains(identifier, ":") {

pkg/content/store_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,34 @@ func TestStoreResolution(t *testing.T) {
109109
assert.NotNil(t, img)
110110
}
111111
}
112+
113+
func TestStoreResolution_DigestReference(t *testing.T) {
114+
store, err := NewStore(WithBaseDir(t.TempDir()))
115+
require.NoError(t, err)
116+
117+
testData := []byte("Digest resolution test")
118+
layer := static.NewLayer(testData, types.OCIUncompressedLayer)
119+
img := empty.Image
120+
img, err = mutate.AppendLayers(img, layer)
121+
require.NoError(t, err)
122+
123+
tagRef := "myrepo/agent:v1"
124+
digest, err := store.StoreArtifact(img, tagRef)
125+
require.NoError(t, err)
126+
127+
// Bare digest should resolve.
128+
retrievedImg, err := store.GetArtifactImage(digest)
129+
require.NoError(t, err)
130+
assert.NotNil(t, retrievedImg)
131+
132+
// Digest reference (repo@sha256:...) should also resolve.
133+
digestRef := "myrepo/agent@" + digest
134+
retrievedImg, err = store.GetArtifactImage(digestRef)
135+
require.NoError(t, err)
136+
assert.NotNil(t, retrievedImg)
137+
138+
// Metadata lookup via digest reference should work too.
139+
meta, err := store.GetArtifactMetadata(digestRef)
140+
require.NoError(t, err)
141+
assert.Equal(t, digest, meta.Digest)
142+
}

pkg/remote/pull.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,29 @@ import (
1010
"github.com/docker/docker-agent/pkg/content"
1111
)
1212

13+
// NormalizeReference parses an OCI reference and returns the normalized
14+
// store key that Pull uses to store artifacts. This ensures that equivalent
15+
// references (e.g. "agentcatalog/review-pr" and
16+
// "index.docker.io/agentcatalog/review-pr:latest") map to the same key.
17+
func NormalizeReference(registryRef string) (string, error) {
18+
ref, err := name.ParseReference(registryRef)
19+
if err != nil {
20+
return "", fmt.Errorf("parsing registry reference %s: %w", registryRef, err)
21+
}
22+
return ref.Context().RepositoryStr() + separator(ref) + ref.Identifier(), nil
23+
}
24+
25+
// IsDigestReference reports whether the given reference pins a specific
26+
// image digest (e.g. "repo@sha256:abc...").
27+
func IsDigestReference(registryRef string) bool {
28+
ref, err := name.ParseReference(registryRef)
29+
if err != nil {
30+
return false
31+
}
32+
_, ok := ref.(name.Digest)
33+
return ok
34+
}
35+
1336
// Pull pulls an artifact from a registry and stores it in the content store
1437
func Pull(ctx context.Context, registryRef string, force bool, opts ...crane.Option) (string, error) {
1538
opts = append(opts, crane.WithContext(ctx))

pkg/remote/pull_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,81 @@ func TestPullIntegration(t *testing.T) {
7272
require.Error(t, err)
7373
}
7474

75+
func TestNormalizeReference(t *testing.T) {
76+
t.Parallel()
77+
78+
tests := []struct {
79+
name string
80+
ref string
81+
expected string
82+
}{
83+
{
84+
name: "short reference gets normalized",
85+
ref: "agentcatalog/review-pr",
86+
expected: "agentcatalog/review-pr:latest",
87+
},
88+
{
89+
name: "fully qualified reference gets normalized to same key",
90+
ref: "index.docker.io/agentcatalog/review-pr:latest",
91+
expected: "agentcatalog/review-pr:latest",
92+
},
93+
{
94+
name: "tagged reference preserves tag",
95+
ref: "agentcatalog/review-pr:v1",
96+
expected: "agentcatalog/review-pr:v1",
97+
},
98+
{
99+
name: "digest reference preserves digest",
100+
ref: "agentcatalog/review-pr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
101+
expected: "agentcatalog/review-pr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
102+
},
103+
{
104+
name: "non-docker-hub registry",
105+
ref: "ghcr.io/myorg/agent:v2",
106+
expected: "myorg/agent:v2",
107+
},
108+
}
109+
110+
for _, tt := range tests {
111+
t.Run(tt.name, func(t *testing.T) {
112+
t.Parallel()
113+
result, err := NormalizeReference(tt.ref)
114+
require.NoError(t, err)
115+
assert.Equal(t, tt.expected, result)
116+
})
117+
}
118+
}
119+
120+
func TestNormalizeReference_InvalidReference(t *testing.T) {
121+
t.Parallel()
122+
123+
_, err := NormalizeReference(":::invalid")
124+
require.Error(t, err)
125+
}
126+
127+
func TestIsDigestReference(t *testing.T) {
128+
t.Parallel()
129+
130+
tests := []struct {
131+
name string
132+
ref string
133+
expected bool
134+
}{
135+
{"tag reference", "agentcatalog/review-pr:latest", false},
136+
{"implicit tag", "agentcatalog/review-pr", false},
137+
{"digest reference", "agentcatalog/review-pr@sha256:0000000000000000000000000000000000000000000000000000000000000000", true},
138+
{"fully qualified digest", "index.docker.io/agentcatalog/review-pr@sha256:0000000000000000000000000000000000000000000000000000000000000000", true},
139+
{"invalid reference", ":::invalid", false},
140+
}
141+
142+
for _, tt := range tests {
143+
t.Run(tt.name, func(t *testing.T) {
144+
t.Parallel()
145+
assert.Equal(t, tt.expected, IsDigestReference(tt.ref))
146+
})
147+
}
148+
}
149+
75150
func TestSeparator(t *testing.T) {
76151
t.Parallel()
77152

0 commit comments

Comments
 (0)