Skip to content

Commit c776047

Browse files
committed
feat: opa bundle json patch
1 parent 840eb36 commit c776047

File tree

2 files changed

+327
-0
lines changed

2 files changed

+327
-0
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package mutator
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
9+
"github.com/hashicorp/go-multierror"
10+
"github.com/hashicorp/nomad/api"
11+
"github.com/mxab/nacp/pkg/admissionctrl/mutator/jsonpatcher"
12+
"github.com/open-policy-agent/opa/v1/sdk"
13+
)
14+
15+
type OpaBundleMutator struct {
16+
name string
17+
path string
18+
logger *slog.Logger
19+
opa *sdk.OPA
20+
}
21+
22+
func (m *OpaBundleMutator) Mutate(context context.Context, job *api.Job) (result *api.Job, mutated bool, warns []error, err error) {
23+
mutated = false
24+
warns = []error{}
25+
err = nil
26+
27+
decision, err := m.opa.Decision(context, sdk.DecisionOptions{
28+
Input: job,
29+
Path: m.path,
30+
})
31+
if err != nil {
32+
err = fmt.Errorf("failed to perform policy decision: %w", err)
33+
return
34+
}
35+
m.logger.DebugContext(context, "OPA decision", slog.Any("result", decision))
36+
37+
if rmap, ok := decision.Result.(map[string]interface{}); ok {
38+
if errs, found := rmap["errors"]; found {
39+
if errlist, ok := errs.([]interface{}); ok {
40+
41+
for _, e := range errlist {
42+
if emsg, ok := e.(string); ok {
43+
err = multierror.Append(err, errors.New(emsg))
44+
} else if e != nil {
45+
err = fmt.Errorf("policy yielded an invalid error entry value: %v", e)
46+
return
47+
}
48+
49+
}
50+
if err != nil {
51+
return
52+
}
53+
} else if errs != nil {
54+
err = fmt.Errorf("policy yielded an invalid errors value: %v", errs)
55+
return
56+
}
57+
}
58+
if warnsRaw, found := rmap["warnings"]; found {
59+
if warnlist, ok := warnsRaw.([]interface{}); ok {
60+
61+
for _, w := range warnlist {
62+
if wmsg, ok := w.(string); ok {
63+
warns = append(warns, errors.New(wmsg))
64+
} else if w != nil {
65+
err = fmt.Errorf("policy yielded an invalid warning entry value: %v", w)
66+
return
67+
}
68+
}
69+
} else if warnsRaw != nil {
70+
err = fmt.Errorf("policy yielded an invalid warnings value: %v", warnsRaw)
71+
return
72+
}
73+
}
74+
if patch, found := rmap["patch"]; found {
75+
if ops, ok := patch.([]interface{}); ok {
76+
result, mutated, err = jsonpatcher.PatchJob(job, ops)
77+
if err != nil {
78+
err = fmt.Errorf("policy yielded patch failed: %w", err)
79+
return
80+
}
81+
} else if patch != nil {
82+
err = fmt.Errorf("policy yielded an invalid patch value: %v", patch)
83+
return
84+
}
85+
} else {
86+
// No patch, return original job
87+
result = job
88+
}
89+
}
90+
91+
return
92+
}
93+
94+
func NewOpaBundleMutator(name string, path string, logger *slog.Logger, sdk *sdk.OPA) (*OpaBundleMutator, error) {
95+
return &OpaBundleMutator{
96+
name: name,
97+
path: path,
98+
logger: logger,
99+
opa: sdk,
100+
}, nil
101+
}
102+
103+
func (m *OpaBundleMutator) Name() string {
104+
return m.name
105+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package mutator
2+
3+
import (
4+
"log/slog"
5+
"testing"
6+
7+
"github.com/hashicorp/nomad/api"
8+
"github.com/mxab/nacp/testutil"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestOpaBundleMutatorName(t *testing.T) {
14+
15+
opa := testutil.SetupOpa(t, "package mypolicy")
16+
mutator, err := NewOpaBundleMutator("test", "test/path", slog.Default(), opa)
17+
18+
require.NoError(t, err, "No error creating mutator")
19+
20+
assert.Equal(t, "test", mutator.Name(), "Name is correct")
21+
}
22+
23+
func TestOpaBundleMutator(t *testing.T) {
24+
25+
tt := []struct {
26+
name string
27+
policy string
28+
path string
29+
inputJob *api.Job
30+
31+
expectedJob *api.Job
32+
expectedMutated bool
33+
expectedWarns []string
34+
expectedErrs []string
35+
}{
36+
{
37+
name: "no changes",
38+
policy: `package mypolicy
39+
`,
40+
path: "/mypolicy",
41+
inputJob: testutil.BaseJob(),
42+
expectedJob: testutil.BaseJob(),
43+
expectedMutated: false,
44+
expectedWarns: []string{},
45+
expectedErrs: []string{},
46+
},
47+
{
48+
name: "handle policy eval error",
49+
policy: `package mypolicy
50+
`,
51+
path: "/nonexistentpath",
52+
inputJob: &api.Job{},
53+
expectedJob: nil,
54+
expectedMutated: false,
55+
expectedWarns: []string{},
56+
expectedErrs: []string{"failed to perform policy decision"},
57+
},
58+
{
59+
name: "add meta",
60+
policy: `package mypolicy
61+
patch = [
62+
{"op": "add", "path": "/Meta", "value": {}},
63+
{"op": "add", "path": "/Meta/hello", "value": "world"}
64+
]
65+
`,
66+
path: "/mypolicy",
67+
inputJob: &api.Job{},
68+
expectedJob: &api.Job{
69+
Meta: map[string]string{
70+
"hello": "world",
71+
},
72+
},
73+
expectedMutated: true,
74+
expectedWarns: []string{},
75+
expectedErrs: []string{},
76+
},
77+
{
78+
name: "handle errors",
79+
policy: `package mypolicy
80+
errors = ["This is an error message", "This is another error message"]
81+
`,
82+
path: "/mypolicy",
83+
inputJob: &api.Job{},
84+
expectedJob: nil,
85+
expectedMutated: false,
86+
expectedWarns: []string{},
87+
expectedErrs: []string{"This is an error message", "This is another error message"},
88+
},
89+
{
90+
name: "handle warnings",
91+
policy: `package mypolicy
92+
warnings = ["This is a warning message", "This is another warning message"]
93+
`,
94+
path: "/mypolicy",
95+
inputJob: &api.Job{},
96+
expectedJob: &api.Job{},
97+
expectedMutated: false,
98+
expectedWarns: []string{"This is a warning message", "This is another warning message"},
99+
expectedErrs: []string{},
100+
},
101+
{
102+
name: "handle patch and warnings",
103+
policy: `package mypolicy
104+
patch = [
105+
{"op": "add", "path": "/Meta", "value": {}},
106+
{"op": "add", "path": "/Meta/hello", "value": "world"}
107+
]
108+
warnings = ["This is a warning message", "This is another warning message"]
109+
`,
110+
path: "/mypolicy",
111+
inputJob: &api.Job{},
112+
expectedJob: &api.Job{
113+
Meta: map[string]string{
114+
"hello": "world",
115+
},
116+
},
117+
expectedMutated: true,
118+
expectedWarns: []string{"This is a warning message", "This is another warning message"},
119+
expectedErrs: []string{},
120+
},
121+
{
122+
name: "handle invalid error type",
123+
policy: `package mypolicy
124+
errors = 5
125+
`,
126+
path: "/mypolicy",
127+
inputJob: &api.Job{},
128+
expectedJob: nil,
129+
expectedMutated: false,
130+
expectedWarns: []string{},
131+
expectedErrs: []string{"policy yielded an invalid errors value"},
132+
},
133+
{
134+
name: "handle invalid warning type as error",
135+
policy: `package mypolicy
136+
warnings = 5
137+
`,
138+
path: "/mypolicy",
139+
inputJob: &api.Job{},
140+
expectedJob: nil,
141+
expectedMutated: false,
142+
expectedWarns: []string{},
143+
expectedErrs: []string{"policy yielded an invalid warnings value"},
144+
},
145+
{
146+
name: "handle invalid error entry type as error",
147+
policy: `package mypolicy
148+
errors = ["this is fine", 5]
149+
`,
150+
path: "/mypolicy",
151+
inputJob: &api.Job{},
152+
expectedJob: nil,
153+
expectedMutated: false,
154+
expectedWarns: []string{},
155+
expectedErrs: []string{"policy yielded an invalid error entry value"},
156+
},
157+
{
158+
name: "handle invalid warning entry type as warning",
159+
policy: `package mypolicy
160+
warnings = ["this is fine", 5]
161+
`,
162+
path: "/mypolicy",
163+
inputJob: &api.Job{},
164+
expectedJob: nil,
165+
expectedMutated: false,
166+
expectedWarns: []string{"this is fine"},
167+
expectedErrs: []string{"policy yielded an invalid warning entry value"},
168+
},
169+
{
170+
name: "handle invalid patch type as error",
171+
policy: `package mypolicy
172+
patch = 5
173+
`,
174+
path: "/mypolicy",
175+
inputJob: &api.Job{},
176+
expectedJob: nil,
177+
expectedMutated: false,
178+
expectedWarns: []string{},
179+
expectedErrs: []string{"policy yielded an invalid patch value"},
180+
},
181+
{
182+
name: "handle invalid patch entry type as error",
183+
policy: `package mypolicy
184+
patch = [5]
185+
`,
186+
path: "/mypolicy",
187+
inputJob: &api.Job{},
188+
expectedJob: nil,
189+
expectedMutated: false,
190+
expectedWarns: []string{},
191+
expectedErrs: []string{"policy yielded patch failed"},
192+
},
193+
}
194+
195+
for _, tc := range tt {
196+
t.Run(tc.name, func(t *testing.T) {
197+
opa := testutil.SetupOpa(t, tc.policy)
198+
mutator, err := NewOpaBundleMutator(tc.name, tc.path, slog.New(slog.DiscardHandler), opa)
199+
200+
require.NoError(t, err, "No error creating mutator")
201+
result, mutated, warns, err := mutator.Mutate(t.Context(), tc.inputJob)
202+
203+
assert.Equal(t, tc.expectedJob, result, "Job is correct")
204+
assert.Equal(t, tc.expectedMutated, mutated, "Mutated is correct")
205+
// warns
206+
assert.Len(t, warns, len(tc.expectedWarns), "Warnings length is correct")
207+
208+
for i, expectedWarn := range tc.expectedWarns {
209+
assert.ErrorContains(t, warns[i], expectedWarn, "Warning is correct")
210+
}
211+
// errs
212+
if len(tc.expectedErrs) == 0 {
213+
assert.NoError(t, err, "No error")
214+
} else {
215+
for _, expectedErr := range tc.expectedErrs {
216+
assert.ErrorContains(t, err, expectedErr, "Error is correct")
217+
}
218+
}
219+
220+
})
221+
}
222+
}

0 commit comments

Comments
 (0)