Skip to content

Commit 1860b52

Browse files
committed
httptransport: accept OCI manifests for indexing
Signed-off-by: Hank Donnay <[email protected]>
1 parent 37f7791 commit 1860b52

File tree

4 files changed

+331
-3
lines changed

4 files changed

+331
-3
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ require (
1515
github.com/jmoiron/sqlx v1.2.0
1616
github.com/klauspost/compress v1.10.11
1717
github.com/mattn/go-sqlite3 v1.11.0 // indirect
18+
github.com/opencontainers/go-digest v1.0.0
19+
github.com/opencontainers/image-spec v1.0.1
1820
github.com/prometheus/client_golang v0.9.4 // indirect
1921
github.com/prometheus/procfs v0.0.8 // indirect
2022
github.com/quay/claircore v0.1.13

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,9 @@ github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
471471
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
472472
github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
473473
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
474+
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
474475
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
476+
github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
475477
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
476478
github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
477479
github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y=

httptransport/indexhandler.go

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package httptransport
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
78
"path"
9+
"strings"
810

11+
oci "github.com/opencontainers/image-spec/specs-go/v1"
912
"github.com/quay/claircore"
1013
je "github.com/quay/claircore/pkg/jsonerr"
1114

@@ -41,8 +44,8 @@ func IndexHandler(serv indexer.StateIndexer) http.HandlerFunc {
4144
return
4245
}
4346

44-
var m claircore.Manifest
45-
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
47+
m, err := decodeManifest(ctx, r)
48+
if err != nil {
4649
resp := &je.Response{
4750
Code: "bad-request",
4851
Message: fmt.Sprintf("failed to deserialize manifest: %v", err),
@@ -70,7 +73,7 @@ func IndexHandler(serv indexer.StateIndexer) http.HandlerFunc {
7073

7174
// TODO Do we need some sort of background context embedded in the HTTP
7275
// struct?
73-
report, err := serv.Index(ctx, &m)
76+
report, err := serv.Index(ctx, m)
7477
if err != nil {
7578
resp := &je.Response{
7679
Code: "index-error",
@@ -88,3 +91,85 @@ func IndexHandler(serv indexer.StateIndexer) http.HandlerFunc {
8891
err = json.NewEncoder(w).Encode(report)
8992
}
9093
}
94+
95+
const (
96+
// Known manifest types we ingest.
97+
typeOCIManifest = oci.MediaTypeImageManifest
98+
typeNativeManifest = `application/vnd.projectquay.clair.mainfest.v1+json`
99+
)
100+
101+
// DecodeManifest switches on the Request's Content-Type to consume the body.
102+
//
103+
// Defaults to expecting a native ClairCore Manifest.
104+
func decodeManifest(ctx context.Context, r *http.Request) (*claircore.Manifest, error) {
105+
defer r.Body.Close()
106+
var m claircore.Manifest
107+
108+
t := r.Header.Get("content-type")
109+
if i := strings.IndexByte(t, ';'); i != -1 {
110+
t = strings.TrimSpace(t[:i])
111+
}
112+
switch t {
113+
case typeOCIManifest:
114+
var om oci.Manifest
115+
if err := json.NewDecoder(r.Body).Decode(&om); err != nil {
116+
return nil, err
117+
}
118+
if err := nativeFromOCI(&m, &om); err != nil {
119+
return nil, err
120+
}
121+
case typeNativeManifest, "application/json", "":
122+
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
123+
return nil, err
124+
}
125+
default:
126+
return nil, fmt.Errorf("unknown content-type %q", t)
127+
}
128+
return &m, nil
129+
}
130+
131+
// These are the layer types we accept inside an OCI Manifest.
132+
var ociLayerTypes = map[string]struct{}{
133+
oci.MediaTypeImageLayer: {},
134+
oci.MediaTypeImageLayerGzip: {},
135+
oci.MediaTypeImageLayer + "+zstd": {}, // The specs package doesn't have zstd, oddly.
136+
}
137+
138+
// NativeFromOCI populates the Manifest from the OCI Manifest, reporting an
139+
// error if something is invalid.
140+
func nativeFromOCI(m *claircore.Manifest, o *oci.Manifest) error {
141+
const header = `header:`
142+
var err error
143+
144+
m.Hash, err = claircore.ParseDigest(o.Config.Digest.String())
145+
if err != nil {
146+
return fmt.Errorf("unable to parse manifest digest %q: %w", o.Config.Digest, err)
147+
}
148+
149+
for _, u := range o.Layers {
150+
if len(u.URLs) == 0 {
151+
// Manifest is missing URLs.
152+
// They're optional in the spec, but we need them for obvious reasons.
153+
return fmt.Errorf("missing URLs for layer %q", u.Digest)
154+
}
155+
if _, ok := ociLayerTypes[u.MediaType]; !ok {
156+
return fmt.Errorf("invalid media type for layer %q", u.Digest)
157+
}
158+
l := claircore.Layer{
159+
URI: u.URLs[0],
160+
}
161+
l.Hash, err = claircore.ParseDigest(u.Digest.String())
162+
if err != nil {
163+
return fmt.Errorf("unable to parse layer digest %q: %w", u.Digest, err)
164+
}
165+
for k, v := range u.Annotations {
166+
if !strings.HasPrefix(k, header) {
167+
continue
168+
}
169+
l.Headers[strings.TrimPrefix(k, header)] = []string{v}
170+
}
171+
m.Layers = append(m.Layers, &l)
172+
}
173+
174+
return nil
175+
}

httptransport/indexhandler_test.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package httptransport
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
"github.com/google/go-cmp/cmp"
11+
"github.com/google/go-cmp/cmp/cmpopts"
12+
"github.com/opencontainers/go-digest"
13+
oci "github.com/opencontainers/image-spec/specs-go/v1"
14+
"github.com/quay/claircore"
15+
)
16+
17+
func TestNativeFromOCI(t *testing.T) {
18+
t.Parallel()
19+
20+
var cmpOpts = cmp.Options{
21+
cmp.Comparer(func(a, b claircore.Digest) bool { return a.String() == b.String() }),
22+
cmpopts.IgnoreUnexported(claircore.Layer{}),
23+
}
24+
type testcase struct {
25+
Name string
26+
Want claircore.Manifest
27+
In oci.Manifest
28+
Err bool
29+
}
30+
Run := func(tc *testcase) func(*testing.T) {
31+
return func(t *testing.T) {
32+
var got claircore.Manifest
33+
34+
err := nativeFromOCI(&got, &tc.In)
35+
if (err != nil) != tc.Err {
36+
t.Errorf("unexpected error: %v", err)
37+
}
38+
39+
if got, want := &got, &tc.Want; !cmp.Equal(got, want, cmpOpts) {
40+
t.Error(cmp.Diff(got, want, cmpOpts))
41+
}
42+
}
43+
}
44+
45+
tt := []testcase{
46+
{
47+
Name: "EmptyDigest",
48+
In: oci.Manifest{},
49+
Err: true,
50+
},
51+
{
52+
Name: "BadDigest",
53+
In: oci.Manifest{
54+
Config: oci.Descriptor{
55+
Digest: digest.Digest("xxx:yyy"),
56+
},
57+
},
58+
Err: true,
59+
},
60+
{
61+
Name: "BadURLs",
62+
In: oci.Manifest{
63+
Config: oci.Descriptor{
64+
Digest: digest.FromString("good manifest"),
65+
},
66+
Layers: []oci.Descriptor{
67+
{URLs: nil},
68+
},
69+
},
70+
Want: claircore.Manifest{
71+
Hash: claircore.MustParseDigest("sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"),
72+
},
73+
Err: true,
74+
},
75+
{
76+
Name: "BadMediaType",
77+
In: oci.Manifest{
78+
Config: oci.Descriptor{
79+
Digest: digest.FromString("good manifest"),
80+
},
81+
Layers: []oci.Descriptor{
82+
{
83+
MediaType: `fake/media-type`,
84+
URLs: []string{"http://localhost/real/layer"},
85+
},
86+
},
87+
},
88+
Want: claircore.Manifest{
89+
Hash: claircore.MustParseDigest("sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"),
90+
},
91+
Err: true,
92+
},
93+
{
94+
Name: "BadLayerDigest",
95+
In: oci.Manifest{
96+
Config: oci.Descriptor{
97+
Digest: digest.FromString("good manifest"),
98+
},
99+
Layers: []oci.Descriptor{
100+
{
101+
Digest: digest.Digest("xxx:yyy"),
102+
MediaType: oci.MediaTypeImageLayer,
103+
URLs: []string{"http://localhost/real/layer"},
104+
},
105+
},
106+
},
107+
Want: claircore.Manifest{
108+
Hash: claircore.MustParseDigest("sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"),
109+
},
110+
Err: true,
111+
},
112+
{
113+
Name: "OK",
114+
Want: claircore.Manifest{
115+
Hash: claircore.MustParseDigest("sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"),
116+
Layers: []*claircore.Layer{
117+
{
118+
Hash: claircore.MustParseDigest("sha256:ba54d2c66022c637137ad0896ba5fb790847755be51b08bc472ffab5fdd76b1b"),
119+
URI: "http://localhost/real/layer",
120+
},
121+
},
122+
},
123+
In: oci.Manifest{
124+
Config: oci.Descriptor{
125+
Digest: digest.FromString("good manifest"),
126+
},
127+
Layers: []oci.Descriptor{
128+
{
129+
Digest: digest.FromString("cool layer"),
130+
MediaType: oci.MediaTypeImageLayer,
131+
URLs: []string{"http://localhost/real/layer"},
132+
},
133+
},
134+
},
135+
},
136+
}
137+
138+
for _, tc := range tt {
139+
t.Run(tc.Name, Run(&tc))
140+
}
141+
}
142+
143+
func TestDecodeManifest(t *testing.T) {
144+
t.Parallel()
145+
ctx := context.Background()
146+
147+
type testcase struct {
148+
Name string
149+
Want claircore.Manifest
150+
In *http.Request
151+
Err bool
152+
}
153+
Run := func(tc *testcase) func(*testing.T) {
154+
return func(t *testing.T) {
155+
got, err := decodeManifest(ctx, tc.In)
156+
if err != nil {
157+
t.Log(err)
158+
}
159+
if (err != nil) != tc.Err {
160+
t.Errorf("unexpected error: %v", err)
161+
}
162+
_ = got
163+
}
164+
}
165+
166+
const (
167+
goodOCI = `{
168+
"mediaType":"` + oci.MediaTypeImageManifest + `",
169+
"config":{"digest":"sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"},
170+
"layers":[{
171+
"mediaType":"` + oci.MediaTypeImageLayer + `",
172+
"digest":"sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19",
173+
"urls":["http://example.com/layer"]
174+
}]}`
175+
errorOCI = `{
176+
"mediaType":"` + oci.MediaTypeImageManifest + `",
177+
"config":{"digest":"sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"},
178+
"layers":[{
179+
"mediaType":"` + oci.MediaTypeImageLayer + `",
180+
"digest":"sha256:a3114909b7f9d6c4a680e04c3f6eacaeb80c4d9d8d802f81e9b9cf0a29d26e19"
181+
}]}`
182+
)
183+
tt := []testcase{
184+
{
185+
Name: "NoHeaders",
186+
In: httptest.NewRequest("", "/", strings.NewReader(`{}`)),
187+
},
188+
{
189+
Name: "BadContentType",
190+
In: httptest.NewRequest("", "/", strings.NewReader(`{}`)),
191+
Err: true,
192+
},
193+
{
194+
Name: "Default",
195+
In: httptest.NewRequest("", "/", strings.NewReader(`{}`)),
196+
},
197+
{
198+
Name: "Default+Error",
199+
In: httptest.NewRequest("", "/", strings.NewReader(`""`)),
200+
Err: true,
201+
},
202+
{
203+
Name: "Claircore",
204+
In: httptest.NewRequest("", "/", strings.NewReader(`{}`)),
205+
},
206+
{
207+
Name: "OCIManifest",
208+
In: httptest.NewRequest("", "/", strings.NewReader(goodOCI)),
209+
},
210+
{
211+
Name: "OCIManifest+DecodeError",
212+
In: httptest.NewRequest("", "/", strings.NewReader(`""`)),
213+
Err: true,
214+
},
215+
{
216+
Name: "OCIManifest+TranslateError",
217+
In: httptest.NewRequest("", "/", strings.NewReader(errorOCI)),
218+
Err: true,
219+
},
220+
}
221+
// Adjust headers
222+
for _, tc := range tt {
223+
switch tc.Name {
224+
case "NoHeaders":
225+
case "BadContentType":
226+
tc.In.Header.Set(`content-type`, `text/plain; charset=UTF-8`)
227+
case "OCIManifest", "OCIManifest+DecodeError", "OCIManifest+TranslateError":
228+
tc.In.Header.Set(`content-type`, oci.MediaTypeImageManifest)
229+
case "Claircore":
230+
tc.In.Header.Set(`content-type`, `application/json; charset=UTF-8`)
231+
default:
232+
tc.In.Header.Set(`content-type`, `application/json; charset=UTF-8`)
233+
}
234+
}
235+
236+
for _, tc := range tt {
237+
t.Run(tc.Name, Run(&tc))
238+
}
239+
}

0 commit comments

Comments
 (0)