Skip to content

Commit 72a8115

Browse files
committed
Harden mirror-aware SOCI artifact fetch and fallback
Follow-up to c751c6d to fully address mirror behavior for SOCI artifact discovery/fetch (index + zTOCs). The original fix moved SOCI artifact lookups away from the image’s origin locator, but it still effectively depended on the first resolved host and didn’t fully preserve per-host details. In practice that meant we could still fail in mirror-heavy setups where the first endpoint is unavailable or where the mirror is configured with HTTP/path differences. This change finishes that work so SOCI index and zTOC fetches behave like the rest of our mirror-aware pull flow. We now carry the full RegistryHost context through the fetch path, rewrite locators with host path awareness, honor mirror scheme when constructing ORAS repositories, and retry across all configured hosts instead of stopping at the first one. I also tightened error handling around missing host clients and added tests that cover the new behavior directly: locator rewriting edge cases, host scheme handling, and fallback from one mirror endpoint to another (including the all-hosts-fail path). The goal is to make artifact discovery/fetch resilient and predictable in the same environments where layer pulls already are. Also adds a simple integration test. Signed-off-by: Milos Gajdos <milosthegajdos@gmail.com>
1 parent c751c6d commit 72a8115

4 files changed

Lines changed: 393 additions & 37 deletions

File tree

fs/artifact_fetcher.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ type orasBlobStore struct {
7777
*remote.Repository
7878
}
7979

80-
func newRemoteBlobStore(refspec reference.Spec, client *http.Client) (*orasBlobStore, error) {
81-
repo, err := newRemoteStore(refspec, client)
80+
func newRemoteBlobStoreFromHost(refspec reference.Spec, host docker.RegistryHost) (*orasBlobStore, error) {
81+
repo, err := newRemoteStoreFromHost(refspec, host)
8282
if err != nil {
8383
return nil, fmt.Errorf("cannot create remote store: %w", err)
8484
}
@@ -230,16 +230,38 @@ func (c *clientWrapper) RoundTrip(req *http.Request) (*http.Response, error) {
230230
return c.Client.Do(req)
231231
}
232232

233-
func withLocatorHost(refspec reference.Spec, host string) (reference.Spec, error) {
233+
func withLocatorHost(refspec reference.Spec, host docker.RegistryHost) (reference.Spec, error) {
234+
if host.Host == "" || strings.Contains(host.Host, "/") {
235+
return reference.Spec{}, fmt.Errorf("invalid registry host %q", host.Host)
236+
}
237+
234238
repoPath := strings.TrimPrefix(refspec.Locator, refspec.Hostname())
235239
repoPath = strings.TrimPrefix(repoPath, "/")
236240
if repoPath == "" {
237241
return reference.Spec{}, fmt.Errorf("invalid image locator %q", refspec.Locator)
238242
}
239-
refspec.Locator = path.Join(host, repoPath)
243+
244+
repoPrefix, err := repositoryPrefixFromHostPath(host.Path)
245+
if err != nil {
246+
return reference.Spec{}, err
247+
}
248+
249+
refspec.Locator = path.Join(host.Host, repoPrefix, repoPath)
240250
return refspec, nil
241251
}
242252

253+
func repositoryPrefixFromHostPath(hostPath string) (string, error) {
254+
clean := path.Clean(hostPath)
255+
switch {
256+
case clean == ".", clean == "/", clean == "/v2":
257+
return "", nil
258+
case strings.HasPrefix(clean, "/v2/"):
259+
return strings.TrimPrefix(clean, "/v2/"), nil
260+
default:
261+
return "", fmt.Errorf("unsupported registry host path %q; expected /v2 or /v2/<prefix>", hostPath)
262+
}
263+
}
264+
243265
func newRemoteStore(refspec reference.Spec, client *http.Client) (*remote.Repository, error) {
244266
repo, err := remote.NewRepository(refspec.Locator)
245267
if err != nil {
@@ -254,6 +276,26 @@ func newRemoteStore(refspec reference.Spec, client *http.Client) (*remote.Reposi
254276
return repo, nil
255277
}
256278

279+
func newRemoteStoreFromHost(refspec reference.Spec, host docker.RegistryHost) (*remote.Repository, error) {
280+
repo, err := newRemoteStore(refspec, host.Client)
281+
if err != nil {
282+
return nil, err
283+
}
284+
285+
switch strings.ToLower(host.Scheme) {
286+
case "http":
287+
repo.PlainHTTP = true
288+
case "", "https":
289+
// Keep the default behavior from newRemoteStore:
290+
// - localhost uses plain HTTP
291+
// - all other hosts use HTTPS
292+
default:
293+
return nil, fmt.Errorf("unsupported registry scheme %q for host %q", host.Scheme, host.Host)
294+
}
295+
296+
return repo, nil
297+
}
298+
257299
// Constructs a new artifact fetcher
258300
// Takes in the image reference, the local store and the resolver
259301
func newArtifactFetcher(refspec reference.Spec, localStore store.BasicStore, remoteStore resolverStorage) (*artifactFetcher, error) {

fs/artifact_fetcher_test.go

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import (
2323
"fmt"
2424
"io"
2525
"net/http"
26+
"strings"
2627
"testing"
2728

2829
"github.com/awslabs/soci-snapshotter/soci"
2930
"github.com/awslabs/soci-snapshotter/soci/store"
3031
"github.com/containerd/containerd/reference"
32+
"github.com/containerd/containerd/remotes/docker"
3133
"github.com/google/go-cmp/cmp"
3234
"github.com/opencontainers/go-digest"
3335
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -306,16 +308,129 @@ func TestNewRemoteStore(t *testing.T) {
306308
}
307309

308310
func TestWithLocatorHost(t *testing.T) {
311+
testCases := []struct {
312+
name string
313+
host docker.RegistryHost
314+
expectedLocator string
315+
expectedError string
316+
}{
317+
{
318+
name: "rewrites locator with mirror host",
319+
host: docker.RegistryHost{
320+
Host: "example-mirror.local",
321+
Path: "/v2",
322+
},
323+
expectedLocator: "example-mirror.local/library/nginx",
324+
},
325+
{
326+
name: "rewrites locator with mirror path prefix",
327+
host: docker.RegistryHost{
328+
Host: "example-mirror.local",
329+
Path: "/v2/team-a",
330+
},
331+
expectedLocator: "example-mirror.local/team-a/library/nginx",
332+
},
333+
{
334+
name: "invalid mirror path",
335+
host: docker.RegistryHost{
336+
Host: "example-mirror.local",
337+
Path: "/custom/path",
338+
},
339+
expectedError: "unsupported registry host path",
340+
},
341+
}
342+
343+
for _, tc := range testCases {
344+
t.Run(tc.name, func(t *testing.T) {
345+
refspec, err := reference.Parse("docker.io/library/nginx:latest")
346+
if err != nil {
347+
t.Fatalf("unexpected failure parsing reference: %v", err)
348+
}
349+
rewritten, err := withLocatorHost(refspec, tc.host)
350+
if tc.expectedError != "" {
351+
if err == nil {
352+
t.Fatalf("expected error containing %q, got nil", tc.expectedError)
353+
}
354+
if got := err.Error(); !strings.Contains(got, tc.expectedError) {
355+
t.Fatalf("unexpected error, expected substring %q, got %q", tc.expectedError, got)
356+
}
357+
return
358+
}
359+
if err != nil {
360+
t.Fatalf("unexpected failure rewriting reference: %v", err)
361+
}
362+
if rewritten.Locator != tc.expectedLocator {
363+
t.Fatalf("unexpected locator, expected %q, got %q", tc.expectedLocator, rewritten.Locator)
364+
}
365+
})
366+
}
367+
}
368+
369+
func TestNewRemoteStoreFromHost(t *testing.T) {
370+
client := &http.Client{}
309371
refspec, err := reference.Parse("docker.io/library/nginx:latest")
310372
if err != nil {
311373
t.Fatalf("unexpected failure parsing reference: %v", err)
312374
}
313-
rewritten, err := withLocatorHost(refspec, "example-mirror.local")
314-
if err != nil {
315-
t.Fatalf("unexpected failure rewriting reference: %v", err)
375+
376+
testCases := []struct {
377+
name string
378+
host docker.RegistryHost
379+
shouldBePlainHTTP bool
380+
expectedError string
381+
}{
382+
{
383+
name: "http mirror uses plain http",
384+
host: docker.RegistryHost{
385+
Host: "example-mirror.local",
386+
Scheme: "http",
387+
Client: client,
388+
},
389+
shouldBePlainHTTP: true,
390+
},
391+
{
392+
name: "https mirror does not use plain http",
393+
host: docker.RegistryHost{
394+
Host: "example-mirror.local",
395+
Scheme: "https",
396+
Client: client,
397+
},
398+
shouldBePlainHTTP: false,
399+
},
400+
{
401+
name: "invalid scheme fails",
402+
host: docker.RegistryHost{
403+
Host: "example-mirror.local",
404+
Scheme: "ftp",
405+
Client: client,
406+
},
407+
expectedError: "unsupported registry scheme",
408+
},
316409
}
317-
if rewritten.Locator != "example-mirror.local/library/nginx" {
318-
t.Fatalf("unexpected locator, expected %q, got %q", "example-mirror.local/library/nginx", rewritten.Locator)
410+
411+
for _, tc := range testCases {
412+
t.Run(tc.name, func(t *testing.T) {
413+
rewritten, err := withLocatorHost(refspec, docker.RegistryHost{Host: tc.host.Host, Path: "/v2"})
414+
if err != nil {
415+
t.Fatalf("unexpected failure rewriting reference: %v", err)
416+
}
417+
store, err := newRemoteStoreFromHost(rewritten, tc.host)
418+
if tc.expectedError != "" {
419+
if err == nil {
420+
t.Fatalf("expected error containing %q, got nil", tc.expectedError)
421+
}
422+
if got := err.Error(); !strings.Contains(got, tc.expectedError) {
423+
t.Fatalf("unexpected error, expected substring %q, got %q", tc.expectedError, got)
424+
}
425+
return
426+
}
427+
if err != nil {
428+
t.Fatalf("unexpected error, got %v", err)
429+
}
430+
if store.PlainHTTP != tc.shouldBePlainHTTP {
431+
t.Fatalf("unexpected plain http setting, expected %v, got %v", tc.shouldBePlainHTTP, store.PlainHTTP)
432+
}
433+
})
319434
}
320435
}
321436

fs/fetch_soci_index_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
Copyright The Soci Snapshotter Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package fs
18+
19+
import (
20+
"context"
21+
"errors"
22+
"net/http"
23+
"net/http/httptest"
24+
"net/url"
25+
"strconv"
26+
"sync/atomic"
27+
"testing"
28+
29+
"github.com/awslabs/soci-snapshotter/snapshot"
30+
"github.com/awslabs/soci-snapshotter/soci"
31+
"github.com/awslabs/soci-snapshotter/soci/store"
32+
"github.com/containerd/containerd/remotes/docker"
33+
"github.com/opencontainers/go-digest"
34+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
35+
"oras.land/oras-go/v2/content/memory"
36+
)
37+
38+
type inMemoryTestStore struct {
39+
*memory.Store
40+
}
41+
42+
func newInMemoryTestStore() *inMemoryTestStore {
43+
return &inMemoryTestStore{
44+
Store: memory.New(),
45+
}
46+
}
47+
48+
func (s *inMemoryTestStore) Label(context.Context, ocispec.Descriptor, string, string) error {
49+
return nil
50+
}
51+
52+
func (s *inMemoryTestStore) Delete(context.Context, digest.Digest) error {
53+
return nil
54+
}
55+
56+
func (s *inMemoryTestStore) BatchOpen(ctx context.Context) (context.Context, store.CleanupFunc, error) {
57+
return ctx, store.NopCleanup, nil
58+
}
59+
60+
func registryHostFromServer(ts *httptest.Server) docker.RegistryHost {
61+
u, _ := url.Parse(ts.URL)
62+
return docker.RegistryHost{
63+
Host: u.Host,
64+
Scheme: u.Scheme,
65+
Path: "/v2",
66+
Client: ts.Client(),
67+
}
68+
}
69+
70+
func TestFetchSociIndexFallsBackToNextHost(t *testing.T) {
71+
indexBytes, err := soci.MarshalIndex(soci.NewIndex(soci.V2, nil, nil, nil))
72+
if err != nil {
73+
t.Fatalf("failed to marshal index: %v", err)
74+
}
75+
indexDigest := digest.FromBytes(indexBytes)
76+
manifestDigest := digest.FromString("manifest")
77+
indexBlobPath := "/v2/library/nginx/blobs/" + indexDigest.String()
78+
indexManifestPath := "/v2/library/nginx/manifests/" + indexDigest.String()
79+
80+
var firstHostRequests int32
81+
firstHost := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+
atomic.AddInt32(&firstHostRequests, 1)
83+
http.Error(w, "temporary upstream failure", http.StatusBadGateway)
84+
}))
85+
defer firstHost.Close()
86+
87+
var secondHostRequests int32
88+
secondHost := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+
atomic.AddInt32(&secondHostRequests, 1)
90+
if r.URL.Path != indexBlobPath && r.URL.Path != indexManifestPath {
91+
http.NotFound(w, r)
92+
return
93+
}
94+
w.Header().Set("Content-Type", "application/octet-stream")
95+
w.Header().Set("Content-Length", strconv.Itoa(len(indexBytes)))
96+
switch r.Method {
97+
case http.MethodHead:
98+
w.WriteHeader(http.StatusOK)
99+
case http.MethodGet:
100+
w.WriteHeader(http.StatusOK)
101+
_, _ = w.Write(indexBytes)
102+
default:
103+
w.WriteHeader(http.StatusMethodNotAllowed)
104+
}
105+
}))
106+
defer secondHost.Close()
107+
108+
fs := &filesystem{
109+
contentStore: newInMemoryTestStore(),
110+
}
111+
index, err := fs.fetchSociIndex(
112+
context.Background(),
113+
"docker.io/library/nginx:latest",
114+
indexDigest.String(),
115+
manifestDigest.String(),
116+
[]docker.RegistryHost{
117+
registryHostFromServer(firstHost),
118+
registryHostFromServer(secondHost),
119+
},
120+
)
121+
if err != nil {
122+
t.Fatalf("unexpected error: %v", err)
123+
}
124+
if index == nil {
125+
t.Fatal("expected non-nil index")
126+
}
127+
if got := atomic.LoadInt32(&firstHostRequests); got == 0 {
128+
t.Fatal("expected first registry host to be attempted")
129+
}
130+
if got := atomic.LoadInt32(&secondHostRequests); got == 0 {
131+
t.Fatal("expected second registry host to be attempted")
132+
}
133+
}
134+
135+
func TestFetchSociIndexReturnsNoIndexWhenAllHostsFail(t *testing.T) {
136+
indexBytes, err := soci.MarshalIndex(soci.NewIndex(soci.V2, nil, nil, nil))
137+
if err != nil {
138+
t.Fatalf("failed to marshal index: %v", err)
139+
}
140+
indexDigest := digest.FromBytes(indexBytes)
141+
manifestDigest := digest.FromString("manifest")
142+
143+
failingHost := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144+
http.Error(w, "temporary upstream failure", http.StatusBadGateway)
145+
}))
146+
defer failingHost.Close()
147+
148+
fs := &filesystem{
149+
contentStore: newInMemoryTestStore(),
150+
}
151+
_, err = fs.fetchSociIndex(
152+
context.Background(),
153+
"docker.io/library/nginx:latest",
154+
indexDigest.String(),
155+
manifestDigest.String(),
156+
[]docker.RegistryHost{
157+
registryHostFromServer(failingHost),
158+
},
159+
)
160+
if err == nil {
161+
t.Fatal("expected error but got nil")
162+
}
163+
if !errors.Is(err, snapshot.ErrNoIndex) {
164+
t.Fatalf("expected error to match snapshot.ErrNoIndex, got: %v", err)
165+
}
166+
}

0 commit comments

Comments
 (0)