diff --git a/pkg/apis/policy/v1alpha1/assertion.go b/pkg/apis/policy/v1alpha1/assertion.go index fc3fb097..94060a5a 100644 --- a/pkg/apis/policy/v1alpha1/assertion.go +++ b/pkg/apis/policy/v1alpha1/assertion.go @@ -4,9 +4,10 @@ package v1alpha1 type Assertion struct { // Message is the message associated message. // +optional - Message string `json:"message,omitempty"` + Message *Message `json:"message,omitempty"` // Engine defines the default engine to use when evaluating expressions. + // +optional Engine *Engine `json:"engine,omitempty"` // Check is the assertion check definition. diff --git a/pkg/apis/policy/v1alpha1/engine.go b/pkg/apis/policy/v1alpha1/engine.go index d97c766d..2a4e1270 100644 --- a/pkg/apis/policy/v1alpha1/engine.go +++ b/pkg/apis/policy/v1alpha1/engine.go @@ -1,5 +1,6 @@ package v1alpha1 +// Engine defines the engine to use when evaluating expressions. // +kubebuilder:validation:Enum:=jp;cel type Engine string diff --git a/pkg/apis/policy/v1alpha1/message.go b/pkg/apis/policy/v1alpha1/message.go new file mode 100644 index 00000000..fb6d479e --- /dev/null +++ b/pkg/apis/policy/v1alpha1/message.go @@ -0,0 +1,42 @@ +package v1alpha1 + +import ( + "github.com/kyverno/kyverno-json/pkg/message" + "k8s.io/apimachinery/pkg/util/json" +) + +type _message = message.Message + +// Message stores a message template. +// +k8s:deepcopy-gen=false +// +kubebuilder:validation:Type:=string +type Message struct { + _message +} + +func (a *Message) MarshalJSON() ([]byte, error) { + return json.Marshal(a.Original()) +} + +func (a *Message) UnmarshalJSON(data []byte) error { + var v string + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + a._message = message.Parse(v) + return nil +} + +func (in *Message) DeepCopyInto(out *Message) { + out._message = in._message +} + +func (in *Message) DeepCopy() *Message { + if in == nil { + return nil + } + out := new(Message) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/policy/v1alpha1/validating_policy_spec.go b/pkg/apis/policy/v1alpha1/validating_policy_spec.go index 9d049743..038ec210 100644 --- a/pkg/apis/policy/v1alpha1/validating_policy_spec.go +++ b/pkg/apis/policy/v1alpha1/validating_policy_spec.go @@ -3,6 +3,7 @@ package v1alpha1 // ValidatingPolicySpec contains the policy spec. type ValidatingPolicySpec struct { // Engine defines the default engine to use when evaluating expressions. + // +optional Engine *Engine `json:"engine,omitempty"` // Rules is a list of ValidatingRule instances. diff --git a/pkg/apis/policy/v1alpha1/validating_rule.go b/pkg/apis/policy/v1alpha1/validating_rule.go index afd91f96..5bd3f60c 100644 --- a/pkg/apis/policy/v1alpha1/validating_rule.go +++ b/pkg/apis/policy/v1alpha1/validating_rule.go @@ -7,6 +7,7 @@ type ValidatingRule struct { Name string `json:"name"` // Engine defines the default engine to use when evaluating expressions. + // +optional Engine *Engine `json:"engine,omitempty"` // Context defines variables and data sources that can be used during rule execution. @@ -30,5 +31,5 @@ type ValidatingRule struct { Feedback []Feedback `json:"feedback,omitempty"` // Assert is used to validate matching resources. - Assert *Assert `json:"assert"` + Assert Assert `json:"assert"` } diff --git a/pkg/apis/policy/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/policy/v1alpha1/zz_generated.deepcopy.go index 12533500..e39d40ce 100644 --- a/pkg/apis/policy/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/policy/v1alpha1/zz_generated.deepcopy.go @@ -58,6 +58,10 @@ func (in *Assert) DeepCopy() *Assert { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Assertion) DeepCopyInto(out *Assertion) { *out = *in + if in.Message != nil { + in, out := &in.Message, &out.Message + *out = (*in).DeepCopy() + } if in.Engine != nil { in, out := &in.Engine, &out.Engine *out = new(Engine) @@ -258,11 +262,7 @@ func (in *ValidatingRule) DeepCopyInto(out *ValidatingRule) { *out = make([]Feedback, len(*in)) copy(*out, *in) } - if in.Assert != nil { - in, out := &in.Assert, &out.Assert - *out = new(Assert) - (*in).DeepCopyInto(*out) - } + in.Assert.DeepCopyInto(&out.Assert) return } diff --git a/pkg/engine/template/template.go b/pkg/engine/template/template.go index d09dd103..1a53abbb 100644 --- a/pkg/engine/template/template.go +++ b/pkg/engine/template/template.go @@ -2,9 +2,6 @@ package template import ( "context" - "fmt" - "regexp" - "strings" "github.com/google/cel-go/cel" "github.com/jmespath-community/go-jmespath/pkg/binding" @@ -12,26 +9,6 @@ import ( "github.com/jmespath-community/go-jmespath/pkg/parsing" ) -var variable = regexp.MustCompile(`{{(.*?)}}`) - -func String(ctx context.Context, in string, value any, bindings binding.Bindings, opts ...Option) string { - groups := variable.FindAllStringSubmatch(in, -1) - for _, group := range groups { - statement := strings.TrimSpace(group[1]) - result, err := ExecuteJP(ctx, statement, value, bindings, opts...) - if err != nil { - in = strings.ReplaceAll(in, group[0], fmt.Sprintf("ERR (%s - %s)", statement, err)) - } else if result == nil { - in = strings.ReplaceAll(in, group[0], fmt.Sprintf("ERR (%s not found)", statement)) - } else if result, ok := result.(string); !ok { - in = strings.ReplaceAll(in, group[0], fmt.Sprintf("ERR (%s not a string)", statement)) - } else { - in = strings.ReplaceAll(in, group[0], result) - } - } - return in -} - func ExecuteCEL(ctx context.Context, statement string, value any, bindings binding.Bindings) (any, error) { env, err := cel.NewEnv(cel.Variable("object", cel.AnyType)) if err != nil { diff --git a/pkg/matching/match.go b/pkg/matching/match.go index 9f75a6ce..7fd56b8d 100644 --- a/pkg/matching/match.go +++ b/pkg/matching/match.go @@ -40,8 +40,8 @@ func (r Results) Error() string { } // func MatchAssert(ctx context.Context, path *field.Path, match *v1alpha1.Assert, actual any, bindings binding.Bindings, opts ...template.Option) ([]error, error) { -func MatchAssert(ctx context.Context, path *field.Path, match *v1alpha1.Assert, actual any, bindings binding.Bindings, opts ...template.Option) ([]Result, error) { - if match == nil || (len(match.Any) == 0 && len(match.All) == 0) { +func MatchAssert(ctx context.Context, path *field.Path, match v1alpha1.Assert, actual any, bindings binding.Bindings, opts ...template.Option) ([]Result, error) { + if len(match.Any) == 0 && len(match.All) == 0 { return nil, field.Invalid(path, match, "an empty assert is not valid") } else { if len(match.Any) != 0 { @@ -64,8 +64,8 @@ func MatchAssert(ctx context.Context, path *field.Path, match *v1alpha1.Assert, fail := Result{ ErrorList: checkFails, } - if assertion.Message != "" { - fail.Message = template.String(ctx, assertion.Message, actual, bindings, opts...) + if assertion.Message != nil { + fail.Message = assertion.Message.Format(actual, bindings, opts...) } fails = append(fails, fail) } @@ -90,8 +90,8 @@ func MatchAssert(ctx context.Context, path *field.Path, match *v1alpha1.Assert, fail := Result{ ErrorList: checkFails, } - if assertion.Message != "" { - fail.Message = template.String(ctx, assertion.Message, actual, bindings, opts...) + if assertion.Message != nil { + fail.Message = assertion.Message.Format(actual, bindings, opts...) } fails = append(fails, fail) } diff --git a/pkg/message/message.go b/pkg/message/message.go new file mode 100644 index 00000000..bfeec4a3 --- /dev/null +++ b/pkg/message/message.go @@ -0,0 +1,76 @@ +package message + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/jmespath-community/go-jmespath/pkg/binding" + "github.com/jmespath-community/go-jmespath/pkg/parsing" + "github.com/kyverno/kyverno-json/pkg/engine/template" +) + +var variable = regexp.MustCompile(`{{(.*?)}}`) + +type Message interface { + Original() string + Format(any, binding.Bindings, ...template.Option) string +} + +type substitution = func(string, any, binding.Bindings, ...template.Option) string + +type message struct { + original string + substitutions []substitution +} + +func (m *message) Original() string { + return m.original +} + +func (m *message) Format(value any, bindings binding.Bindings, opts ...template.Option) string { + out := m.original + for _, substitution := range m.substitutions { + out = substitution(out, value, bindings, opts...) + } + return out +} + +func Parse(in string) *message { + groups := variable.FindAllStringSubmatch(in, -1) + var substitutions []func(string, any, binding.Bindings, ...template.Option) string + for _, group := range groups { + statement := strings.TrimSpace(group[1]) + parse := sync.OnceValues(func() (parsing.ASTNode, error) { + parser := parsing.NewParser() + return parser.Parse(statement) + }) + evaluate := func(value any, bindings binding.Bindings, opts ...template.Option) (any, error) { + ast, err := parse() + if err != nil { + return nil, err + } + return template.ExecuteAST(context.TODO(), ast, value, bindings, opts...) + } + placeholder := group[0] + substitutions = append(substitutions, func(out string, value any, bindings binding.Bindings, opts ...template.Option) string { + result, err := evaluate(value, bindings, opts...) + if err != nil { + out = strings.ReplaceAll(out, placeholder, fmt.Sprintf("ERR (%s - %s)", statement, err)) + } else if result == nil { + out = strings.ReplaceAll(out, placeholder, fmt.Sprintf("ERR (%s not found)", statement)) + } else if result, ok := result.(string); !ok { + out = strings.ReplaceAll(out, placeholder, fmt.Sprintf("ERR (%s not a string)", statement)) + } else { + out = strings.ReplaceAll(out, placeholder, result) + } + return out + }) + } + return &message{ + original: in, + substitutions: substitutions, + } +} diff --git a/pkg/policy/load_test.go b/pkg/policy/load_test.go index a6906d7a..80826316 100644 --- a/pkg/policy/load_test.go +++ b/pkg/policy/load_test.go @@ -70,7 +70,7 @@ func TestLoad(t *testing.T) { ), }, }, - Assert: &v1alpha1.Assert{ + Assert: v1alpha1.Assert{ All: []v1alpha1.Assertion{{ Check: v1alpha1.NewAssertionTree( map[string]any{ diff --git a/website/apis/config.yaml b/website/apis/config.yaml index 8b9b656f..7cee339b 100644 --- a/website/apis/config.yaml +++ b/website/apis/config.yaml @@ -3,6 +3,7 @@ hiddenMemberFields: - _value - _tree - _assertion +- _message externalPackages: - match: ^k8s\.io/apimachinery/pkg/apis/meta/v1\.Duration$ diff --git a/website/docs/apis/kyverno-json.v1alpha1.md b/website/docs/apis/kyverno-json.v1alpha1.md index 1f331eab..0b0859e1 100644 --- a/website/docs/apis/kyverno-json.v1alpha1.md +++ b/website/docs/apis/kyverno-json.v1alpha1.md @@ -78,8 +78,8 @@ auto_generated: true | Field | Type | Required | Inline | Description | |---|---|---|---|---| -| `message` | `string` | | |
Message is the message associated message.
| -| `engine` | [`Engine`](#json-kyverno-io-v1alpha1-Engine) | :white_check_mark: | |Engine defines the default engine to use when evaluating expressions.
| +| `message` | [`Message`](#json-kyverno-io-v1alpha1-Message) | | |Message is the message associated message.
| +| `engine` | [`Engine`](#json-kyverno-io-v1alpha1-Engine) | | |Engine defines the default engine to use when evaluating expressions.
| | `check` | [`AssertionTree`](#json-kyverno-io-v1alpha1-AssertionTree) | :white_check_mark: | |Check is the assertion check definition.
| ## `AssertionTree` {#json-kyverno-io-v1alpha1-AssertionTree} @@ -119,6 +119,9 @@ auto_generated: true - [ValidatingPolicySpec](#json-kyverno-io-v1alpha1-ValidatingPolicySpec) - [ValidatingRule](#json-kyverno-io-v1alpha1-ValidatingRule) +Engine defines the engine to use when evaluating expressions.
+ + ## `Feedback` {#json-kyverno-io-v1alpha1-Feedback} **Appears in:** @@ -147,6 +150,18 @@ auto_generated: true | `any` | [`[]AssertionTree`](#json-kyverno-io-v1alpha1-AssertionTree) | | |Any allows specifying assertion trees which will be ORed.
| | `all` | [`[]AssertionTree`](#json-kyverno-io-v1alpha1-AssertionTree) | | |All allows specifying assertion trees which will be ANDed.
| +## `Message` {#json-kyverno-io-v1alpha1-Message} + +**Appears in:** + +- [Assertion](#json-kyverno-io-v1alpha1-Assertion) + +Message stores a message template.
+ + +| Field | Type | Required | Inline | Description | +|---|---|---|---|---| + ## `ValidatingPolicySpec` {#json-kyverno-io-v1alpha1-ValidatingPolicySpec} **Appears in:** @@ -158,7 +173,7 @@ auto_generated: true | Field | Type | Required | Inline | Description | |---|---|---|---|---| -| `engine` | [`Engine`](#json-kyverno-io-v1alpha1-Engine) | :white_check_mark: | |Engine defines the default engine to use when evaluating expressions.
| +| `engine` | [`Engine`](#json-kyverno-io-v1alpha1-Engine) | | |Engine defines the default engine to use when evaluating expressions.
| | `rules` | [`[]ValidatingRule`](#json-kyverno-io-v1alpha1-ValidatingRule) | :white_check_mark: | |Rules is a list of ValidatingRule instances.
| ## `ValidatingRule` {#json-kyverno-io-v1alpha1-ValidatingRule} @@ -173,7 +188,7 @@ auto_generated: true | Field | Type | Required | Inline | Description | |---|---|---|---|---| | `name` | `string` | :white_check_mark: | |Name is a label to identify the rule, It must be unique within the policy.
| -| `engine` | [`Engine`](#json-kyverno-io-v1alpha1-Engine) | :white_check_mark: | |Engine defines the default engine to use when evaluating expressions.
| +| `engine` | [`Engine`](#json-kyverno-io-v1alpha1-Engine) | | |Engine defines the default engine to use when evaluating expressions.
| | `context` | [`[]ContextEntry`](#json-kyverno-io-v1alpha1-ContextEntry) | | |Context defines variables and data sources that can be used during rule execution.
| | `match` | [`Match`](#json-kyverno-io-v1alpha1-Match) | | |Match defines when this policy rule should be applied.
| | `exclude` | [`Match`](#json-kyverno-io-v1alpha1-Match) | | |Exclude defines when this policy rule should not be applied.
|