mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
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:
+164
-82
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 OPA’s 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 OPA’s 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)...)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user