Skip to content

Commit c058812

Browse files
authored
feat: implements external data response cache (#334)
1 parent 6ccacf8 commit c058812

File tree

7 files changed

+257
-5
lines changed

7 files changed

+257
-5
lines changed

.github/workflows/gatekeeper.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ jobs:
55
lint:
66
name: "Gatekeeper Test"
77
runs-on: ubuntu-latest
8-
timeout-minutes: 10
8+
timeout-minutes: 15
99
steps:
1010
- name: Set up Go 1.20
1111
uses: actions/setup-go@v4

constraint/pkg/client/drivers/rego/args.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ func AddExternalDataProviderCache(providerCache *externaldata.ProviderCache) Arg
9292
}
9393
}
9494

95+
func AddExternalDataProviderResponseCache(providerResponseCache *externaldata.ProviderResponseCache) Arg {
96+
return func(d *Driver) error {
97+
d.providerResponseCache = providerResponseCache
98+
99+
return nil
100+
}
101+
}
102+
95103
func DisableBuiltins(builtins ...string) Arg {
96104
return func(d *Driver) error {
97105
if d.compilers.capabilities == nil {

constraint/pkg/client/drivers/rego/builtin.go

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ package rego
22

33
import (
44
"net/http"
5+
"time"
56

67
"github.com/open-policy-agent/frameworks/constraint/pkg/externaldata"
78
"github.com/open-policy-agent/opa/ast"
89
"github.com/open-policy-agent/opa/rego"
910
)
1011

12+
const (
13+
providerResponseAPIVersion = "externaldata.gatekeeper.sh/v1beta1"
14+
providerResponseKind = "ProviderResponse"
15+
)
16+
1117
func externalDataBuiltin(d *Driver) func(bctx rego.BuiltinContext, regorequest *ast.Term) (*ast.Term, error) {
1218
return func(bctx rego.BuiltinContext, regorequest *ast.Term) (*ast.Term, error) {
1319
var regoReq externaldata.RegoRequest
@@ -25,12 +31,78 @@ func externalDataBuiltin(d *Driver) func(bctx rego.BuiltinContext, regorequest *
2531
return externaldata.HandleError(http.StatusBadRequest, err)
2632
}
2733

28-
externaldataResponse, statusCode, err := d.sendRequestToProvider(bctx.Context, &provider, regoReq.Keys, clientCert)
29-
if err != nil {
30-
return externaldata.HandleError(statusCode, err)
34+
// check provider response cache
35+
var providerRequestKeys []string
36+
var providerResponseStatusCode int
37+
var prepareResponse externaldata.Response
38+
39+
prepareResponse.Idempotent = true
40+
for _, k := range regoReq.Keys {
41+
cachedResponse, err := d.providerResponseCache.Get(
42+
externaldata.CacheKey{
43+
ProviderName: regoReq.ProviderName,
44+
Key: k,
45+
},
46+
)
47+
if err != nil || time.Since(time.Unix(cachedResponse.Received, 0)) > d.providerResponseCache.TTL {
48+
// key is not found or cache entry is stale, add key to the provider request keys
49+
providerRequestKeys = append(providerRequestKeys, k)
50+
} else {
51+
prepareResponse.Items = append(
52+
prepareResponse.Items, externaldata.Item{
53+
Key: k,
54+
Value: cachedResponse.Value,
55+
Error: cachedResponse.Error,
56+
},
57+
)
58+
59+
// we are taking conservative approach here, if any of the cached response is not idempotent
60+
// we will mark the whole response as not idempotent
61+
if !cachedResponse.Idempotent {
62+
prepareResponse.Idempotent = false
63+
}
64+
}
65+
}
66+
67+
if len(providerRequestKeys) > 0 {
68+
externaldataResponse, statusCode, err := d.sendRequestToProvider(bctx.Context, &provider, providerRequestKeys, clientCert)
69+
if err != nil {
70+
return externaldata.HandleError(statusCode, err)
71+
}
72+
73+
for _, item := range externaldataResponse.Response.Items {
74+
d.providerResponseCache.Upsert(
75+
externaldata.CacheKey{
76+
ProviderName: regoReq.ProviderName,
77+
Key: item.Key,
78+
},
79+
externaldata.CacheValue{
80+
Received: time.Now().Unix(),
81+
Value: item.Value,
82+
Error: item.Error,
83+
Idempotent: externaldataResponse.Response.Idempotent,
84+
},
85+
)
86+
}
87+
88+
// we are taking conservative approach here, if any of the response is not idempotent
89+
// we will mark the whole response as not idempotent
90+
if !externaldataResponse.Response.Idempotent {
91+
prepareResponse.Idempotent = false
92+
}
93+
94+
prepareResponse.Items = append(prepareResponse.Items, externaldataResponse.Response.Items...)
95+
prepareResponse.SystemError = externaldataResponse.Response.SystemError
96+
providerResponseStatusCode = statusCode
97+
}
98+
99+
providerResponse := &externaldata.ProviderResponse{
100+
APIVersion: providerResponseAPIVersion,
101+
Kind: providerResponseKind,
102+
Response: prepareResponse,
31103
}
32104

33-
regoResponse := externaldata.NewRegoResponse(statusCode, externaldataResponse)
105+
regoResponse := externaldata.NewRegoResponse(providerResponseStatusCode, providerResponse)
34106
return externaldata.PrepareRegoResponse(regoResponse)
35107
}
36108
}

constraint/pkg/client/drivers/rego/driver.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ type Driver struct {
7373
// providerCache allows Rego to read from external_data in Rego queries.
7474
providerCache *externaldata.ProviderCache
7575

76+
// providerResponseCache allows to cache responses from external_data providers.
77+
providerResponseCache *externaldata.ProviderResponseCache
78+
7679
// sendRequestToProvider allows Rego to send requests to the provider specified in external_data.
7780
sendRequestToProvider externaldata.SendRequestToProvider
7881

constraint/pkg/client/drivers/rego/driver_unit_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"sort"
1111
"testing"
12+
"time"
1213

1314
"github.com/google/go-cmp/cmp"
1415
"github.com/google/go-cmp/cmp/cmpopts"
@@ -743,6 +744,7 @@ func TestDriver_ExternalData(t *testing.T) {
743744

744745
d, err := New(
745746
AddExternalDataProviderCache(externaldata.NewCache()),
747+
AddExternalDataProviderResponseCache(externaldata.NewProviderResponseCache(context.Background(), 1*time.Minute)),
746748
EnableExternalDataClientAuth(),
747749
AddExternalDataClientCertWatcher(clientCertWatcher),
748750
)

constraint/pkg/externaldata/cache.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,91 @@
11
package externaldata
22

33
import (
4+
"context"
45
"crypto/x509"
56
"encoding/base64"
67
"encoding/pem"
78
"fmt"
89
"net/url"
910
"sync"
11+
"time"
1012

1113
"github.com/open-policy-agent/frameworks/constraint/pkg/apis/externaldata/unversioned"
14+
"k8s.io/apimachinery/pkg/util/wait"
1215
)
1316

1417
type ProviderCache struct {
1518
cache map[string]unversioned.Provider
1619
mux sync.RWMutex
1720
}
1821

22+
type ProviderResponseCache struct {
23+
cache sync.Map
24+
TTL time.Duration
25+
}
26+
27+
type CacheKey struct {
28+
ProviderName string
29+
Key string
30+
}
31+
32+
type CacheValue struct {
33+
Received int64
34+
Value interface{}
35+
Error string
36+
Idempotent bool
37+
}
38+
39+
func NewProviderResponseCache(ctx context.Context, ttl time.Duration) *ProviderResponseCache {
40+
providerResponseCache := &ProviderResponseCache{
41+
cache: sync.Map{},
42+
TTL: ttl,
43+
}
44+
45+
go wait.UntilWithContext(ctx, func(ctx context.Context) {
46+
providerResponseCache.invalidateProviderResponseCache(providerResponseCache.TTL)
47+
}, ttl)
48+
49+
return providerResponseCache
50+
}
51+
52+
func (c *ProviderResponseCache) Get(key CacheKey) (*CacheValue, error) {
53+
if v, ok := c.cache.Load(key); ok {
54+
value, ok := v.(*CacheValue)
55+
if !ok {
56+
return nil, fmt.Errorf("value is not of type CacheValue")
57+
}
58+
return value, nil
59+
}
60+
return nil, fmt.Errorf("key '%s:%s' is not found in provider response cache", key.ProviderName, key.Key)
61+
}
62+
63+
func (c *ProviderResponseCache) Upsert(key CacheKey, value CacheValue) {
64+
c.cache.Store(key, &value)
65+
}
66+
67+
func (c *ProviderResponseCache) Remove(key CacheKey) {
68+
c.cache.Delete(key)
69+
}
70+
71+
func (c *ProviderResponseCache) invalidateProviderResponseCache(ttl time.Duration) {
72+
c.cache.Range(func(k, v interface{}) bool {
73+
value, ok := v.(*CacheValue)
74+
if !ok {
75+
return false
76+
}
77+
78+
if time.Since(time.Unix(value.Received, 0)) > ttl {
79+
key, ok := k.(CacheKey)
80+
if !ok {
81+
return false
82+
}
83+
c.Remove(key)
84+
}
85+
return true
86+
})
87+
}
88+
1989
func NewCache() *ProviderCache {
2090
return &ProviderCache{
2191
cache: make(map[string]unversioned.Provider),

constraint/pkg/externaldata/cache_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package externaldata
22

33
import (
4+
"context"
5+
"fmt"
46
"testing"
7+
"time"
58

69
"github.com/open-policy-agent/frameworks/constraint/pkg/apis/externaldata/unversioned"
710
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -169,3 +172,97 @@ func TestRemove(t *testing.T) {
169172
})
170173
}
171174
}
175+
176+
func TestProviderResponseCache(t *testing.T) {
177+
ctx, cancel := context.WithCancel(context.Background())
178+
t.Cleanup(cancel)
179+
180+
tests := []struct {
181+
name string
182+
key CacheKey
183+
value CacheValue
184+
expected *CacheValue
185+
expectedErr error
186+
}{
187+
{
188+
name: "Upsert and Get",
189+
key: CacheKey{ProviderName: "test", Key: "key1"},
190+
value: CacheValue{Received: time.Now().Unix(), Value: "value1"},
191+
expected: &CacheValue{Received: time.Now().Unix(), Value: "value1"},
192+
expectedErr: nil,
193+
},
194+
{
195+
name: "Remove",
196+
key: CacheKey{ProviderName: "test", Key: "key1"},
197+
value: CacheValue{Received: time.Now().Unix(), Value: "value1"},
198+
expected: nil,
199+
expectedErr: fmt.Errorf("key 'test:key1' is not found in provider response cache"),
200+
},
201+
{
202+
name: "Invalidation",
203+
key: CacheKey{ProviderName: "test", Key: "key2"},
204+
value: CacheValue{Value: "value2"},
205+
expected: nil,
206+
expectedErr: fmt.Errorf("key 'test:key2' is not found in provider response cache"),
207+
},
208+
{
209+
name: "Error",
210+
key: CacheKey{ProviderName: "test", Key: "key3"},
211+
value: CacheValue{},
212+
expected: nil,
213+
expectedErr: fmt.Errorf("key 'test:key3' is not found in provider response cache"),
214+
},
215+
}
216+
217+
for _, tt := range tests {
218+
t.Run(tt.name, func(t *testing.T) {
219+
switch tt.name {
220+
case "Upsert and Get":
221+
cache := NewProviderResponseCache(ctx, 1*time.Minute)
222+
cache.Upsert(tt.key, tt.value)
223+
224+
cachedValue, err := cache.Get(tt.key)
225+
if err != tt.expectedErr {
226+
t.Errorf("Expected error to be %v, but got %v", tt.expectedErr, err)
227+
}
228+
if cachedValue != nil && cachedValue.Value != tt.expected.Value {
229+
t.Errorf("Expected cached value to be %v, but got %v", tt.expected.Value, cachedValue.Value)
230+
}
231+
case "Remove":
232+
cache := NewProviderResponseCache(ctx, 1*time.Minute)
233+
cache.Remove(tt.key)
234+
235+
_, err := cache.Get(tt.key)
236+
if err == nil {
237+
t.Errorf("Expected error, but got nil")
238+
}
239+
if err.Error() != tt.expectedErr.Error() {
240+
t.Errorf("Expected error message to be '%s', but got '%s'", tt.expectedErr.Error(), err.Error())
241+
}
242+
case "Invalidation":
243+
cache := NewProviderResponseCache(ctx, 5*time.Second)
244+
tt.value.Received = time.Now().Add(-10 * time.Second).Unix()
245+
cache.Upsert(tt.key, tt.value)
246+
247+
time.Sleep(5 * time.Second)
248+
249+
_, err := cache.Get(tt.key)
250+
if err == nil {
251+
t.Errorf("Expected error, but got nil")
252+
}
253+
if err.Error() != tt.expectedErr.Error() {
254+
t.Errorf("Expected error message to be '%s', but got '%s'", tt.expectedErr.Error(), err.Error())
255+
}
256+
case "Error":
257+
cache := NewProviderResponseCache(ctx, 1*time.Minute)
258+
_, err := cache.Get(tt.key)
259+
if err == nil {
260+
t.Errorf("Expected error, but got nil")
261+
}
262+
if err.Error() != tt.expectedErr.Error() {
263+
t.Errorf("Expected error message to be '%s', but got '%s'", tt.expectedErr.Error(), err.Error())
264+
}
265+
}
266+
})
267+
}
268+
}

0 commit comments

Comments
 (0)