Skip to content

Commit 2211d78

Browse files
committed
feat: implement OPA bundle validator
1 parent fa49f44 commit 2211d78

4 files changed

Lines changed: 271 additions & 0 deletions

File tree

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,16 @@ require (
5454
github.com/armon/go-radix v1.0.0 // indirect
5555
github.com/beorn7/perks v1.0.1 // indirect
5656
github.com/bgentry/speakeasy v0.2.0 // indirect
57+
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 // indirect
5758
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
5859
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
5960
github.com/cespare/xxhash/v2 v2.3.0 // indirect
61+
github.com/containerd/containerd/v2 v2.1.4 // indirect
6062
github.com/containerd/errdefs v1.0.0 // indirect
6163
github.com/containerd/errdefs/pkg v0.3.0 // indirect
6264
github.com/containerd/log v0.1.0 // indirect
6365
github.com/containerd/platforms v1.0.0-rc.1 // indirect
66+
github.com/containerd/typeurl/v2 v2.2.3 // indirect
6467
github.com/cpuguy83/dockercfg v0.3.2 // indirect
6568
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
6669
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
@@ -70,6 +73,7 @@ require (
7073
github.com/ebitengine/purego v0.8.4 // indirect
7174
github.com/fatih/color v1.18.0 // indirect
7275
github.com/felixge/httpsnoop v1.0.4 // indirect
76+
github.com/fsnotify/fsnotify v1.9.0 // indirect
7377
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
7478
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
7579
github.com/go-ini/ini v1.67.0 // indirect
@@ -145,6 +149,7 @@ require (
145149
github.com/mitchellh/reflectwalk v1.0.2 // indirect
146150
github.com/moby/docker-image-spec v1.3.1 // indirect
147151
github.com/moby/go-archive v0.1.0 // indirect
152+
github.com/moby/locker v1.0.1 // indirect
148153
github.com/moby/patternmatcher v0.6.0 // indirect
149154
github.com/moby/sys/sequential v0.6.0 // indirect
150155
github.com/moby/sys/user v0.4.0 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
5656
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5757
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
5858
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
59+
github.com/containerd/containerd/v2 v2.1.4 h1:/hXWjiSFd6ftrBOBGfAZ6T30LJcx1dBjdKEeI8xucKQ=
60+
github.com/containerd/containerd/v2 v2.1.4/go.mod h1:8C5QV9djwsYDNhxfTCFjWtTBZrqjditQ4/ghHSYjnHM=
5961
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
6062
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
6163
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -64,6 +66,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
6466
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
6567
github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E=
6668
github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
69+
github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40=
70+
github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
6771
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
6872
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
6973
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -105,6 +109,8 @@ github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7Dlme
105109
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
106110
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
107111
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
112+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
113+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
108114
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
109115
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
110116
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
@@ -304,6 +310,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
304310
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
305311
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
306312
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
313+
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
314+
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
307315
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
308316
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
309317
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
@@ -350,6 +358,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
350358
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
351359
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
352360
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
361+
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
362+
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
353363
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
354364
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
355365
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package validator
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
9+
"github.com/hashicorp/go-multierror"
10+
"github.com/mxab/nacp/pkg/admissionctrl"
11+
"github.com/mxab/nacp/pkg/admissionctrl/types"
12+
"github.com/open-policy-agent/opa/v1/sdk"
13+
)
14+
15+
type OpaBundleValidator struct {
16+
name string
17+
path string
18+
logger *slog.Logger
19+
opa *sdk.OPA
20+
}
21+
22+
var _ admissionctrl.JobValidator = (*OpaBundleValidator)(nil) // Verify that *T implements I.
23+
24+
func NewOpaBundleValidator(name string, path string, logger *slog.Logger, sdk *sdk.OPA) (*OpaBundleValidator, error) {
25+
return &OpaBundleValidator{
26+
name: name,
27+
path: path,
28+
logger: logger,
29+
opa: sdk,
30+
}, nil
31+
}
32+
33+
func (v *OpaBundleValidator) Validate(ctx context.Context, payload *types.Payload) (warnings []error, err error) {
34+
35+
result, err := v.opa.Decision(ctx, sdk.DecisionOptions{
36+
Input: payload,
37+
Path: v.path,
38+
})
39+
if err != nil {
40+
return nil, fmt.Errorf("failed to perform policy decision: %w", err)
41+
}
42+
43+
v.logger.DebugContext(ctx, "OPA decision", slog.Any("result", result))
44+
45+
if rmap, ok := result.Result.(map[string]interface{}); ok {
46+
if errs, found := rmap["errors"]; found {
47+
if errlist, ok := errs.([]interface{}); ok {
48+
49+
for _, e := range errlist {
50+
if emsg, ok := e.(string); ok {
51+
err = multierror.Append(err, errors.New(emsg))
52+
} else {
53+
err = multierror.Append(err, fmt.Errorf("policy yielded an invalid error value: %v", e))
54+
}
55+
}
56+
if err != nil {
57+
return
58+
}
59+
} else if errs != nil {
60+
err = fmt.Errorf("policy yielded an invalid errors value: %v", errs)
61+
return
62+
}
63+
}
64+
if warns, found := rmap["warnings"]; found {
65+
if warnlist, ok := warns.([]interface{}); ok {
66+
for _, w := range warnlist {
67+
if wmsg, ok := w.(string); ok {
68+
warnings = append(warnings, errors.New(wmsg))
69+
} else {
70+
warnings = append(warnings, fmt.Errorf("policy yielded an invalid warning value: %v", w))
71+
}
72+
}
73+
} else if warns != nil {
74+
warnings = append(warnings, fmt.Errorf("policy yielded an invalid warnings value: %v", warns))
75+
}
76+
}
77+
}
78+
79+
return
80+
}
81+
82+
func (v *OpaBundleValidator) Name() string {
83+
return v.name
84+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package validator
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"log/slog"
7+
"testing"
8+
9+
"github.com/mxab/nacp/pkg/admissionctrl/types"
10+
"github.com/mxab/nacp/testutil"
11+
"github.com/open-policy-agent/opa/v1/logging"
12+
"github.com/open-policy-agent/opa/v1/sdk"
13+
sdktest "github.com/open-policy-agent/opa/v1/sdk/test"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestOpaBundleValidator(t *testing.T) {
19+
20+
// https://www.openpolicyagent.org/docs/integration#integrating-with-the-go-sdk
21+
22+
tt := []struct {
23+
name string
24+
policy string
25+
path string
26+
expectErrParts []string
27+
expectWarns []string
28+
}{
29+
{
30+
name: "no issues",
31+
policy: `package mypolicy`,
32+
path: "/mypolicy",
33+
},
34+
{
35+
name: "error",
36+
policy: `package mypolicy
37+
errors = ["an error message"]`,
38+
path: "/mypolicy",
39+
expectErrParts: []string{"an error message"},
40+
},
41+
{
42+
name: "multiple errors",
43+
policy: `package mypolicy
44+
errors = ["an error message", "another error message"]`,
45+
path: "/mypolicy",
46+
expectErrParts: []string{"an error message", "another error message"},
47+
},
48+
{
49+
name: "warning",
50+
policy: `package mypolicy
51+
warnings = ["a warning message"]`,
52+
path: "/mypolicy",
53+
expectWarns: []string{"a warning message"},
54+
},
55+
{
56+
name: "handle invalid errors value",
57+
policy: `package mypolicy
58+
errors = 5`,
59+
path: "/mypolicy",
60+
expectErrParts: []string{"policy yielded an invalid errors value"},
61+
},
62+
{
63+
name: "handle invalid error value",
64+
policy: `package mypolicy
65+
errors = [5]`,
66+
path: "/mypolicy",
67+
expectErrParts: []string{"policy yielded an invalid error value"},
68+
},
69+
{
70+
name: "handle invalid warnings value",
71+
policy: `package mypolicy
72+
warnings = 5`,
73+
path: "/mypolicy",
74+
expectWarns: []string{"policy yielded an invalid warnings value"},
75+
},
76+
{
77+
name: "handle invalid warnings value",
78+
policy: `package mypolicy
79+
warnings = [5]`,
80+
path: "/mypolicy",
81+
expectWarns: []string{"policy yielded an invalid warning value"},
82+
},
83+
{
84+
name: "test invalid policy path",
85+
policy: `package mypolicy
86+
errors = ["an error message"]`,
87+
path: "/invalidpath",
88+
expectErrParts: []string{"failed to perform policy decision"},
89+
},
90+
}
91+
92+
for _, tc := range tt {
93+
t.Run(tc.name, func(t *testing.T) {
94+
job := testutil.BaseJob()
95+
96+
opa := setupOpa(t, tc.policy)
97+
validator, err := NewOpaBundleValidator("testopabundlevalidator", tc.path, slog.New(slog.DiscardHandler), opa)
98+
99+
require.NoError(t, err, "No error creating validator")
100+
101+
warnings, err := validator.Validate(t.Context(), &types.Payload{Job: job})
102+
103+
if len(tc.expectErrParts) > 0 {
104+
for _, expectErrPart := range tc.expectErrParts {
105+
assert.ErrorContains(t, err, expectErrPart, "Error from validator")
106+
}
107+
} else {
108+
assert.NoError(t, err, "No error from validator")
109+
}
110+
111+
// check warnings
112+
require.Len(t, warnings, len(tc.expectWarns), "Number of warnings from validator")
113+
for i, expectWarn := range tc.expectWarns {
114+
assert.ErrorContains(t, warnings[i], expectWarn, "Warning from validator")
115+
}
116+
117+
})
118+
}
119+
}
120+
121+
func setupOpa(t *testing.T, policy string) *sdk.OPA {
122+
t.Helper()
123+
ctx := t.Context()
124+
125+
// create a mock HTTP bundle server
126+
server, err := sdktest.NewServer(sdktest.MockBundle("/bundles/bundle.tar.gz", map[string]string{
127+
"example.rego": policy,
128+
}))
129+
require.NoError(t, err, "No error creating mock server")
130+
t.Cleanup(server.Stop)
131+
132+
// provide the OPA configuration which specifies
133+
// fetching policy bundles from the mock server
134+
// and logging decisions locally to the console
135+
config := []byte(fmt.Sprintf(`{
136+
"services": {
137+
"test": {
138+
"url": %q
139+
}
140+
},
141+
"bundles": {
142+
"test": {
143+
"resource": "/bundles/bundle.tar.gz"
144+
}
145+
},
146+
"decision_logs": {
147+
"console": true
148+
}
149+
}`, server.URL()))
150+
151+
// create an instance of the OPA object
152+
153+
opa, err := sdk.New(ctx, sdk.Options{
154+
ID: "opa-test-1",
155+
Config: bytes.NewReader(config),
156+
Logger: logging.New(),
157+
})
158+
require.NoError(t, err, "No error creating OPA instance")
159+
t.Cleanup(func() {
160+
opa.Stop(ctx)
161+
})
162+
163+
return opa
164+
}
165+
func TestBundleValidatorName(t *testing.T) {
166+
opa := setupOpa(t, "package mypolicy")
167+
validator, err := NewOpaBundleValidator("testopabundlevalidator", "/mypolicy", slog.New(slog.DiscardHandler), opa)
168+
169+
require.NoError(t, err, "No error creating validator")
170+
171+
assert.Equal(t, "testopabundlevalidator", validator.Name(), "Validator name")
172+
}

0 commit comments

Comments
 (0)