Add support for delta bundles

Earlier a snapshot bundle would describe the full state of OPA's
policy/data and any update would require first erasing the state from
the existing bundle and then activating the new bundle.

This commit introduces a new bundle type called "delta".
Delta bundles contain patches to data instead of snapshots.
They allow users to efficiently make updates to OPA's data
cache.

Signed-off-by: Ashutosh Narkar <anarkar4387@gmail.com>
This commit is contained in:
Ashutosh Narkar
2021-05-05 08:38:47 -07:00
parent cb867a177c
commit dd02a7f848
15 changed files with 1372 additions and 235 deletions
+164 -82
View File
@@ -35,10 +35,13 @@ const (
PlanFile = "plan.json"
ManifestExt = ".manifest"
SignaturesFile = "signatures.json"
patchFile = "patch.json"
dataFile = "data.json"
yamlDataFile = "data.yaml"
defaultHashingAlg = "SHA-256"
DefaultSizeLimitBytes = (1024 * 1024 * 1024) // limit bundle reads to 1GB to protect against gzip bombs
DeltaBundleType = "delta"
SnapshotBundleType = "snapshot"
)
// Bundle represents a loaded bundle. The bundle can contain data and policies.
@@ -50,6 +53,20 @@ type Bundle struct {
Wasm []byte // Deprecated. Use WasmModules instead
WasmModules []WasmModuleFile
PlanModules []PlanModuleFile
Patch Patch
}
// Patch contains an array of objects wherein each object represents the patch operation to be
// applied to the bundle data.
type Patch struct {
Data []PatchOperation `json:"data,omitempty"`
}
// PatchOperation models a single patch operation against a document.
type PatchOperation struct {
Op string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value"`
}
// SignaturesConfig represents an array of JWTs that encapsulate the signatures for the bundle.
@@ -131,21 +148,11 @@ func (m Manifest) Equal(other Manifest) bool {
return false
}
if len(m.WasmResolvers) != len(other.WasmResolvers) {
return false
}
for i := 0; i < len(m.WasmResolvers); i++ {
if m.WasmResolvers[i] != other.WasmResolvers[i] {
return false
}
}
if !reflect.DeepEqual(m.Metadata, other.Metadata) {
return false
}
return m.rootSet().Equal(other.rootSet())
return m.equalWasmResolversAndRoots(other)
}
// Copy returns a deep copy of the manifest.
@@ -186,6 +193,20 @@ func (m Manifest) rootSet() stringSet {
return stringSet(rs)
}
func (m Manifest) equalWasmResolversAndRoots(other Manifest) bool {
if len(m.WasmResolvers) != len(other.WasmResolvers) {
return false
}
for i := 0; i < len(m.WasmResolvers); i++ {
if m.WasmResolvers[i] != other.WasmResolvers[i] {
return false
}
}
return m.rootSet().Equal(other.rootSet())
}
type stringSet map[string]struct{}
func (ss stringSet) Equal(other stringSet) bool {
@@ -224,12 +245,7 @@ func (m *Manifest) validateAndInjectDefaults(b Bundle) error {
for _, module := range b.Modules {
found := false
if path, err := module.Parsed.Package.Path.Ptr(); err == nil {
for i := range roots {
if strings.HasPrefix(path, roots[i]) {
found = true
break
}
}
found = RootPathsContain(roots, path)
}
if !found {
return fmt.Errorf("manifest roots %v do not permit '%v' in module '%v'", roots, module.Parsed.Package, module.Path)
@@ -250,15 +266,7 @@ func (m *Manifest) validateAndInjectDefaults(b Bundle) error {
}
// Ensure wasm module entrypoint in within bundle roots
found := false
for i := range roots {
if strings.HasPrefix(wmConfig.Entrypoint, roots[i]) {
found = true
break
}
}
if !found {
if !RootPathsContain(roots, wmConfig.Entrypoint) {
return fmt.Errorf("manifest roots %v do not permit '%v' entrypoint for wasm module '%v'", roots, wmConfig.Entrypoint, wmConfig.Module)
}
@@ -270,17 +278,24 @@ func (m *Manifest) validateAndInjectDefaults(b Bundle) error {
wasmModuleToEps[wmConfig.Module] = wmConfig.Entrypoint
}
// Validate data patches in bundle.
for _, patch := range b.Patch.Data {
path := strings.Trim(patch.Path, "/")
if !RootPathsContain(roots, path) {
return fmt.Errorf("manifest roots %v do not permit data patch at path '%s'", roots, path)
}
}
// Validate data in bundle.
return dfs(b.Data, "", func(path string, node interface{}) (bool, error) {
path = strings.Trim(path, "/")
for i := range roots {
if strings.HasPrefix(path, roots[i]) {
return true, nil
}
if RootPathsContain(roots, path) {
return true, nil
}
if _, ok := node.(map[string]interface{}); ok {
for i := range roots {
if strings.HasPrefix(roots[i], path) {
if RootPathsContain(strings.Split(path, "/"), roots[i]) {
return false, nil
}
}
@@ -398,31 +413,28 @@ func (r *Reader) Read() (Bundle, error) {
var descriptors []*Descriptor
var err error
bundle.Data = map[string]interface{}{}
bundle.Signatures, descriptors, err = listSignaturesAndDescriptors(r.loader, r.skipVerify, r.sizeLimitBytes)
bundle.Signatures, bundle.Patch, descriptors, err = preProcessBundle(r.loader, r.skipVerify, r.sizeLimitBytes)
if err != nil {
return bundle, err
}
err = r.checkSignaturesAndDescriptors(bundle.Signatures)
if err != nil {
return bundle, err
if bundle.Type() == SnapshotBundleType {
err = r.checkSignaturesAndDescriptors(bundle.Signatures)
if err != nil {
return bundle, err
}
bundle.Data = map[string]interface{}{}
}
for _, f := range descriptors {
var buf bytes.Buffer
n, err := f.Read(&buf, r.sizeLimitBytes)
f.Close() // always close, even on error
if err != nil && err != io.EOF {
buf, err := readFile(f, r.sizeLimitBytes)
if err != nil {
return bundle, err
} else if err == nil && n >= r.sizeLimitBytes {
return bundle, fmt.Errorf("bundle file exceeded max size (%v bytes)", r.sizeLimitBytes-1)
}
// verify the file content
if !bundle.Signatures.isEmpty() {
if bundle.Type() == SnapshotBundleType && !bundle.Signatures.isEmpty() {
path := f.Path()
if r.baseDir != "" {
path = f.URL()
@@ -509,8 +521,22 @@ func (r *Reader) Read() (Bundle, error) {
}
}
if bundle.Type() == DeltaBundleType {
if len(bundle.Data) != 0 {
return bundle, fmt.Errorf("delta bundle expected to contain only patch file but data files found")
}
if len(bundle.Modules) != 0 {
return bundle, fmt.Errorf("delta bundle expected to contain only patch file but policy files found")
}
if len(bundle.WasmModules) != 0 {
return bundle, fmt.Errorf("delta bundle expected to contain only patch file but wasm files found")
}
}
// check if the bundle signatures specify any files that weren't found in the bundle
if len(r.files) != 0 {
if bundle.Type() == SnapshotBundleType && len(r.files) != 0 {
extra := []string{}
for k := range r.files {
extra = append(extra, k)
@@ -651,43 +677,51 @@ func (w *Writer) Write(bundle Bundle) error {
gw := gzip.NewWriter(w.w)
tw := tar.NewWriter(gw)
var buf bytes.Buffer
bundleType := bundle.Type()
if err := json.NewEncoder(&buf).Encode(bundle.Data); err != nil {
return err
}
if bundleType == SnapshotBundleType {
var buf bytes.Buffer
if err := archive.WriteFile(tw, "data.json", buf.Bytes()); err != nil {
return err
}
for _, module := range bundle.Modules {
path := module.URL
if w.usePath {
path = module.Path
if err := json.NewEncoder(&buf).Encode(bundle.Data); err != nil {
return err
}
if err := archive.WriteFile(tw, path, module.Raw); err != nil {
if err := archive.WriteFile(tw, "data.json", buf.Bytes()); err != nil {
return err
}
for _, module := range bundle.Modules {
path := module.URL
if w.usePath {
path = module.Path
}
if err := archive.WriteFile(tw, path, module.Raw); err != nil {
return err
}
}
if err := w.writeWasm(tw, bundle); err != nil {
return err
}
if err := writeSignatures(tw, bundle); err != nil {
return err
}
if err := w.writePlan(tw, bundle); err != nil {
return err
}
} else if bundleType == DeltaBundleType {
if err := writePatch(tw, bundle); err != nil {
return err
}
}
if err := w.writeWasm(tw, bundle); err != nil {
return err
}
if err := w.writePlan(tw, bundle); err != nil {
return err
}
if err := writeManifest(tw, bundle); err != nil {
return err
}
if err := writeSignatures(tw, bundle); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
@@ -749,6 +783,17 @@ func writeManifest(tw *tar.Writer, bundle Bundle) error {
return archive.WriteFile(tw, ManifestExt, buf.Bytes())
}
func writePatch(tw *tar.Writer, bundle Bundle) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(bundle.Patch); err != nil {
return err
}
return archive.WriteFile(tw, patchFile, buf.Bytes())
}
func writeSignatures(tw *tar.Writer, bundle Bundle) error {
if bundle.Signatures.isEmpty() {
@@ -1019,6 +1064,14 @@ func (b *Bundle) readData(key []string) *interface{} {
return &child
}
// Type returns the type of the bundle.
func (b *Bundle) Type() string {
if len(b.Patch.Data) != 0 {
return DeltaBundleType
}
return SnapshotBundleType
}
func mktree(path []string, value interface{}) (map[string]interface{}, error) {
if len(path) == 0 {
// For 0 length path the value is the full tree.
@@ -1189,9 +1242,10 @@ func IsStructuredDoc(name string) bool {
filepath.Base(name) == SignaturesFile || filepath.Base(name) == ManifestExt
}
func listSignaturesAndDescriptors(loader DirectoryLoader, skipVerify bool, sizeLimitBytes int64) (SignaturesConfig, []*Descriptor, error) {
func preProcessBundle(loader DirectoryLoader, skipVerify bool, sizeLimitBytes int64) (SignaturesConfig, Patch, []*Descriptor, error) {
descriptors := []*Descriptor{}
var signatures SignaturesConfig
var patch Patch
for {
f, err := loader.NextFile()
@@ -1200,26 +1254,54 @@ func listSignaturesAndDescriptors(loader DirectoryLoader, skipVerify bool, sizeL
}
if err != nil {
return signatures, nil, errors.Wrap(err, "bundle read failed")
return signatures, patch, nil, errors.Wrap(err, "bundle read failed")
}
// check for the signatures file
if !skipVerify && strings.HasSuffix(f.Path(), SignaturesFile) {
var buf bytes.Buffer
n, err := f.Read(&buf, sizeLimitBytes)
f.Close() // always close, even on error
if err != nil && err != io.EOF {
return signatures, nil, err
} else if err == nil && n >= sizeLimitBytes {
return signatures, nil, fmt.Errorf("bundle signatures file exceeded max size (%v bytes)", sizeLimitBytes-1)
buf, err := readFile(f, sizeLimitBytes)
if err != nil {
return signatures, patch, nil, err
}
if err := util.NewJSONDecoder(&buf).Decode(&signatures); err != nil {
return signatures, nil, errors.Wrap(err, "bundle load failed on signatures decode")
return signatures, patch, nil, errors.Wrap(err, "bundle load failed on signatures decode")
}
} else if !strings.HasSuffix(f.Path(), SignaturesFile) {
descriptors = append(descriptors, f)
if filepath.Base(f.Path()) == patchFile {
var b bytes.Buffer
tee := io.TeeReader(f.reader, &b)
f.reader = tee
buf, err := readFile(f, sizeLimitBytes)
if err != nil {
return signatures, patch, nil, err
}
if err := util.NewJSONDecoder(&buf).Decode(&patch); err != nil {
return signatures, patch, nil, errors.Wrap(err, "bundle load failed on patch decode")
}
f.reader = &b
}
}
}
return signatures, descriptors, nil
return signatures, patch, descriptors, nil
}
func readFile(f *Descriptor, sizeLimitBytes int64) (bytes.Buffer, error) {
var buf bytes.Buffer
n, err := f.Read(&buf, sizeLimitBytes)
f.Close() // always close, even on error
if err != nil && err != io.EOF {
return buf, err
} else if err == nil && n >= sizeLimitBytes {
return buf, fmt.Errorf("bundle file '%v' exceeded max size (%v bytes)", strings.TrimPrefix(f.Path(), "/"), sizeLimitBytes-1)
}
return buf, nil
}
+147 -3
View File
@@ -101,7 +101,7 @@ func TestReadWithSizeLimit(t *testing.T) {
br := NewCustomReader(loader).WithSizeLimitBytes(4)
_, err := br.Read()
if err == nil || err.Error() != "bundle file exceeded max size (4 bytes)" {
if err == nil || err.Error() != "bundle file 'data.json' exceeded max size (4 bytes)" {
t.Fatal("expected error but got:", err)
}
@@ -113,10 +113,9 @@ func TestReadWithSizeLimit(t *testing.T) {
br = NewCustomReader(loader).WithSizeLimitBytes(4)
_, err = br.Read()
if err == nil || err.Error() != "bundle signatures file exceeded max size (4 bytes)" {
if err == nil || err.Error() != "bundle file '.signatures.json' exceeded max size (4 bytes)" {
t.Fatal("expected error but got:", err)
}
}
func testReadBundle(t *testing.T, baseDir string) {
@@ -418,6 +417,103 @@ func TestReadWithSignaturesWithBaseDir(t *testing.T) {
}
}
func TestReadWithPatch(t *testing.T) {
files := [][2]string{
{"/.manifest", `{"revision": "quickbrownfaux", "roots": ["a"]}`},
{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
}
buf := archive.MustWriteTarGz(files)
loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
reader := NewCustomReader(loader).WithBaseDir("/foo/bar")
b, err := reader.Read()
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
actual := b.Type()
if actual != DeltaBundleType {
t.Fatalf("Expected delta bundle but got %v", actual)
}
if len(b.Patch.Data) != 2 {
t.Fatalf("Expected two patch operations but got %v", len(b.Patch.Data))
}
p1 := PatchOperation{
Op: "add",
Path: "/a/b/d",
Value: "foo",
}
p2 := PatchOperation{
Op: "remove",
Path: "a/b/c",
}
expected := Patch{Data: []PatchOperation{p1, p2}}
if !reflect.DeepEqual(b.Patch.Data, expected.Data) {
t.Fatalf("Expected patch %v but got %v", expected.Data, b.Patch.Data)
}
}
func TestReadWithPatchExtraFiles(t *testing.T) {
cases := []struct {
note string
files [][2]string
err string
}{
{
note: "extra data file",
files: [][2]string{
{"/.manifest", `{"revision": "quickbrownfaux", "roots": ["a"]}`},
{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
{"/a/b/c/data.json", "[1,2,3]"},
},
err: "delta bundle expected to contain only patch file but data files found",
},
{
note: "extra policy file",
files: [][2]string{
{"/.manifest", `{"revision": "quickbrownfaux", "roots": ["a"]}`},
{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
{"/http/policy/policy.rego", `package example`},
},
err: "delta bundle expected to contain only patch file but policy files found",
},
{
note: "extra wasm file",
files: [][2]string{
{"/.manifest", `{"revision": "quickbrownfaux", "roots": ["a"]}`},
{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
{"/policy.wasm", `modules-compiled-as-wasm-binary`},
},
err: "delta bundle expected to contain only patch file but wasm files found",
},
}
for _, tc := range cases {
t.Run(tc.note, func(t *testing.T) {
buf := archive.MustWriteTarGz(tc.files)
loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
reader := NewCustomReader(loader).WithBaseDir("/foo/bar")
_, err := reader.Read()
if tc.err == "" && err != nil {
t.Fatal("Unexpected error occurred:", err)
} else if tc.err != "" && err == nil {
t.Fatal("Expected error but got success")
} else if tc.err != "" && err != nil {
if tc.err != err.Error() {
t.Fatalf("Expected error to contain %q but got: %v", tc.err, err)
}
}
})
}
}
func TestReadWithSignaturesExtraFiles(t *testing.T) {
signedTokenHS256 := `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyJ9.eyJmaWxlcyI6W3sibmFtZSI6Ii5tYW5pZmVzdCIsImhhc2giOiI1MDdhMmMzOGExNDQxZGI1OGQyY2I4Nzk4MmM0MmFhOTFhNDM0MmVmNDIyYTZiNTQyZWRkZWJlZWY2ZjA0MTJmIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6ImEvYi9jL2RhdGEuanNvbiIsImhhc2giOiI0MmNmZTY3NjhiNTdiYjVmNzUwM2MxNjVjMjhkZDA3YWM1YjgxMzU1NGViYzg1MGYyY2MzNTg0M2U3MTM3YjFkIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6Imh0dHAvcG9saWN5L3BvbGljeS5yZWdvIiwiaGFzaCI6ImE2MTVlZWFlZTIxZGU1MTc5ZGUwODBkZThjMzA1MmM4ZGE5MDExMzg0MDZiYTcxYzM4YzAzMjg0NWY3ZDU0ZjQiLCJhbGdvcml0aG0iOiJTSEEtMjU2In1dLCJpYXQiOjE1OTIyNDgwMjcsImlzcyI6IkpXVFNlcnZpY2UiLCJzY29wZSI6IndyaXRlIn0.Vmm9UDiInUnXXlk-OOjiCy3rR7EVvXS-OFst1rbh3Zo`
@@ -635,6 +731,14 @@ func TestReadRootValidation(t *testing.T) {
},
err: "manifest roots [a b c/d] do not permit data at path '/c/e'",
},
{
note: "err data patch outside scope",
files: [][2]string{
{"/.manifest", `{"revision": "abcd", "roots": ["a", "b", "c/d"]}`},
{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "/c/e"}]}`},
},
err: "manifest roots [a b c/d] do not permit data patch at path 'c/e'",
},
}
for _, tc := range cases {
@@ -914,6 +1018,46 @@ func TestRoundtripWithPlanModules(t *testing.T) {
}
}
func TestRoundtripDeltaBundle(t *testing.T) {
// replace a value
p1 := PatchOperation{
Op: "replace",
Path: "a/baz",
Value: "bux",
}
// add a new object member
p2 := PatchOperation{
Op: "add",
Path: "/a/foo",
Value: []string{"hello", "world"},
}
bundle := Bundle{
Patch: Patch{Data: []PatchOperation{p1, p2}},
Manifest: Manifest{
Revision: "delta",
Roots: &[]string{"a"},
},
}
var buf bytes.Buffer
if err := NewWriter(&buf).Write(bundle); err != nil {
t.Fatal("Unexpected error:", err)
}
bundle2, err := NewReader(&buf).Read()
if err != nil {
t.Fatal("Unexpected error:", err)
}
if !bundle2.Equal(bundle) {
t.Fatal("Exp:", bundle, "\n\nGot:", bundle2)
}
}
func TestWriterUsePath(t *testing.T) {
bundle := Bundle{
+138 -22
View File
@@ -9,8 +9,10 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/internal/json/patch"
"github.com/open-policy-agent/opa/metrics"
"github.com/open-policy-agent/opa/storage"
"github.com/open-policy-agent/opa/util"
@@ -300,21 +302,28 @@ func activateBundles(opts *ActivateOpts) error {
// Build collections of bundle names, modules, and roots to erase
erase := map[string]struct{}{}
names := map[string]struct{}{}
deltaBundles := map[string]*Bundle{}
snapshotBundles := map[string]*Bundle{}
for name, b := range opts.Bundles {
names[name] = struct{}{}
if b.Type() == DeltaBundleType {
deltaBundles[name] = b
} else {
snapshotBundles[name] = b
names[name] = struct{}{}
if roots, err := ReadBundleRootsFromStore(opts.Ctx, opts.Store, opts.Txn, name); err == nil {
for _, root := range roots {
if roots, err := ReadBundleRootsFromStore(opts.Ctx, opts.Store, opts.Txn, name); err == nil {
for _, root := range roots {
erase[root] = struct{}{}
}
} else if !storage.IsNotFound(err) {
return err
}
// Erase data at new roots to prepare for writing the new data
for _, root := range *b.Manifest.Roots {
erase[root] = struct{}{}
}
} else if !storage.IsNotFound(err) {
return err
}
// Erase data at new roots to prepare for writing the new data
for _, root := range *b.Manifest.Roots {
erase[root] = struct{}{}
}
}
@@ -325,14 +334,21 @@ func activateBundles(opts *ActivateOpts) error {
return err
}
if len(deltaBundles) != 0 {
err := activateDeltaBundles(opts, deltaBundles)
if err != nil {
return err
}
}
// Erase data and policies at new + old roots, and remove the old
// manifests before activating a new bundles.
// manifests before activating a new snapshot bundle.
remaining, err := eraseBundles(opts.Ctx, opts.Store, opts.Txn, names, erase)
if err != nil {
return err
}
for _, b := range opts.Bundles {
for _, b := range snapshotBundles {
// Write data from each new bundle into the store. Only write under the
// roots contained in their manifest. This should be done *before* the
// policies so that path conflict checks can occur.
@@ -350,22 +366,15 @@ func activateBundles(opts *ActivateOpts) error {
remainingAndExtra[name] = mod
}
err = writeModules(opts.Ctx, opts.Store, opts.Txn, opts.Compiler, opts.Metrics, opts.Bundles, remainingAndExtra, opts.legacy)
err = writeModules(opts.Ctx, opts.Store, opts.Txn, opts.Compiler, opts.Metrics, snapshotBundles, remainingAndExtra, opts.legacy)
if err != nil {
return err
}
for name, b := range opts.Bundles {
// Always write manifests to the named location. If the plugin is in the older style config
// then also write to the old legacy unnamed location.
if err := WriteManifestToStore(opts.Ctx, opts.Store, opts.Txn, name, b.Manifest); err != nil {
for name, b := range snapshotBundles {
if err := writeManifestToStore(opts, name, b.Manifest); err != nil {
return err
}
if opts.legacy {
if err := LegacyWriteManifestToStore(opts.Ctx, opts.Store, opts.Txn, b.Manifest); err != nil {
return err
}
}
if err := writeWasmModulesToStore(opts.Ctx, opts.Store, opts.Txn, name, b); err != nil {
return err
@@ -375,6 +384,56 @@ func activateBundles(opts *ActivateOpts) error {
return nil
}
func activateDeltaBundles(opts *ActivateOpts, bundles map[string]*Bundle) error {
// Check that the manifest roots and wasm resolvers in the delta bundle
// match with those currently in the store
for name, b := range bundles {
value, err := opts.Store.Read(opts.Ctx, opts.Txn, ManifestStoragePath(name))
if err != nil {
if storage.IsNotFound(err) {
continue
}
return err
}
bs, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("corrupt manifest data: %w", err)
}
var manifest Manifest
err = util.UnmarshalJSON(bs, &manifest)
if err != nil {
return fmt.Errorf("corrupt manifest data: %w", err)
}
if !b.Manifest.equalWasmResolversAndRoots(manifest) {
return fmt.Errorf("delta bundle '%s' has wasm resolvers or manifest roots that are different from those in the store", name)
}
}
for _, b := range bundles {
err := applyPatches(opts.Ctx, opts.Store, opts.Txn, b.Patch.Data)
if err != nil {
return err
}
}
if err := ast.CheckPathConflicts(opts.Compiler, storage.NonEmpty(opts.Ctx, opts.Store, opts.Txn)); len(err) > 0 {
return err
}
for name, b := range bundles {
if err := writeManifestToStore(opts, name, b.Manifest); err != nil {
return err
}
}
return nil
}
// erase bundles by name and roots. This will clear all policies and data at its roots and remove its
// manifest from storage.
func eraseBundles(ctx context.Context, store storage.Store, txn storage.Transaction, names map[string]struct{}, roots map[string]struct{}) (map[string]*ast.Module, error) {
@@ -462,6 +521,22 @@ func erasePolicies(ctx context.Context, store storage.Store, txn storage.Transac
return remaining, nil
}
func writeManifestToStore(opts *ActivateOpts, name string, manifest Manifest) error {
// Always write manifests to the named location. If the plugin is in the older style config
// then also write to the old legacy unnamed location.
if err := WriteManifestToStore(opts.Ctx, opts.Store, opts.Txn, name, manifest); err != nil {
return err
}
if opts.legacy {
if err := LegacyWriteManifestToStore(opts.Ctx, opts.Store, opts.Txn, manifest); err != nil {
return err
}
}
return nil
}
func writeData(ctx context.Context, store storage.Store, txn storage.Transaction, roots []string, data map[string]interface{}) error {
for _, root := range roots {
path, ok := storage.ParsePathEscaped("/" + root)
@@ -607,6 +682,47 @@ func hasRootsOverlap(ctx context.Context, store storage.Store, txn storage.Trans
return nil
}
func applyPatches(ctx context.Context, store storage.Store, txn storage.Transaction, patches []PatchOperation) error {
for _, pat := range patches {
// construct patch path
path, ok := patch.ParsePatchPathEscaped("/" + strings.Trim(pat.Path, "/"))
if !ok {
return fmt.Errorf("error parsing patch path")
}
var op storage.PatchOp
switch pat.Op {
case "upsert":
op = storage.AddOp
_, err := store.Read(ctx, txn, path[:len(path)-1])
if err != nil {
if !storage.IsNotFound(err) {
return err
}
if err := storage.MakeDir(ctx, store, txn, path[:len(path)-1]); err != nil {
return err
}
}
case "remove":
op = storage.RemoveOp
case "replace":
op = storage.ReplaceOp
default:
return fmt.Errorf("bad patch operation: %v", pat.Op)
}
// apply the patch
if err := store.Write(ctx, txn, op, path, pat.Value); err != nil {
return err
}
}
return nil
}
// Helpers for the older single (unnamed) bundle style manifest storage.
// LegacyManifestStoragePath is the older unnamed bundle path for manifests to be stored.
+458 -1
View File
@@ -223,7 +223,7 @@ func TestBundleLifecycle(t *testing.T) {
"mod1": ast.MustParseModule("package x\np = true"),
}
mod2 := "package a\np = true"
const mod2 = "package a\np = true"
mod3 := "package b\np = true"
bundles := map[string]*Bundle{
@@ -384,6 +384,463 @@ func TestBundleLifecycle(t *testing.T) {
mockStore.AssertValid(t)
}
func TestDeltaBundleLifecycle(t *testing.T) {
ctx := context.Background()
mockStore := mock.New()
compiler := ast.NewCompiler()
m := metrics.New()
mod1 := "package a\np = true"
mod2 := "package b\np = true"
bundles := map[string]*Bundle{
"bundle1": {
Manifest: Manifest{
Roots: &[]string{"a"},
},
Data: map[string]interface{}{
"a": map[string]interface{}{
"b": "foo",
"e": map[string]interface{}{
"f": "bar",
},
"x": []map[string]string{{"name": "john"}, {"name": "jane"}},
},
},
Modules: []ModuleFile{
{
Path: "a/policy.rego",
Raw: []byte(mod1),
Parsed: ast.MustParseModule(mod1),
},
},
},
"bundle2": {
Manifest: Manifest{
Roots: &[]string{"b", "c"},
},
Data: nil,
Modules: []ModuleFile{
{
Path: "b/policy.rego",
Raw: []byte(mod2),
Parsed: ast.MustParseModule(mod2),
},
},
},
}
txn := storage.NewTransactionOrDie(ctx, mockStore, storage.WriteParams)
err := Activate(&ActivateOpts{
Ctx: ctx,
Store: mockStore,
Txn: txn,
Compiler: compiler,
Metrics: m,
Bundles: bundles,
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = mockStore.Commit(ctx, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// Ensure the snapshot bundles were activated
txn = storage.NewTransactionOrDie(ctx, mockStore)
names, err := ReadBundleNamesFromStore(ctx, mockStore, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(names) != len(bundles) {
t.Fatalf("expected %d bundles in store, found %d", len(bundles), len(names))
}
for _, name := range names {
if _, ok := bundles[name]; !ok {
t.Fatalf("unexpected bundle name found in store: %s", name)
}
}
for bundleName, bundle := range bundles {
for modName := range bundle.ParsedModules(bundleName) {
if _, ok := compiler.Modules[modName]; !ok {
t.Fatalf("expected module %s from bundle %s to have been compiled", modName, bundleName)
}
}
}
// Stop the "read" transaction
mockStore.Abort(ctx, txn)
// create a delta bundle and activate it
// add a new object member
p1 := PatchOperation{
Op: "upsert",
Path: "/a/c/d",
Value: []string{"foo", "bar"},
}
// append value to array
p2 := PatchOperation{
Op: "upsert",
Path: "/a/c/d/-",
Value: "baz",
}
// insert value in array
p3 := PatchOperation{
Op: "upsert",
Path: "/a/x/1",
Value: map[string]string{"name": "alice"},
}
// replace a value
p4 := PatchOperation{
Op: "replace",
Path: "a/b",
Value: "bar",
}
// remove a value
p5 := PatchOperation{
Op: "remove",
Path: "a/e",
}
// add a new object with an escaped character in the path
p6 := PatchOperation{
Op: "upsert",
Path: "a/y/~0z",
Value: []int{1, 2, 3},
}
// add a new object root
p7 := PatchOperation{
Op: "upsert",
Path: "/c/d",
Value: []string{"foo", "bar"},
}
deltaBundles := map[string]*Bundle{
"bundle1": {
Manifest: Manifest{
Revision: "delta-1",
Roots: &[]string{"a"},
},
Patch: Patch{Data: []PatchOperation{p1, p2, p3, p4, p5, p6}},
},
"bundle2": {
Manifest: Manifest{
Revision: "delta-2",
Roots: &[]string{"b", "c"},
},
Patch: Patch{Data: []PatchOperation{p7}},
},
"bundle3": {
Manifest: Manifest{
Roots: &[]string{"d"},
},
Data: map[string]interface{}{
"d": map[string]interface{}{
"e": "foo",
},
},
},
}
txn = storage.NewTransactionOrDie(ctx, mockStore, storage.WriteParams)
err = Activate(&ActivateOpts{
Ctx: ctx,
Store: mockStore,
Txn: txn,
Compiler: compiler,
Metrics: m,
Bundles: deltaBundles,
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = mockStore.Commit(ctx, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// check the modules from the snapshot bundles are on the compiler
for bundleName, bundle := range bundles {
for modName := range bundle.ParsedModules(bundleName) {
if _, ok := compiler.Modules[modName]; !ok {
t.Fatalf("expected module %s from bundle %s to have been compiled", modName, bundleName)
}
}
}
// Ensure the patches were applied
txn = storage.NewTransactionOrDie(ctx, mockStore)
actual, err := mockStore.Read(ctx, txn, storage.MustParsePath("/"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
expectedRaw := `
{
"a": {
"b": "bar",
"c": {
"d": ["foo", "bar", "baz"]
},
"x": [{"name": "john"}, {"name": "alice"}, {"name": "jane"}],
"y": {"~z": [1, 2, 3]}
},
"c": {"d": ["foo", "bar"]},
"d": {"e": "foo"},
"system": {
"bundles": {
"bundle1": {
"manifest": {
"revision": "delta-1",
"roots": ["a"]
}
},
"bundle2": {
"manifest": {
"revision": "delta-2",
"roots": ["b", "c"]
}
},
"bundle3": {
"manifest": {
"revision": "",
"roots": ["d"]
}
}
}
}
}`
expected := loadExpectedSortedResult(expectedRaw)
if !reflect.DeepEqual(expected, actual) {
t.Errorf("expected %v, got %v", expectedRaw, string(util.MustMarshalJSON(actual)))
}
// Stop the "read" transaction
mockStore.Abort(ctx, txn)
mockStore.AssertValid(t)
}
func TestDeltaBundleActivate(t *testing.T) {
ctx := context.Background()
mockStore := mock.New()
compiler := ast.NewCompiler()
m := metrics.New()
// create a delta bundle
p1 := PatchOperation{
Op: "upsert",
Path: "/a/c/d",
Value: []string{"foo", "bar"},
}
deltaBundles := map[string]*Bundle{
"bundle1": {
Manifest: Manifest{
Revision: "delta",
Roots: &[]string{"a"},
},
Patch: Patch{Data: []PatchOperation{p1}},
},
}
txn := storage.NewTransactionOrDie(ctx, mockStore, storage.WriteParams)
err := Activate(&ActivateOpts{
Ctx: ctx,
Store: mockStore,
Txn: txn,
Compiler: compiler,
Metrics: m,
Bundles: deltaBundles,
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = mockStore.Commit(ctx, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// Ensure the delta bundle was activated
txn = storage.NewTransactionOrDie(ctx, mockStore)
names, err := ReadBundleNamesFromStore(ctx, mockStore, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(names) != len(deltaBundles) {
t.Fatalf("expected %d bundles in store, found %d", len(deltaBundles), len(names))
}
for _, name := range names {
if _, ok := deltaBundles[name]; !ok {
t.Fatalf("unexpected bundle name found in store: %s", name)
}
}
// Stop the "read" transaction
mockStore.Abort(ctx, txn)
// Ensure the patches were applied
txn = storage.NewTransactionOrDie(ctx, mockStore)
actual, err := mockStore.Read(ctx, txn, storage.MustParsePath("/"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
expectedRaw := `
{
"a": {
"c": {
"d": ["foo", "bar"]
}
},
"system": {
"bundles": {
"bundle1": {
"manifest": {
"revision": "delta",
"roots": ["a"]
}
}
}
}
}
`
expected := loadExpectedSortedResult(expectedRaw)
if !reflect.DeepEqual(expected, actual) {
t.Errorf("expected %v, got %v", expectedRaw, string(util.MustMarshalJSON(actual)))
}
// Stop the "read" transaction
mockStore.Abort(ctx, txn)
mockStore.AssertValid(t)
}
func TestDeltaBundleBadManifest(t *testing.T) {
ctx := context.Background()
mockStore := mock.New()
compiler := ast.NewCompiler()
m := metrics.New()
mod1 := "package a\np = true"
bundles := map[string]*Bundle{
"bundle1": {
Manifest: Manifest{
Roots: &[]string{"a"},
},
Modules: []ModuleFile{
{
Path: "a/policy.rego",
Raw: []byte(mod1),
Parsed: ast.MustParseModule(mod1),
},
},
},
}
txn := storage.NewTransactionOrDie(ctx, mockStore, storage.WriteParams)
err := Activate(&ActivateOpts{
Ctx: ctx,
Store: mockStore,
Txn: txn,
Compiler: compiler,
Metrics: m,
Bundles: bundles,
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = mockStore.Commit(ctx, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// Ensure the snapshot bundle was activated
txn = storage.NewTransactionOrDie(ctx, mockStore)
names, err := ReadBundleNamesFromStore(ctx, mockStore, txn)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(names) != len(bundles) {
t.Fatalf("expected %d bundles in store, found %d", len(bundles), len(names))
}
for _, name := range names {
if _, ok := bundles[name]; !ok {
t.Fatalf("unexpected bundle name found in store: %s", name)
}
}
// Stop the "read" transaction
mockStore.Abort(ctx, txn)
// create a delta bundle with a different manifest from the snapshot bundle
p1 := PatchOperation{
Op: "upsert",
Path: "/a/c/d",
Value: []string{"foo", "bar"},
}
deltaBundles := map[string]*Bundle{
"bundle1": {
Manifest: Manifest{
Roots: &[]string{"b"},
},
Patch: Patch{Data: []PatchOperation{p1}},
},
}
txn = storage.NewTransactionOrDie(ctx, mockStore, storage.WriteParams)
err = Activate(&ActivateOpts{
Ctx: ctx,
Store: mockStore,
Txn: txn,
Compiler: compiler,
Metrics: m,
Bundles: deltaBundles,
})
if err == nil {
t.Fatal("expected error but got nil")
}
expected := "delta bundle 'bundle1' has wasm resolvers or manifest roots that are different from those in the store"
if err.Error() != expected {
t.Fatalf("Expected error %v but got %v", expected, err.Error())
}
mockStore.AssertValid(t)
}
func TestEraseData(t *testing.T) {
ctx := context.Background()
cases := []struct {
+2
View File
@@ -750,6 +750,7 @@ included in the actual bundle gzipped tarball.
| `bundles[_].polling.min_delay_seconds` | `int64` | No (default: `60`) | Minimum amount of time to wait between bundle downloads. |
| `bundles[_].polling.max_delay_seconds` | `int64` | No (default: `120`) | Maximum amount of time to wait between bundle downloads. |
| `bundles[_].trigger` | `string` (default: `periodic`) | No | Controls how bundle is downloaded from the remote server. Allowed values are `periodic` and `manual`. |
| `bundles[_].polling.long_polling_timeout_seconds` | `int64` | No | Maximum amount of time the server should wait before issuing a timeout if there's no update available. |
| `bundles[_].persist` | `bool` | No | Persist activated bundles to disk. |
| `bundles[_].signing.keyid` | `string` | No | Name of the key to use for bundle signature verification. |
| `bundles[_].signing.scope` | `string` | No | Scope to use for bundle signature verification. |
@@ -793,6 +794,7 @@ included in the actual bundle gzipped tarball.
| `discovery.polling.min_delay_seconds` | `int64` | No (default: `60`) | Minimum amount of time to wait between configuration downloads. |
| `discovery.polling.max_delay_seconds` | `int64` | No (default: `120`) | Maximum amount of time to wait between configuration downloads. |
| `discovery.trigger` | `string` (default: `periodic`) | No | Controls how bundle is downloaded from the remote server. Allowed values are `periodic` and `manual`. |
| `discovery.polling.long_polling_timeout_seconds` | `int64` | No | Maximum amount of time the server should wait before issuing a timeout if there's no update available. |
| `discovery.signing.keyid` | `string` | No | Name of the key to use for bundle signature verification. |
| `discovery.signing.scope` | `string` | No | Scope to use for bundle signature verification. |
| `discovery.signing.exclude_files` | `array` | No | Files in the bundle to exclude during verification. |
+137
View File
@@ -105,6 +105,46 @@ in bundle responses to identify the revision of the bundle. OPA will include the
check the `If-None-Match` header and reply with HTTP `304 Not Modified` if the
bundle has not changed since the last update.
#### HTTP Long Polling
With the periodic bundle downloading (ie. `short polling`) technique, OPA sends regular requests to the remote HTTP
server to pull any available bundle. If there is no new bundle, the server responds with a `304 Not Modified` response.
The polling frequency depends on the latency that the client can tolerate in
retrieving updated information from the server. A drawback of this
method is that if the acceptable latency is low, then the polling frequency could add unnecessary
burden on the server and/or network.
[HTTP Long Polling](https://datatracker.ietf.org/doc/html/rfc6202#section-2) helps to minimize server/network resource
usage and also reduces the delay in delivery of updates to the client. When OPA sends a long poll request to the server,
it defers its response until an update is available or timeout has occurred. In case of a timeout, the server responds
with a `304 Not Modified` response.
The below configuration shows how to enable bundle downloading via `long polling`:
```yaml
services:
- name: acmecorp
url: https://example.com/service/v1
credentials:
bearer:
token: "bGFza2RqZmxha3NkamZsa2Fqc2Rsa2ZqYWtsc2RqZmtramRmYWxkc2tm"
bundles:
authz:
service: acmecorp
resource: somedir/bundle.tar.gz
persist: true
polling:
long_polling_timeout_seconds: 10
signing:
keyid: my_global_key
scope: read
```
With the above configuration, OPA sends a long poll request to the server with a timeout set to `10` seconds. If the server
supports `long polling`, OPA expects the server to set the `Content-Type` header to `application/vnd.openpolicyagent.bundles`.
If the server does not support `long polling`, OPA will fallback to the regular periodic polling.
### Bundle File Format
Bundle files are gzipped tarballs that contain policies and data. The data
@@ -419,6 +459,103 @@ bundle.RegisterSigner("custom", &CustomSigner{})
bundle.RegisterVerifier("custom", &CustomVerifier{})
```
### Delta Bundles
A regular _snapshot_ bundle represents the entirety of OPAs policy and data cache. When a new _snapshot_ bundle is
downloaded, OPA will erase and overwrite all the policy and data in its cache before activating the new bundle. We can
optionally scope the bundle to a subset of OPAs policy and data cache by defining the `roots` in the bundle's manifest.
Although OPA [caches](#caching) snapshot bundles to avoid unnecessary retransmission,
servers must still retransmit the entire snapshot when any change occurs. If you need
to propagate small changes to bundles without waiting for polling delays, consider
using _delta_ bundles in conjunction with [HTTP Long Polling](#http-long-polling).
_Delta_ bundles provide a more efficient way to make data changes by containing patches to data instead of snapshots.
_Delta_ bundles are similar to _snapshot_ bundles in terms of structure and layout semantics. A _delta_ bundle contains a
single `patch.json` file at the root of the bundle which includes a [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902)
(i.e., an array of JSON objects). The operations in the JSON Patch will be applied to OPA's in-memory store in order.
_Delta_ bundles currently support updates to data only and not on policies. Hence, by leveraging _delta_ bundles along with
[HTTP Long Polling](#http-long-polling), bundle services can propagate data changes to OPAs more quickly and efficiently.
#### Delta Bundle File Format
OPA expects a _delta_ bundle to contain an optional `.manifest` file and a required `patch.json` file that specifies a list of
patch operations on the data. OPA will generate an error if a _delta_ bundle contains any policy, data or wasm binary files.
If the `.manifest` file specifies any `roots`, any data patch outside the bundle's roots will cause an error.
```bash
$ tar tzf bundle.tar.gz
.manifest
patch.json
```
Below is an example of the `patch.json` file:
```json
[
{"op": "upsert", "path": "/a/b", "value": ["hello", "world"]},
{"op": "remove", "path": "/a/c"}
]
```
A _delta_ bundle update for an existing _snapshot_ bundle, MUST have the same values for the manifest `roots` and `wasm`
fields from the original _snapshot_ bundle. This means a _delta_ bundle cannot be used to change the scope of the original
bundle. A _delta_ bundle can however contain different values for the bundle's `revision` and `metadata`.
#### Delta Bundle Patch Operations
Each patch operation defined in the `patch.json` file must have exactly one `op` member which indicates the
operation to perform. Valid options include:
| op | Description |
|-----|--------------|
| `"remove"` | The `"path"` specified will be removed from OPA's in-memory store. The `"value"` field is ignored for `"remove"` operations. |
| `"replace"` | The value at the specified `"path"` will be replaced by the new value defined by the `"value"` field. The target path must exist for the operation to be successful. |
| `"upsert"` | The `"value"` will be set at the specified `"path"`. If the `"path"` specifies an array index, the `"value"` is inserted into the array at the specified index. If the `"path"` specifies an object member that does not already exist, a new member is added to the object. If the object member exists, its value is replaced. If the `"path"` does not exist, OPA will create and add it to its in-memory store. |
The `"path"` field defines a JSON pointer path to the location to perform the operation on.
The `"value"` field defines the value to be added or replaced. Only required for `"upsert"` and `"replace"` operations.
#### Limitations
* _Delta_ bundles only support updates to data
* _Delta_ bundles do not support bundle signing
* Unlike _snapshot_ bundles, activated _delta_ bundles are not persisted to disk when the `bundles[_].persist` field is `true`
#### Delta Bundle FAQ
This section discusses some _delta_ bundle usage, edge cases and failure scenarios.
* What happens if OPA cannot apply a data patch ?
Bundle activation will fail in this scenario. In the next attempt to download the bundle, OPA will set the value
of the `If-None-Match` header of the bundle request to the last successful activation Etag value. This should help the
Bundle Service to send the correct revision of the bundle to OPA.
* What happens if OPA cannot reach the Bundle Service (for example. network failure) or is unable to download a bundle ?
OPA always includes the last successful activation Etag value in the bundle request. When OPA eventually reconnects
with the server, the value of the `If-None-Match` header of bundle request could be empty indicating that OPA was not
able to activate the first revision of the bundle itself. This helps the server to re-transmit the correct bundle revision.
In case OPA has already activated a revision of the bundle, and reaches out to the server with the last
successful activation Etag value, the server now knows to send the next bundle revision. This could either be a snapshot
or delta bundle. One possible approach on the server-side, would be to first send a snapshot bundle and then send delta bundles
to perform data patch operations. The server could maintain the order in which the bundles should go out for example,
assigning an Etag value to each bundle revision. Hence, it can figure out the right bundle to send by looking up the
`If-None-Match` header of bundle request and then lining-up the next bundle in the queue.
* Does a _delta_ bundle always need to be preceded by a _snapshot_ bundle ?
No. OPA will activate a _delta_ bundle if all the patch operations in it were successfully applied. Note that a _snapshot_
bundle would erase and overwrite policy and data under the manifest `roots`.
## Implementations
The Bundle API is simple. Most HTTP servers capable of serving static files will do. While not strictly required in all deployments, it is also good if the implementation supports:
+6
View File
@@ -14,6 +14,12 @@ import (
const (
defaultMinDelaySeconds = int64(60)
defaultMaxDelaySeconds = int64(120)
// deltaBundleMode indicates that OPA supports delta bundle processing
deltaBundleMode = "delta"
// defaultBundleMode indicates that OPA supports snapshot bundle processing
defaultBundleMode = "snapshot"
)
// PollingConfig represents polling configuration for the downloader.
+9 -3
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"path"
"strconv"
"strings"
"sync"
"time"
@@ -260,8 +261,12 @@ func (d *Downloader) download(ctx context.Context, m metrics.Metrics) (*download
d.logger.Debug("Download starting.")
d.client = d.client.WithHeader("If-None-Match", d.etag)
preferences := []string{fmt.Sprintf("modes=%v,%v", defaultBundleMode, deltaBundleMode)}
if d.longPollingEnabled && d.config.Polling.LongPollingTimeoutSeconds != nil {
d.client = d.client.WithHeader("Prefer", fmt.Sprintf("wait=%s", strconv.FormatInt(*d.config.Polling.LongPollingTimeoutSeconds, 10)))
wait := fmt.Sprintf("wait=%s", strconv.FormatInt(*d.config.Polling.LongPollingTimeoutSeconds, 10))
preferences = append(preferences, wait)
// fetch existing response header timeout value on the http client's transport and
// clear it for the long poll request
@@ -271,10 +276,11 @@ func (d *Downloader) download(ctx context.Context, m metrics.Metrics) (*download
t := int64(0)
d.client = d.client.SetResponseHeaderTimeout(&t)
}
} else {
d.client = d.client.WithHeader("Prefer", "wait=0")
}
preferValue := fmt.Sprintf("%v", strings.Join(preferences, ";"))
d.client = d.client.WithHeader("Prefer", preferValue)
m.Timer(metrics.BundleRequest).Start()
resp, err := d.client.Do(ctx, "GET", d.path)
m.Timer(metrics.BundleRequest).Stop()
+79 -8
View File
@@ -153,6 +153,37 @@ func TestStopWithMultipleCalls(t *testing.T) {
}
}
func TestStartStopWithDeltaBundleMode(t *testing.T) {
ctx := context.Background()
updates := make(chan *Update)
config := Config{}
if err := config.ValidateAndInjectDefaults(); err != nil {
t.Fatal(err)
}
fixture := newTestFixture(t)
d := New(config, fixture.client, "/bundles/test/bundle2").WithCallback(func(_ context.Context, u Update) {
updates <- &u
})
d.Start(ctx)
// Give time for some download events to occur
time.Sleep(1 * time.Second)
u1 := <-updates
if u1.Bundle == nil || u1.Bundle.Manifest.Revision != deltaBundleMode {
t.Fatal("expected delta bundle but got:", u1)
}
d.Stop(ctx)
}
func TestStartStopWithLongPollNotSupported(t *testing.T) {
ctx := context.Background()
@@ -584,7 +615,8 @@ func TestOneShotLongPollingSwitch(t *testing.T) {
func TestOneShotNotLongPollingSwitch(t *testing.T) {
ctx := context.Background()
config := Config{}
config.Polling.LongPollingTimeoutSeconds = nil
timeout := int64(3)
config.Polling.LongPollingTimeoutSeconds = &timeout
if err := config.ValidateAndInjectDefaults(); err != nil {
t.Fatal(err)
}
@@ -599,7 +631,7 @@ func TestOneShotNotLongPollingSwitch(t *testing.T) {
if err != nil {
t.Fatal("Unexpected:", err)
}
if fixture.d.longPollingEnabled != true {
if !fixture.d.longPollingEnabled {
t.Fatal("Expected long polling to be enabled")
}
@@ -624,6 +656,12 @@ type testFixture struct {
func newTestFixture(t *testing.T) testFixture {
patch := bundle.PatchOperation{
Op: "upsert",
Path: "/a/c/d",
Value: []string{"foo", "bar"},
}
ts := testServer{
t: t,
expAuth: "Bearer secret",
@@ -645,6 +683,12 @@ func newTestFixture(t *testing.T) testFixture {
},
},
},
"test/bundle2": {
Manifest: bundle.Manifest{
Revision: deltaBundleMode,
},
Patch: bundle.Patch{Data: []bundle.PatchOperation{patch}},
},
},
}
@@ -709,12 +753,8 @@ type testServer struct {
func (t *testServer) handle(w http.ResponseWriter, r *http.Request) {
if t.longPoll {
parts := strings.Split(r.Header.Get("Prefer"), "=")
if len(parts) != 2 {
panic("Invalid \"wait\" Preference")
}
timeout, err := strconv.Atoi(parts[1])
wait := getPreferHeaderField(r, "wait")
timeout, err := strconv.Atoi(wait)
if err != nil {
panic(err)
}
@@ -742,6 +782,23 @@ func (t *testServer) handle(w http.ResponseWriter, r *http.Request) {
return
}
// check to verify if server can send a delta bundle to OPA
if b.Manifest.Revision == deltaBundleMode {
modes := strings.Split(getPreferHeaderField(r, "modes"), ",")
found := false
for _, m := range modes {
if m == deltaBundleMode {
found = true
break
}
}
if !found {
panic("delta bundle requested but OPA does not support it")
}
}
contentTypeShouldBeSend := true
if t.expEtag != "" {
etag := r.Header.Get("If-None-Match")
@@ -786,3 +843,17 @@ func (t *testServer) start() {
func (t *testServer) stop() {
t.server.Close()
}
func getPreferHeaderField(r *http.Request, field string) string {
for _, line := range r.Header.Values("prefer") {
for _, part := range strings.Split(line, ";") {
preference := strings.Split(strings.TrimSpace(part), "=")
if len(preference) == 2 {
if strings.ToLower(preference[0]) == field {
return preference[1]
}
}
}
}
return ""
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2021 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package patch
import (
"strings"
"github.com/open-policy-agent/opa/storage"
)
// ParsePatchPathEscaped returns a new path for the given escaped str.
// This is based on storage.ParsePathEscaped so will do URL unescaping of
// the provided str for backwards compatibility, but also handles the
// specific escape strings defined in RFC 6901 (JSON Pointer) because
// that's what's mandated by RFC 6902 (JSON Patch).
func ParsePatchPathEscaped(str string) (path storage.Path, ok bool) {
path, ok = storage.ParsePathEscaped(str)
if !ok {
return
}
for i := range path {
// RFC 6902 section 4: "[The "path" member's] value is a string containing
// a JSON-Pointer value [RFC6901] that references a location within the
// target document (the "target location") where the operation is performed."
//
// RFC 6901 section 3: "Because the characters '~' (%x7E) and '/' (%x2F)
// have special meanings in JSON Pointer, '~' needs to be encoded as '~0'
// and '/' needs to be encoded as '~1' when these characters appear in a
// reference token."
// RFC 6901 section 4: "Evaluation of each reference token begins by
// decoding any escaped character sequence. This is performed by first
// transforming any occurrence of the sequence '~1' to '/', and then
// transforming any occurrence of the sequence '~0' to '~'. By performing
// the substitutions in this order, an implementation avoids the error of
// turning '~01' first into '~1' and then into '/', which would be
// incorrect (the string '~01' correctly becomes '~1' after transformation)."
path[i] = strings.Replace(path[i], "~1", "/", -1)
path[i] = strings.Replace(path[i], "~0", "~", -1)
}
return
}
+87
View File
@@ -0,0 +1,87 @@
package patch
import (
"reflect"
"testing"
"github.com/open-policy-agent/opa/storage"
)
func TestParsePatchPathEscaped(t *testing.T) {
tests := []struct {
note string
path string
expectedPath storage.Path
expectedOK bool
}{
// success-path tests
{
note: "single-level",
path: "/single-level",
expectedPath: storage.Path{"single-level"},
expectedOK: true,
},
{
note: "multi-level",
path: "/a/multi-level/path",
expectedPath: storage.Path{"a", "multi-level", "path"},
expectedOK: true,
},
{
note: "end",
path: "/-",
expectedPath: storage.Path{"-"},
expectedOK: true,
},
{ // not strictly correct but included for backwards compatibility with existing OPA
note: "url-escaped forward slash",
path: "/github.com%2Fopen-policy-agent",
expectedPath: storage.Path{"github.com/open-policy-agent"},
expectedOK: true,
},
{
note: "json-pointer-escaped forward slash",
path: "/github.com~1open-policy-agent",
expectedPath: storage.Path{"github.com/open-policy-agent"},
expectedOK: true,
},
{
note: "json-pointer-escaped tilde",
path: "/~0opa",
expectedPath: storage.Path{"~opa"},
expectedOK: true,
},
{
note: "json-pointer-escape correctness",
path: "/~01",
expectedPath: storage.Path{"~1"},
expectedOK: true,
},
// failure-path tests
{ // not possible with existing callers but for completeness...
note: "empty string",
path: "",
expectedOK: false,
},
{ // not possible with existing callers but for completeness...
note: "string that doesn't start with /",
path: "foo",
expectedOK: false,
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
actualPath, actualOK := ParsePatchPathEscaped(tc.path)
if tc.expectedOK != actualOK {
t.Fatalf("Expected ok to be %v but was %v", tc.expectedOK, actualOK)
}
if !reflect.DeepEqual(tc.expectedPath, actualPath) {
t.Fatalf("Expected path to be %v but was %v", tc.expectedPath, actualPath)
}
})
}
}
+13 -3
View File
@@ -454,7 +454,7 @@ func (p *Plugin) process(ctx context.Context, name string, u download.Update) {
return
}
if p.persistBundle(name) {
if u.Bundle.Type() == bundle.SnapshotBundleType && p.persistBundle(name) {
p.log(name).Debug("Persisting bundle to disk in progress.")
err := p.saveBundleToDisk(name, u.Raw)
@@ -521,8 +521,18 @@ func (p *Plugin) activate(ctx context.Context, name string, b *bundle.Bundle) er
// Compile the bundle modules with a new compiler and set it on the
// transaction params for use by onCommit hooks.
compiler := ast.NewCompiler().
WithPathConflictsCheck(storage.NonEmpty(ctx, p.manager.Store, txn)).
// If activating a delta bundle, use the manager's compiler which should have
// the polices compiled on it.
var compiler *ast.Compiler
if b.Type() == bundle.DeltaBundleType {
compiler = p.manager.GetCompiler()
}
if compiler == nil {
compiler = ast.NewCompiler()
}
compiler = compiler.WithPathConflictsCheck(storage.NonEmpty(ctx, p.manager.Store, txn)).
WithEnablePrintStatements(p.manager.EnablePrintStatements())
var activateErr error
+85
View File
@@ -97,6 +97,91 @@ func TestPluginOneShot(t *testing.T) {
}
}
func TestPluginOneShotDeltaBundle(t *testing.T) {
ctx := context.Background()
manager := getTestManager()
plugin := New(&Config{}, manager)
bundleName := "test-bundle"
plugin.status[bundleName] = &Status{Name: bundleName, Metrics: metrics.New()}
plugin.downloaders[bundleName] = download.New(download.Config{}, plugin.manager.Client(""), bundleName)
ensurePluginState(t, plugin, plugins.StateNotReady)
module := "package a\n\ncorge=1"
b := bundle.Bundle{
Manifest: bundle.Manifest{Revision: "quickbrownfaux", Roots: &[]string{"a"}},
Data: map[string]interface{}{
"a": map[string]interface{}{
"baz": "qux",
},
},
Modules: []bundle.ModuleFile{
{
Path: "a/policy.rego",
Parsed: ast.MustParseModule(module),
Raw: []byte(module),
},
},
}
plugin.oneShot(ctx, bundleName, download.Update{Bundle: &b, Metrics: metrics.New()})
ensurePluginState(t, plugin, plugins.StateOK)
// simulate a delta bundle download
// replace a value
p1 := bundle.PatchOperation{
Op: "replace",
Path: "a/baz",
Value: "bux",
}
// add a new object member
p2 := bundle.PatchOperation{
Op: "upsert",
Path: "/a/foo",
Value: []string{"hello", "world"},
}
b2 := bundle.Bundle{
Manifest: bundle.Manifest{Revision: "delta", Roots: &[]string{"a"}},
Patch: bundle.Patch{Data: []bundle.PatchOperation{p1, p2}},
}
plugin.process(ctx, bundleName, download.Update{Bundle: &b2, Metrics: metrics.New()})
ensurePluginState(t, plugin, plugins.StateOK)
txn := storage.NewTransactionOrDie(ctx, manager.Store)
defer manager.Store.Abort(ctx, txn)
ids, err := manager.Store.ListPolicies(ctx, txn)
if err != nil {
t.Fatal(err)
} else if len(ids) != 1 {
t.Fatal("Expected 1 policy")
}
bs, err := manager.Store.GetPolicy(ctx, txn, ids[0])
exp := []byte("package a\n\ncorge=1")
if err != nil {
t.Fatal(err)
} else if !bytes.Equal(bs, exp) {
t.Fatalf("Bad policy content. Exp:\n%v\n\nGot:\n\n%v", string(exp), string(bs))
}
data, err := manager.Store.Read(ctx, txn, storage.Path{})
expData := util.MustUnmarshalJSON([]byte(`{"a": {"baz": "bux", "foo": ["hello", "world"]}, "system": {"bundles": {"test-bundle": {"manifest": {"revision": "delta", "roots": ["a"]}}}}}`))
if err != nil {
t.Fatal(err)
} else if !reflect.DeepEqual(data, expData) {
t.Fatalf("Bad data content. Exp:\n%v\n\nGot:\n\n%v", expData, data)
}
}
func TestPluginStart(t *testing.T) {
ctx := context.Background()
+2 -34
View File
@@ -34,6 +34,7 @@ import (
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/bundle"
"github.com/open-policy-agent/opa/internal/json/patch"
"github.com/open-policy-agent/opa/metrics"
"github.com/open-policy-agent/opa/plugins"
bundlePlugin "github.com/open-policy-agent/opa/plugins/bundle"
@@ -2429,7 +2430,7 @@ func (s *Server) prepareV1PatchSlice(root string, ops []types.PatchV1) (result [
}
var ok bool
impl.path, ok = parsePatchPathEscaped(path)
impl.path, ok = patch.ParsePatchPathEscaped(path)
if !ok {
return nil, types.BadPatchPathErr(op.Path)
}
@@ -2495,39 +2496,6 @@ func (s *Server) updateCacheConfig(cacheConfig *iCache.Config) {
s.interQueryBuiltinCache.UpdateConfig(cacheConfig)
}
// parsePatchPathEscaped returns a new path for the given escaped str.
// This is based on storage.ParsePathEscaped so will do URL unescaping of
// the provided str for backwards compatibility, but also handles the
// specific escape strings defined in RFC 6901 (JSON Pointer) because
// that's what's mandated by RFC 6902 (JSON Patch).
func parsePatchPathEscaped(str string) (path storage.Path, ok bool) {
path, ok = storage.ParsePathEscaped(str)
if !ok {
return
}
for i := range path {
// RFC 6902 section 4: "[The "path" member's] value is a string containing
// a JSON-Pointer value [RFC6901] that references a location within the
// target document (the "target location") where the operation is performed."
//
// RFC 6901 section 3: "Because the characters '~' (%x7E) and '/' (%x2F)
// have special meanings in JSON Pointer, '~' needs to be encoded as '~0'
// and '/' needs to be encoded as '~1' when these characters appear in a
// reference token."
// RFC 6901 section 4: "Evaluation of each reference token begins by
// decoding any escaped character sequence. This is performed by first
// transforming any occurrence of the sequence '~1' to '/', and then
// transforming any occurrence of the sequence '~0' to '~'. By performing
// the substitutions in this order, an implementation avoids the error of
// turning '~01' first into '~1' and then into '/', which would be
// incorrect (the string '~01' correctly becomes '~1' after transformation)."
path[i] = strings.Replace(path[i], "~1", "/", -1)
path[i] = strings.Replace(path[i], "~0", "~", -1)
}
return
}
func stringPathToDataRef(s string) (r ast.Ref) {
result := ast.Ref{ast.DefaultRootDocument}
result = append(result, stringPathToRef(s)...)
-79
View File
@@ -1567,85 +1567,6 @@ func TestDataPutV1IfNoneMatch(t *testing.T) {
}
}
func TestParsePatchPathEscaped(t *testing.T) {
tests := []struct {
note string
path string
expectedPath storage.Path
expectedOK bool
}{
// success-path tests
{
note: "single-level",
path: "/single-level",
expectedPath: storage.Path{"single-level"},
expectedOK: true,
},
{
note: "multi-level",
path: "/a/multi-level/path",
expectedPath: storage.Path{"a", "multi-level", "path"},
expectedOK: true,
},
{
note: "end",
path: "/-",
expectedPath: storage.Path{"-"},
expectedOK: true,
},
{ // not strictly correct but included for backwards compatibility with existing OPA
note: "url-escaped forward slash",
path: "/github.com%2Fopen-policy-agent",
expectedPath: storage.Path{"github.com/open-policy-agent"},
expectedOK: true,
},
{
note: "json-pointer-escaped forward slash",
path: "/github.com~1open-policy-agent",
expectedPath: storage.Path{"github.com/open-policy-agent"},
expectedOK: true,
},
{
note: "json-pointer-escaped tilde",
path: "/~0opa",
expectedPath: storage.Path{"~opa"},
expectedOK: true,
},
{
note: "json-pointer-escape correctness",
path: "/~01",
expectedPath: storage.Path{"~1"},
expectedOK: true,
},
// failure-path tests
{ // not possible with existing callers but for completeness...
note: "empty string",
path: "",
expectedOK: false,
},
{ // not possible with existing callers but for completeness...
note: "string that doesn't start with /",
path: "foo",
expectedOK: false,
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
actualPath, actualOK := parsePatchPathEscaped(tc.path)
if tc.expectedOK != actualOK {
t.Fatalf("Expected ok to be %v but was %v", tc.expectedOK, actualOK)
}
if !reflect.DeepEqual(tc.expectedPath, actualPath) {
t.Fatalf("Expected path to be %v but was %v", tc.expectedPath, actualPath)
}
})
}
}
func TestBundleScope(t *testing.T) {
ctx := context.Background()