Skip to content

Commit 8223378

Browse files
authored
Merge pull request #3341 from robnester-rh/EC-1836
feat(EC-1836): add ec.oci.parsed_blob builtin for cached JSON parsing
2 parents 8e0e5b5 + 48aac2b commit 8223378

5 files changed

Lines changed: 204 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
= ec.oci.parsed_blob
2+
3+
Fetch a blob from an OCI registry and return the parsed JSON value. Results are cached per component.
4+
5+
== Usage
6+
7+
value = ec.oci.parsed_blob(ref: string)
8+
9+
== Parameters
10+
11+
* `ref` (`string`): OCI blob reference
12+
13+
== Return
14+
15+
`value` (`any`): the parsed JSON value from the OCI blob

docs/modules/ROOT/pages/rego_builtins.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ information.
2626
|Discover artifacts attached to an image via OCI Referrers API.
2727
|xref:ec_oci_image_tag_refs.adoc[ec.oci.image_tag_refs]
2828
|Discover artifacts attached to an image via legacy tag-based discovery (cosign .sig, .att, .sbom suffixes).
29+
|xref:ec_oci_parsed_blob.adoc[ec.oci.parsed_blob]
30+
|Fetch a blob from an OCI registry and return the parsed JSON value. Results are cached per component.
2931
|xref:ec_purl_is_valid.adoc[ec.purl.is_valid]
3032
|Determine whether or not a given PURL is valid.
3133
|xref:ec_purl_parse.adoc[ec.purl.parse]

docs/modules/ROOT/partials/rego_nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
** xref:ec_oci_image_manifests.adoc[ec.oci.image_manifests]
99
** xref:ec_oci_image_referrers.adoc[ec.oci.image_referrers]
1010
** xref:ec_oci_image_tag_refs.adoc[ec.oci.image_tag_refs]
11+
** xref:ec_oci_parsed_blob.adoc[ec.oci.parsed_blob]
1112
** xref:ec_purl_is_valid.adoc[ec.purl.is_valid]
1213
** xref:ec_purl_parse.adoc[ec.purl.parse]
1314
** xref:ec_sigstore_verify_attestation.adoc[ec.sigstore.verify_attestation]

internal/rego/oci/oci.go

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const (
6363
ociImageIndexName = "ec.oci.image_index"
6464
ociImageTagRefsName = "ec.oci.image_tag_refs"
6565
ociImageReferrersName = "ec.oci.image_referrers"
66+
ociParsedBlobName = "ec.oci.parsed_blob"
6667
maxTarEntrySizeConst = 500 * 1024 * 1024 // 500MB
6768
)
6869

@@ -96,6 +97,28 @@ func registerOCIBlob() {
9697
})
9798
}
9899

100+
func registerOCIParsedBlob() {
101+
decl := rego.Function{
102+
Name: ociParsedBlobName,
103+
Decl: types.NewFunction(
104+
types.Args(
105+
types.Named("ref", types.S).Description("OCI blob reference"),
106+
),
107+
types.Named("value", types.A).Description("the parsed JSON value from the OCI blob"),
108+
),
109+
Memoize: true,
110+
Nondeterministic: true,
111+
}
112+
113+
rego.RegisterBuiltin1(&decl, ociParsedBlob)
114+
ast.RegisterBuiltin(&ast.Builtin{
115+
Name: decl.Name,
116+
Description: "Fetch a blob from an OCI registry and return the parsed JSON value. Results are cached per component.",
117+
Decl: decl.Decl,
118+
Nondeterministic: decl.Nondeterministic,
119+
})
120+
}
121+
99122
func registerOCIDescriptor() {
100123
platform := types.NewObject(
101124
[]*types.StaticProperty{
@@ -603,6 +626,71 @@ func ociBlobInternal(bctx rego.BuiltinContext, a *ast.Term, verifyDigest bool) (
603626
return result.(*ast.Term), nil
604627
}
605628

629+
func ociParsedBlob(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) {
630+
logger := log.WithField("function", ociParsedBlobName)
631+
632+
uri, ok := a.Value.(ast.String)
633+
if !ok {
634+
logger.Error("input is not a string")
635+
return nil, nil
636+
}
637+
refStr := string(uri)
638+
logger = logger.WithField("ref", refStr)
639+
640+
cc := componentCacheFromContext(bctx.Context)
641+
642+
if cached, found := cc.parsedBlobCache.Load(refStr); found {
643+
logger.Debug("Parsed blob served from cache")
644+
return cached.(*ast.Term), nil
645+
}
646+
647+
result, err, _ := cc.parsedBlobFlight.Do(refStr, func() (any, error) {
648+
if cached, found := cc.parsedBlobCache.Load(refStr); found {
649+
logger.Debug("Parsed blob served from cache (after singleflight)")
650+
return cached, nil
651+
}
652+
653+
rawTerm, err := ociBlobInternal(bctx, a, true)
654+
if err != nil || rawTerm == nil {
655+
return nil, nil //nolint:nilerr
656+
}
657+
658+
rawStr, ok := rawTerm.Value.(ast.String)
659+
if !ok {
660+
logger.Error("blob value is not a string")
661+
return nil, nil //nolint:nilerr
662+
}
663+
664+
var parsed any
665+
if err := json.Unmarshal([]byte(string(rawStr)), &parsed); err != nil {
666+
logger.WithFields(log.Fields{
667+
"action": "unmarshal",
668+
"error": err,
669+
}).Error("failed to unmarshal blob as JSON")
670+
return nil, nil //nolint:nilerr
671+
}
672+
673+
value, err := ast.InterfaceToValue(parsed)
674+
if err != nil {
675+
logger.WithFields(log.Fields{
676+
"action": "convert to ast",
677+
"error": err,
678+
}).Error("failed to convert parsed JSON to AST value")
679+
return nil, nil //nolint:nilerr
680+
}
681+
682+
term := ast.NewTerm(value)
683+
cc.parsedBlobCache.Store(refStr, term)
684+
logger.Debug("Parsed blob cached")
685+
return term, nil
686+
})
687+
688+
if err != nil || result == nil {
689+
return nil, nil
690+
}
691+
return result.(*ast.Term), nil
692+
}
693+
606694
func ociDescriptor(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) {
607695
logger := log.WithField("function", ociDescriptorName)
608696

@@ -807,10 +895,12 @@ func ClearCaches() {
807895
// Lighter caches (manifests, descriptors, image indexes) remain global because they
808896
// are small and benefit from cross-component sharing (e.g., shared task bundle manifests).
809897
type ComponentCache struct {
810-
blobCache sync.Map
811-
blobFlight singleflight.Group
812-
filesCache sync.Map
813-
filesFlight singleflight.Group
898+
blobCache sync.Map
899+
blobFlight singleflight.Group
900+
filesCache sync.Map
901+
filesFlight singleflight.Group
902+
parsedBlobCache sync.Map
903+
parsedBlobFlight singleflight.Group
814904
}
815905

816906
type componentCacheKey struct{}
@@ -1634,6 +1724,7 @@ func isNotFoundError(err error) bool {
16341724

16351725
func init() {
16361726
registerOCIBlob()
1727+
registerOCIParsedBlob()
16371728
registerOCIBlobFiles()
16381729
registerOCIDescriptor()
16391730
registerOCIImageFiles()

internal/rego/oci/oci_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,96 @@ func TestOCIBlob(t *testing.T) {
129129
}
130130
}
131131

132+
func TestOCIParsedBlob(t *testing.T) {
133+
t.Cleanup(ClearCaches)
134+
ClearCaches()
135+
136+
// Each ref must match the SHA256 of the test data content
137+
// echo -n '{"spam": "maps"}' | sha256sum → 4bbf56a3...
138+
validRef := ast.StringTerm("registry.local/spam@sha256:4bbf56a3a9231f752d3b9c174637975f0f83ed2b15e65799837c571e4ef3374b")
139+
// echo -n 'not valid json' | sha256sum → 62eb7d4f...
140+
invalidJSONRef := ast.StringTerm("registry.local/spam@sha256:62eb7d4ff39a69b09cf8fdaa37579468bf970290cb3ff1fe87554cba9d06cc50")
141+
142+
t.Run("valid JSON blob", func(t *testing.T) {
143+
ClearCaches()
144+
client := fake.FakeClient{}
145+
layer := static.NewLayer([]byte(`{"spam": "maps"}`), types.OCIUncompressedLayer)
146+
client.On("Layer", mock.Anything, mock.Anything).Return(layer, nil)
147+
148+
ctx := oci.WithClient(context.Background(), &client)
149+
bctx := rego.BuiltinContext{Context: ctx}
150+
151+
result, err := ociParsedBlob(bctx, validRef)
152+
require.NoError(t, err)
153+
require.NotNil(t, result)
154+
155+
obj, ok := result.Value.(ast.Object)
156+
require.True(t, ok)
157+
val := obj.Get(ast.StringTerm("spam"))
158+
require.NotNil(t, val)
159+
require.Equal(t, ast.String("maps"), val.Value)
160+
})
161+
162+
t.Run("invalid JSON blob", func(t *testing.T) {
163+
ClearCaches()
164+
client := fake.FakeClient{}
165+
layer := static.NewLayer([]byte(`not valid json`), types.OCIUncompressedLayer)
166+
client.On("Layer", mock.Anything, mock.Anything).Return(layer, nil)
167+
168+
ctx := oci.WithClient(context.Background(), &client)
169+
bctx := rego.BuiltinContext{Context: ctx}
170+
171+
result, err := ociParsedBlob(bctx, invalidJSONRef)
172+
require.NoError(t, err)
173+
require.Nil(t, result)
174+
})
175+
176+
t.Run("caching returns same result", func(t *testing.T) {
177+
ClearCaches()
178+
client := fake.FakeClient{}
179+
layer := static.NewLayer([]byte(`{"spam": "maps"}`), types.OCIUncompressedLayer)
180+
client.On("Layer", mock.Anything, mock.Anything).Return(layer, nil)
181+
182+
ctx := oci.WithClient(context.Background(), &client)
183+
bctx := rego.BuiltinContext{Context: ctx}
184+
185+
result1, err := ociParsedBlob(bctx, validRef)
186+
require.NoError(t, err)
187+
require.NotNil(t, result1)
188+
189+
result2, err := ociParsedBlob(bctx, validRef)
190+
require.NoError(t, err)
191+
require.NotNil(t, result2)
192+
193+
require.Same(t, result1, result2)
194+
client.AssertNumberOfCalls(t, "Layer", 1)
195+
})
196+
197+
t.Run("unexpected uri type", func(t *testing.T) {
198+
ClearCaches()
199+
client := fake.FakeClient{}
200+
ctx := oci.WithClient(context.Background(), &client)
201+
bctx := rego.BuiltinContext{Context: ctx}
202+
203+
result, err := ociParsedBlob(bctx, ast.IntNumberTerm(42))
204+
require.NoError(t, err)
205+
require.Nil(t, result)
206+
})
207+
208+
t.Run("remote error", func(t *testing.T) {
209+
ClearCaches()
210+
client := fake.FakeClient{}
211+
client.On("Layer", mock.Anything, mock.Anything).Return(nil, errors.New("boom!"))
212+
213+
ctx := oci.WithClient(context.Background(), &client)
214+
bctx := rego.BuiltinContext{Context: ctx}
215+
216+
result, err := ociParsedBlob(bctx, validRef)
217+
require.NoError(t, err)
218+
require.Nil(t, result)
219+
})
220+
}
221+
132222
func TestOCIBlobFiles(t *testing.T) {
133223
t.Cleanup(ClearCaches)
134224
ClearCaches() // Clear before test to avoid interference from previous tests
@@ -1258,6 +1348,7 @@ func TestFunctionsRegistered(t *testing.T) {
12581348
ociImageIndexName,
12591349
ociImageTagRefsName,
12601350
ociImageReferrersName,
1351+
ociParsedBlobName,
12611352
}
12621353
for _, name := range names {
12631354
t.Run(name, func(t *testing.T) {

0 commit comments

Comments
 (0)