diff --git a/Makefile b/Makefile index b21b9ee..b0379c7 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,9 @@ serve: ## Serve static files. .PHONY: update-data update-data: ## Update the web/assets/data.json file. yq -ojson '.' examples.yaml > web/assets/data.json + yq -ojson '.' validating_examples.yaml > web/assets/validating_data.json yq -ojson -i '.versions.cel-go = "$(CEL_GO_VERSION)"' web/assets/data.json + yq -ojson -i '.versions.cel-go = "$(CEL_GO_VERSION)"' web/assets/validating_data.json .PHONY: addlicense addlicense: ## Add copyright license headers in source code files. diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 1396984..56494b9 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -24,15 +24,22 @@ import ( "gopkg.in/yaml.v3" "github.com/undistro/cel-playground/eval" + "github.com/undistro/cel-playground/k8s" ) func main() { - evalFunc := js.FuncOf(evalWrapper) - js.Global().Set("eval", evalFunc) - defer evalFunc.Release() + + defer addFunction("eval", evalWrapper).Release() + defer addFunction("vapEval", validatingAdmissionPolicyWrapper).Release() <-make(chan bool) } +func addFunction(name string, fn func(js.Value, []js.Value) any) js.Func { + function := js.FuncOf(fn) + js.Global().Set(name, function) + return function +} + // evalWrapper wraps the eval function with `syscall/js` parameters func evalWrapper(_ js.Value, args []js.Value) any { if len(args) < 2 { @@ -52,6 +59,22 @@ func evalWrapper(_ js.Value, args []js.Value) any { return response(output, nil) } +// ValidatingAdmissionPolicy functionality +func validatingAdmissionPolicyWrapper(_ js.Value, args []js.Value) any { + if len(args) < 3 { + return response("", errors.New("invalid arguments")) + } + policy := []byte(args[0].String()) + originalValue := []byte(args[1].String()) + updatedValue := []byte(args[2].String()) + + output, err := k8s.EvalValidatingAdmissionPolicy(policy, originalValue, updatedValue) + if err != nil { + return response("", err) + } + return response(output, nil) +} + func response(out string, err error) any { if err != nil { out = err.Error() diff --git a/k8s/evals.go b/k8s/evals.go new file mode 100644 index 0000000..8c27cbd --- /dev/null +++ b/k8s/evals.go @@ -0,0 +1,97 @@ +// Copyright 2023 Undistro Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s + +import ( + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/interpreter" +) + +type lazyVariableEval struct { + name string + ast *cel.Ast + val *ref.Val +} + +func (lve *lazyVariableEval) eval(env *cel.Env, activation interpreter.Activation) ref.Val { + val := lve.evalExpression(env, activation) + lve.val = &val + return val +} + +func (lve *lazyVariableEval) evalExpression(env *cel.Env, activation interpreter.Activation) ref.Val { + prog, err := env.Program(lve.ast, celProgramOptions...) + if err != nil { + return types.NewErr("Unexpected error parsing expression %s: %v", lve.name, err) + } + val, _, err := prog.Eval(activation) + if err != nil { + return types.NewErr("Unexpected error parsing expression %s: %v", lve.name, err) + } + return val +} + +type EvalVariable struct { + Name string `json:"name"` + Value any `json:"value"` + Error *string `json:"error"` +} + +type EvalValidation struct { + Result any `json:"result"` + Error *string `json:"error"` +} + +type EvalResponse struct { + Variables []EvalVariable `json:"variables,omitempty"` + Validations []EvalValidation `json:"validations,omitempty"` +} + +func getResults(val *ref.Val) (any, *string) { + value := (*val).Value() + if err, ok := value.(error); ok { + errResponse := err.Error() + return nil, &errResponse + } + return value, nil +} + +func generateResponse(variableLazyEvals map[string]*lazyVariableEval, validationEvals []ref.Val) *EvalResponse { + variables := []EvalVariable{} + for _, varLazyEval := range variableLazyEvals { + if varLazyEval.val != nil { + value, err := getResults(varLazyEval.val) + variables = append(variables, EvalVariable{ + Name: varLazyEval.name, + Value: value, + Error: err, + }) + } + } + validations := []EvalValidation{} + for _, validationEval := range validationEvals { + value, err := getResults(&validationEval) + validations = append(validations, EvalValidation{ + Result: value, + Error: err, + }) + } + return &EvalResponse{ + Variables: variables, + Validations: validations, + } +} diff --git a/k8s/extractcelinfo.go b/k8s/extractcelinfo.go new file mode 100644 index 0000000..3ca705c --- /dev/null +++ b/k8s/extractcelinfo.go @@ -0,0 +1,129 @@ +// Copyright 2023 Undistro Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s + +import ( + "fmt" + "reflect" + + "k8s.io/api/admissionregistration/v1alpha1" + "k8s.io/api/admissionregistration/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" +) + +type MatchCondition struct { + name string + expression string +} + +type CelVariableInfo struct { + name string + expression string +} + +type CelValidationInfo struct { + expression string + message string + messageExpression string +} + +type CelInformation struct { + name string + namespace string + variables []CelVariableInfo + validations []CelValidationInfo +} + +func deserialize(data []byte) (runtime.Object, error) { + decoder := scheme.Codecs.UniversalDeserializer() + + runtimeObject, _, err := decoder.Decode(data, nil, nil) + if err != nil { + return nil, err + } + + return runtimeObject, nil +} + +func extractCelInformation(policyInput []byte) (*CelInformation, error) { + if deser, err := deserialize(policyInput); err != nil { + return nil, fmt.Errorf("failed to decode ValidatingAdmissionPolicy: %w", err) + } else { + switch policy := deser.(type) { + case *v1alpha1.ValidatingAdmissionPolicy: + return extractV1Alpha1CelInformation(policy), nil + case *v1beta1.ValidatingAdmissionPolicy: + return extractV1Beta1CelInformation(policy), nil + default: + policyType := reflect.TypeOf(deser) + return nil, fmt.Errorf("expected ValidatingAdmissionPolicy, received %s", policyType.Kind()) + } + } +} + +func extractV1Alpha1CelInformation(policy *v1alpha1.ValidatingAdmissionPolicy) *CelInformation { + namespace := policy.ObjectMeta.GetNamespace() + name := policy.ObjectMeta.GetName() + + variables := []CelVariableInfo{} + for _, variable := range policy.Spec.Variables { + variables = append(variables, CelVariableInfo{ + name: variable.Name, + expression: variable.Expression, + }) + } + + validations := []CelValidationInfo{} + for _, validation := range policy.Spec.Validations { + + validations = append(validations, CelValidationInfo{ + expression: validation.Expression, + message: validation.Message, + messageExpression: validation.MessageExpression, + }) + } + + return &CelInformation{ + name, namespace, variables, validations, + } +} + +func extractV1Beta1CelInformation(policy *v1beta1.ValidatingAdmissionPolicy) *CelInformation { + namespace := policy.ObjectMeta.GetNamespace() + name := policy.ObjectMeta.GetName() + + variables := []CelVariableInfo{} + for _, variable := range policy.Spec.Variables { + variables = append(variables, CelVariableInfo{ + name: variable.Name, + expression: variable.Expression, + }) + } + + validations := []CelValidationInfo{} + for _, validation := range policy.Spec.Validations { + + validations = append(validations, CelValidationInfo{ + expression: validation.Expression, + message: validation.Message, + messageExpression: validation.MessageExpression, + }) + } + + return &CelInformation{ + name, namespace, variables, validations, + } +} diff --git a/k8s/testdata/policy1.yaml b/k8s/testdata/policy1.yaml new file mode 100644 index 0000000..e3aaae9 --- /dev/null +++ b/k8s/testdata/policy1.yaml @@ -0,0 +1,15 @@ +apiVersion: admissionregistration.k8s.io/v1alpha1 +kind: ValidatingAdmissionPolicy +metadata: + name: "force-ha-in-prod" +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + validations: + - expression: "object.spec.replicas >= 3" + message: "All production deployments should be HA with at least three replicas" diff --git a/k8s/testdata/policy2.yaml b/k8s/testdata/policy2.yaml new file mode 100644 index 0000000..16da94e --- /dev/null +++ b/k8s/testdata/policy2.yaml @@ -0,0 +1,14 @@ +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: ValidatingAdmissionPolicy +metadata: + name: "demo-policy.example.com" +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + validations: + - expression: "object.spec.replicas <= 5" diff --git a/k8s/testdata/updated1.yaml b/k8s/testdata/updated1.yaml new file mode 100644 index 0000000..25f30aa --- /dev/null +++ b/k8s/testdata/updated1.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2023-10-02T15:26:06Z" + generation: 1 + labels: + app: kubernetes-bootcamp + name: kubernetes-bootcamp + namespace: default + resourceVersion: "246826" + uid: dcdda63b-1611-467d-8927-43e3c73bc963 +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: kubernetes-bootcamp + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: kubernetes-bootcamp + spec: + containers: + - image: gcr.io/google-samples/kubernetes-bootcamp:v1 + imagePullPolicy: IfNotPresent + name: kubernetes-bootcamp + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 diff --git a/k8s/testdata/updated2.yaml b/k8s/testdata/updated2.yaml new file mode 100644 index 0000000..31f17e1 --- /dev/null +++ b/k8s/testdata/updated2.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2023-10-02T15:26:06Z" + generation: 1 + labels: + app: kubernetes-bootcamp + name: kubernetes-bootcamp + namespace: default + resourceVersion: "246826" + uid: dcdda63b-1611-467d-8927-43e3c73bc963 +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: kubernetes-bootcamp + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: kubernetes-bootcamp + spec: + containers: + - image: gcr.io/google-samples/kubernetes-bootcamp:v1 + imagePullPolicy: IfNotPresent + name: kubernetes-bootcamp + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + conditions: + - lastTransitionTime: "2023-10-02T15:26:08Z" + lastUpdateTime: "2023-10-02T15:26:08Z" + message: Deployment does not have minimum availability. + reason: MinimumReplicasUnavailable + status: "False" + type: Available + - lastTransitionTime: "2023-10-02T15:26:07Z" + lastUpdateTime: "2023-10-02T15:26:10Z" + message: ReplicaSet "kubernetes-bootcamp-855d5cc575" is progressing. + reason: ReplicaSetUpdated + status: "True" + type: Progressing + observedGeneration: 1 + replicas: 1 + unavailableReplicas: 1 + updatedReplicas: 1 \ No newline at end of file diff --git a/k8s/testdata/variable1 policy.yaml b/k8s/testdata/variable1 policy.yaml new file mode 100644 index 0000000..c1de371 --- /dev/null +++ b/k8s/testdata/variable1 policy.yaml @@ -0,0 +1,17 @@ +apiVersion: admissionregistration.k8s.io/v1alpha1 +kind: ValidatingAdmissionPolicy +metadata: + name: "test-variable-access" +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + variables: + - name: foo + expression: "'foo' in object.metadata.labels ? object.metadata.labels['foo'] : 'default'" + validations: + - expression: variables.foo == 'bar' diff --git a/k8s/testdata/variable1 updated.yaml b/k8s/testdata/variable1 updated.yaml new file mode 100644 index 0000000..31f17e1 --- /dev/null +++ b/k8s/testdata/variable1 updated.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2023-10-02T15:26:06Z" + generation: 1 + labels: + app: kubernetes-bootcamp + name: kubernetes-bootcamp + namespace: default + resourceVersion: "246826" + uid: dcdda63b-1611-467d-8927-43e3c73bc963 +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: kubernetes-bootcamp + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: kubernetes-bootcamp + spec: + containers: + - image: gcr.io/google-samples/kubernetes-bootcamp:v1 + imagePullPolicy: IfNotPresent + name: kubernetes-bootcamp + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + conditions: + - lastTransitionTime: "2023-10-02T15:26:08Z" + lastUpdateTime: "2023-10-02T15:26:08Z" + message: Deployment does not have minimum availability. + reason: MinimumReplicasUnavailable + status: "False" + type: Available + - lastTransitionTime: "2023-10-02T15:26:07Z" + lastUpdateTime: "2023-10-02T15:26:10Z" + message: ReplicaSet "kubernetes-bootcamp-855d5cc575" is progressing. + reason: ReplicaSetUpdated + status: "True" + type: Progressing + observedGeneration: 1 + replicas: 1 + unavailableReplicas: 1 + updatedReplicas: 1 \ No newline at end of file diff --git a/k8s/testdata/variable2 policy.yaml b/k8s/testdata/variable2 policy.yaml new file mode 100644 index 0000000..c1de371 --- /dev/null +++ b/k8s/testdata/variable2 policy.yaml @@ -0,0 +1,17 @@ +apiVersion: admissionregistration.k8s.io/v1alpha1 +kind: ValidatingAdmissionPolicy +metadata: + name: "test-variable-access" +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + variables: + - name: foo + expression: "'foo' in object.metadata.labels ? object.metadata.labels['foo'] : 'default'" + validations: + - expression: variables.foo == 'bar' diff --git a/k8s/testdata/variable2 updated.yaml b/k8s/testdata/variable2 updated.yaml new file mode 100644 index 0000000..4961baf --- /dev/null +++ b/k8s/testdata/variable2 updated.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2023-10-02T15:26:06Z" + generation: 1 + labels: + app: kubernetes-bootcamp + foo: bar + name: kubernetes-bootcamp + namespace: default + resourceVersion: "246826" + uid: dcdda63b-1611-467d-8927-43e3c73bc963 +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: kubernetes-bootcamp + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: kubernetes-bootcamp + spec: + containers: + - image: gcr.io/google-samples/kubernetes-bootcamp:v1 + imagePullPolicy: IfNotPresent + name: kubernetes-bootcamp + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + conditions: + - lastTransitionTime: "2023-10-02T15:26:08Z" + lastUpdateTime: "2023-10-02T15:26:08Z" + message: Deployment does not have minimum availability. + reason: MinimumReplicasUnavailable + status: "False" + type: Available + - lastTransitionTime: "2023-10-02T15:26:07Z" + lastUpdateTime: "2023-10-02T15:26:10Z" + message: ReplicaSet "kubernetes-bootcamp-855d5cc575" is progressing. + reason: ReplicaSetUpdated + status: "True" + type: Progressing + observedGeneration: 1 + replicas: 1 + unavailableReplicas: 1 + updatedReplicas: 1 \ No newline at end of file diff --git a/k8s/validatingadmissionpolicy.go b/k8s/validatingadmissionpolicy.go new file mode 100644 index 0000000..366912c --- /dev/null +++ b/k8s/validatingadmissionpolicy.go @@ -0,0 +1,135 @@ +// Copyright 2023 Undistro Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s + +import ( + "encoding/json" + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + "github.com/google/cel-go/interpreter" + "gopkg.in/yaml.v3" + k8s "k8s.io/apiserver/pkg/cel/library" +) + +var celEnvOptions = []cel.EnvOption{ + cel.EagerlyValidateDeclarations(true), + cel.DefaultUTCTimeZone(true), + ext.Strings(ext.StringsVersion(2)), + cel.CrossTypeNumericComparisons(true), + cel.OptionalTypes(), + k8s.URLs(), + k8s.Regex(), + k8s.Lists(), + k8s.Quantity(), +} + +var celProgramOptions = []cel.ProgramOption{ + cel.EvalOptions(cel.OptOptimize, cel.OptTrackCost), +} + +func EvalValidatingAdmissionPolicy(policyInput, origValueInput, updatedValueInput []byte) (string, error) { + celInfo, err := extractCelInformation(policyInput) + if err != nil { + return "", err + } + + var origValue map[string]any + if err := yaml.Unmarshal(origValueInput, &origValue); err != nil { + return "", fmt.Errorf("failed to decode input for the old resource value: %w", err) + } + + var updatedValue map[string]any + if err := yaml.Unmarshal(updatedValueInput, &updatedValue); err != nil { + return "", fmt.Errorf("failed to decode input for the new resource value: %w", err) + } + + celVars := []cel.EnvOption{} + inputData := map[string]any{} + + if origValue != nil { + celVars = append(celVars, cel.Variable("oldObject", cel.DynType)) + inputData["oldObject"] = origValue + } + + if updatedValue != nil { + celVars = append(celVars, cel.Variable("object", cel.DynType)) + inputData["object"] = updatedValue + } + + env, err := cel.NewEnv(append(celEnvOptions, celVars...)...) + if err != nil { + return "", fmt.Errorf("failed to create CEL env: %w", err) + } + + exprActivations, err := interpreter.NewActivation(inputData) + if err != nil { + return "", fmt.Errorf("failed to create CEL activations: %w", err) + } + + variableLazyEvals := map[string]*lazyVariableEval{} + + if len(celInfo.variables) > 0 { + for _, variable := range celInfo.variables { + ast, issues := env.Parse(variable.expression) + if issues.Err() != nil { + return "", fmt.Errorf("failed to parse expression for variable %s: %w", variable.name, err) + } + env, err = env.Extend(cel.Variable(variable.name, ast.OutputType())) + if err != nil { + return "", fmt.Errorf("could not append variable %s to CEL env: %w", variable.name, err) + } + variableLazyEval := lazyVariableEval{ + name: variable.name, + ast: ast, + } + variableLazyEvals[variable.name] = &variableLazyEval + name := "variables." + variable.name + inputData[name] = func() ref.Val { + return variableLazyEval.eval(env, exprActivations) + } + celVars = append(celVars, cel.Variable(name, ast.OutputType())) + } + } + + validationEvals := []ref.Val{} + + for _, validation := range celInfo.validations { + ast, issues := env.Parse(validation.expression) + if issues.Err() != nil { + return "", fmt.Errorf("failed to parse expression %s: %w", validation.expression, err) + } + var val ref.Val + if prog, err := env.Program(ast, celProgramOptions...); err != nil { + val = types.NewErr("Unexpected error parsing expression %s: %v", validation.expression, err) + } else if exprEval, _, err := prog.Eval(exprActivations); err != nil { + val = types.NewErr("Unexpected error parsing expression %s: %v", validation.expression, err) + } else { + val = exprEval + } + validationEvals = append(validationEvals, val) + } + + response := generateResponse(variableLazyEvals, validationEvals) + + out, err := json.Marshal(response) + if err != nil { + return "", nil + } + return string(out), nil +} diff --git a/k8s/validatingadmissionpolicy_test.go b/k8s/validatingadmissionpolicy_test.go new file mode 100644 index 0000000..0009be3 --- /dev/null +++ b/k8s/validatingadmissionpolicy_test.go @@ -0,0 +1,122 @@ +// Copyright 2023 Undistro Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s + +import ( + "embed" + "encoding/json" + "reflect" + "testing" +) + +//go:embed testdata +var testdata embed.FS + +func testfile(name string) string { + return "testdata/" + name +} + +func readTestData(policy, original, updated string) (policyData, originalData, updatedData []byte, err error) { + policyData, err = testdata.ReadFile(testfile(policy)) + if err == nil { + if original != "" { + originalData, err = testdata.ReadFile(testfile(original)) + } + if err == nil { + if updated != "" { + updatedData, err = testdata.ReadFile(testfile(updated)) + } + } + } + return +} + +func TestEval(t *testing.T) { + tests := []struct { + name string + policy string + orig string + updated string + expected EvalResponse + wantErr bool + }{ + { + name: "test an expression which should fail", + policy: "policy1.yaml", + orig: "", + updated: "updated1.yaml", + expected: EvalResponse{ + Validations: []EvalValidation{{Result: false}}, + }, + }, + { + name: "test an expression which should succeed", + policy: "policy2.yaml", + orig: "", + updated: "updated2.yaml", + expected: EvalResponse{ + Validations: []EvalValidation{{Result: true}}, + }, + }, + { + name: "test an expression with variables, expression should fail", + policy: "variable1 policy.yaml", + orig: "", + updated: "variable1 updated.yaml", + expected: EvalResponse{ + Variables: []EvalVariable{{ + Name: "foo", + Value: "default", + }}, + Validations: []EvalValidation{{Result: false}}, + }, + }, + { + name: "test an expression with variables, expression should succeed", + policy: "variable2 policy.yaml", + orig: "", + updated: "variable2 updated.yaml", + expected: EvalResponse{ + Variables: []EvalVariable{{ + Name: "foo", + Value: "bar", + }}, + Validations: []EvalValidation{{Result: true}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy, orig, updated, err := readTestData(tt.policy, tt.orig, tt.updated) + var results string + if err == nil { + results, err = EvalValidatingAdmissionPolicy(policy, orig, updated) + } + if err != nil { + if !tt.wantErr { + t.Errorf("Eval() error = %v, wantErr %v", err, tt.wantErr) + } + } else { + evalResponse := EvalResponse{} + if err := json.Unmarshal([]byte(results), &evalResponse); err != nil { + t.Errorf("Eval() error = %v", err) + } + if !reflect.DeepEqual(tt.expected, evalResponse) { + t.Errorf("Expected %v\n, received %v", tt.expected, evalResponse) + } + } + }) + } +} diff --git a/validating_examples.yaml b/validating_examples.yaml new file mode 100644 index 0000000..00f5040 --- /dev/null +++ b/validating_examples.yaml @@ -0,0 +1,73 @@ +# Copyright 2023 Undistro Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +examples: + - name: "default" + vap: | + apiVersion: admissionregistration.k8s.io/v1alpha1 + kind: ValidatingAdmissionPolicy + metadata: + name: "force-ha-in-prod" + spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["apps"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + validations: + - expression: "object.spec.replicas >= 3" + message: "All production deployments should be HA with at least three replicas" + + dataOriginal: | + + dataUpdated: | + apiVersion: apps/v1 + kind: Deployment + metadata: + labels: + app: kubernetes-bootcamp + name: kubernetes-bootcamp + namespace: default + spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: kubernetes-bootcamp + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: kubernetes-bootcamp + spec: + containers: + - image: gcr.io/google-samples/kubernetes-bootcamp:v1 + imagePullPolicy: IfNotPresent + name: kubernetes-bootcamp + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 diff --git a/web/assets/css/validating_styles.css b/web/assets/css/validating_styles.css new file mode 100644 index 0000000..ec953ed --- /dev/null +++ b/web/assets/css/validating_styles.css @@ -0,0 +1,459 @@ +/** + * Copyright 2023 Undistro Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + .tab { + background-color: #f0f0f0; + border: 1px solid #e3e3e3; + border-radius: 8px; + flex: none; + overflow: hidden; +} + +.tab-links { + background-color: inherit; + border: none; + cursor: pointer; + float: left; + outline: none; + padding: 8px 24px; +} + +.tabcontainer { + display: flex; + flex-direction: column; +} + +.tab-content-container { + display: flex; + flex: auto; +} + +.tab > button { + align-items: center; + display:flex; + gap: 8px; + height: 40px; + line-height: 18px; + padding: 8px 24px; + white-space: nowrap; +} + +.tab button:hover { + background-color: #cccccc; +} + +.tab button.button-primary:hover { + background-color: #925CD6; +} + +.tab button.active { + background-color: #aaaaaa; +} + +.tab-content { + display: none; + border: 1px solid #e3e3e3; + flex: auto; + border-radius: 8px; +} + +.tab-content > div { + flex: auto; + width: 100%; +} + +body { + font-family: 'Inter', sans-serif; + margin: 0; + padding: 0; + background-color: #FCFCFC; +} + +main { + padding: 24px; +} + +a { + color: #8447D1; + text-decoration: underline; +} + +.cel-logo { + height: 24px; +} + +.navbar { + background-color: white; + width: 100%; + height: 72px; + display: flex; + justify-content: space-between; + border-bottom: 1px solid rgba(0, 0, 0, 0.04); +} + +.navbar .title { + color: #243942; + font-weight: 500; + font-size: 1.125rem; +} + +.navbar .divider { + width: 1px; + height: 1rem; + background: rgba(0, 0, 0, 0.12); +} + +.navbar .logo { + display: flex; + align-items: center; + padding: 0 1rem; + padding: 1.5rem; + text-decoration: none; +} + +.navbar .logo>*+* { + margin-left: 1rem; +} + +.navbar span { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 16px; + letter-spacing: 0.01em; + color: rgba(0, 0, 0, 0.5); +} + +.nav-links { + display: flex; + flex-direction: row; + align-items: center; + justify-content: right; + gap: 16px; +} + +.nav-links > button { + height: 40px; + white-space: nowrap; + display:flex; + align-items: center; + gap: 8px; + padding: 8px 24px; +} + +.nav-divider { + content: ''; + width: 1px; + height: 24px; + border-radius: 2px; + background: rgba(0, 0, 0, 0.16); +} + +.share-url__container { + display: none; + height: 40px; + padding-left: 8px; + justify-content: flex-end; + align-items: center; + gap: 8px; + border-radius: 4px; + border: 1px solid #E6E6E6; + background: #FFF; + position: relative; +} + +.share-url__input { + display: flex; + width: 220px; + flex-direction: column; + color: rgba(0, 0, 0, 0.60); + font-size: 12px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.12px; + border:none; +} + +.share-url__tooltip { + color: #3EAA63; + font-size: 12px; + font-weight: 500; + line-height: 120%; + letter-spacing: 0.12px; + padding: 4px 8px; + position: absolute; + border-radius: 4px; + background: #E6EBE8; + right: 0; + bottom: -30px; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.share-url__input:focus { + outline: none; +} + +.share-url__copy { + display: flex; + height: 100%; + padding: 8px 12px; + justify-content: center; + align-items: center; + gap: 10px; + background: #F5F5F5; + border:none; + cursor: pointer; +} + +.nav-link { + display: flex; + justify-content: center; + align-items: center; + padding: 24px; + gap: 8px; + + background-color: rgba(132, 71, 209, 0.08); + text-decoration: none; + color: #8447D1; +} + +/* Containers */ +.editor-container { + display: flex; + flex-direction: row; + /* 3 * 24px = 72px for padding, 72px for navbar, 32px for footer */ + height: calc(100vh - (3 * 24px) - 72px - 32px); + gap: 24px; +} + +.editor-container>div { + flex: 1; +} + +.output-container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.output-container>* { + flex: 1; +} + +.editor { + display: flex; + flex-direction: column; + border-radius: 8px; + border: 1px solid #E3E3E3; + overflow: visible; +} + +.editor__header { + padding: 1rem; + border-bottom: 1px solid #E3E3E3; + display: flex; + justify-content: space-between; + align-items: center; +} + +.editor__header .title { + color: #34454C; + font-weight: 500; + font-size: 18px; + line-height: 40px; +} + +.editor__header .description { + color: #5D7B89; + font-weight: normal; + font-size: 14px; +} + +.editor>.editor__input { + height: 100%; + width: 100%; + font-size: 14px; +} + +.editor.editor--output { + background: #1D2E35; + color: white; +} + +.editor.editor--output .editor__header { + border-color: #2D4753; +} + +.editor>.editor__output { + background: #1D2E35; + resize: none; + height: 100%; + padding: 12px; + font-size: 14px; +} + +.editor>.editor__output::placeholder { + color: #E1E7EA; +} + + +/* Buttons and Inputs */ + +.button { + padding: 4px 24px; + border-radius: 4px; + background-color: #8447D1; + color: white; + transition: all 0.2s ease-in-out; + cursor: pointer; + border: none; + font-weight: 500; + font-size: 14px; + line-height: 24px; + height: 32px; +} + +.button:hover { + background: #925CD6; +} + +.button:disabled { + background: #8B8692; +} + + +/* Footer */ + +footer { + display: flex; + justify-content: space-between; + color: rgba(0, 0, 0, 0.5); + margin: 0 24px; + font-size: 14px; +} + +footer .version { + padding: 4px 8px; + background: #EBEBEB; + border-radius: 4px; +} + +/* Ace Custom */ + +.ace-clouds .ace_marker-layer .ace_active-line { + background: rgba(0, 0, 0, 0.07); +} + +.ace-clouds .ace_gutter-active-line { + background-color: #dcdcdc; +} + +.ace-clouds .ace_comment { + color: #848484 +} + +.ace-clouds .ace_string, +.ace-clouds .ace_keyword, +.ace-clouds .ace_meta.ace_tag { + color: #59328B; +} + + +.ace-clouds .ace_line, +.ace-clouds .ace_constant.ace_numeric, +.ace-clouds .ace_constant.ace_boolean { + color: #0E394C +} + +.tippy-tooltip { + background-color: white; + color: #3E525B; + padding: 0; +} + +.tippy-backdrop { + background-color: white; +} + +.tippy-content { + display: flex; + flex-direction: column; + border: 1px solid #E3E3E3; + overflow: hidden; + border-radius: 0.25rem; +} + +.example-item { + min-width: 280px; + background-color: white; + padding: 0.5rem 1rem; + border: none; + text-align: left; + line-height: 24px; + font-size: 14px; + color: #3E525B; + transition: all 0.2s; + cursor: pointer; + z-index: 1000; +} + +.example-item:hover { + background-color: rgba(132, 71, 209, 0.08); +} + +.examples__button { + padding: 8px 16px; + background-color: #FAFAFA; + border: 1px solid #E3E3E3; + border-radius: 4px; + color: #3E525B; + display: flex; + align-items: center; + gap: 8px; +} + +.examples__button > #example-name { + line-height: 24px; +} + +.nice-select > span.current { + display: block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +.nice-select .list { + max-height: 50vh; + overflow-y: scroll; + padding-bottom: 0 !important; + margin: 0; +} + +.nice-select .list::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.nice-select .list::-webkit-scrollbar-track { + background: #F5F5F5; +} + +.nice-select .list::-webkit-scrollbar-thumb { + background: #E3E3E3; + border-radius: 4px; +} diff --git a/web/assets/js/editor.js b/web/assets/js/editor.js index a757312..0f1f14f 100644 --- a/web/assets/js/editor.js +++ b/web/assets/js/editor.js @@ -23,6 +23,18 @@ const EDITOR_DEFAULTS = { theme: "ace/theme/clouds", mode: "ace/mode/yaml", }, + "vap-input": { + theme: "ace/theme/clouds", + mode: "ace/mode/javascript", + }, + "data-input-original": { + theme: "ace/theme/clouds", + mode: "ace/mode/yaml", + }, + "data-input-updated": { + theme: "ace/theme/clouds", + mode: "ace/mode/yaml", + }, }; class AceEditor { diff --git a/web/assets/js/validating.js b/web/assets/js/validating.js new file mode 100644 index 0000000..33df714 --- /dev/null +++ b/web/assets/js/validating.js @@ -0,0 +1,215 @@ +/** + * Copyright 2023 Undistro Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AceEditor } from "./editor.js"; + +const selectInstance = NiceSelect.bind(document.getElementById("examples")); + +// Add the following polyfill for Microsoft Edge 17/18 support: +// +// (see https://caniuse.com/#feat=textencoder) +if (!WebAssembly.instantiateStreaming) { + // polyfill + WebAssembly.instantiateStreaming = async (resp, importObject) => { + const source = await (await resp).arrayBuffer(); + return await WebAssembly.instantiate(source, importObject); + }; +} + +const celEditor = new AceEditor("vap-input"); +const dataEditorOriginal = new AceEditor("data-input-original"); +const dataEditorUpdated = new AceEditor("data-input-updated"); + +function run() { + const dataOriginal = dataEditorOriginal.getValue(); + const dataUpdated = dataEditorUpdated.getValue(); + const expression = celEditor.getValue(); + const output = document.getElementById("output"); + + output.value = "Evaluating..."; + const result = vapEval(expression, dataOriginal, dataUpdated); + + const { output: resultOutput, isError } = result; + output.value = `${resultOutput}`; + output.style.color = isError ? "red" : "white"; +} + + +function share() { + const dataOriginal = dataEditorOriginal.getValue(); + const dataUpdated = dataEditorUpdated.getValue(); + const expression = celEditor.getValue(); + + const obj = { + dataOriginal: dataOriginal, + dataUpdated: dataUpdated, + expression: expression, + }; + + const str = JSON.stringify(obj); + var compressed_uint8array = pako.gzip(str); + var b64encoded_string = btoa( + String.fromCharCode.apply(null, compressed_uint8array) + ); + + const url = new URL(window.location.href); + url.searchParams.set("content", b64encoded_string); + window.history.pushState({}, "", url.toString()); + + document.querySelector(".share-url__container").style.display = "flex"; + document.querySelector(".share-url__input").value = url.toString(); +} + +var urlParams = new URLSearchParams(window.location.search); +if (urlParams.has("content")) { + const content = urlParams.get("content"); + try { + const decodedUint8Array = new Uint8Array( + atob(content) + .split("") + .map(function (char) { + return char.charCodeAt(0); + }) + ); + + const decompressedData = pako.ungzip(decodedUint8Array, { to: "string" }); + if (!decompressedData) { + throw new Error("Invalid content parameter"); + } + const obj = JSON.parse(decompressedData); + celEditor.setValue(obj.expression, -1); + dataEditorOriginal.setValue(obj.dataOriginal, -1); + dataEditorUpdated.setValue(obj.dataUpdated, -1); + } catch (error) { + console.error(error); + } +} + +function copy() { + const copyText = document.querySelector(".share-url__input"); + copyText.select(); + copyText.setSelectionRange(0, 99999); + navigator.clipboard.writeText(copyText.value); + window.getSelection().removeAllRanges(); + + const tooltip = document.querySelector(".share-url__tooltip"); + tooltip.style.opacity = 1; + setTimeout(() => { + tooltip.style.opacity = 0; + }, 3000); +} + +function showTab(evt, tabName) { + var index; + var tabcontent = document.getElementsByClassName("tab-content"); + for (index = 0; index < tabcontent.length; index++) { + tabcontent[index].style.display = "none"; + } + + var tablinks = document.getElementsByClassName("tab-links"); + for (index = 0; index < tablinks.length; index++) { + tablinks[index].className = tablinks[index].className.replace(" active", ""); + } + + var elem = document.getElementById(tabName); + elem.style.flex = "auto"; + elem.style.display = "flex"; + evt.currentTarget.className += " active"; +} + +(async function loadAndRunGoWasm() { + const go = new Go(); + + const buffer = pako.ungzip( + await (await fetch("assets/main.wasm.gz")).arrayBuffer() + ); + + // A fetched response might be decompressed twice on Firefox. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=610679 + if (buffer[0] === 0x1f && buffer[1] === 0x8b) { + buffer = pako.ungzip(buffer); + } + + WebAssembly.instantiate(buffer, go.importObject) + .then((result) => { + go.run(result.instance); + document.getElementById("run").disabled = false; + document.getElementById("output").placeholder = + "Press 'Run' to evaluate the ValidatingAdmissionPolicy."; + }) + .catch((err) => { + console.error(err); + }); +})(); + +const runButton = document.getElementById("run"); +const shareButton = document.getElementById("share"); +const copyButton = document.getElementById("copy"); +const originalResourceButton = document.getElementById("original-resource-button"); +const updatedResourceButton = document.getElementById("updated-resource-button"); + +runButton.addEventListener("click", run); +shareButton.addEventListener("click", share); +copyButton.addEventListener("click", copy); +originalResourceButton.addEventListener("click", (event) => {showTab(event, "original-resource-tab")}) +updatedResourceButton.addEventListener("click", (event) => {showTab(event, "updated-resource-tab")}) +document.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.code === "Enter") { + run(); + } +}); + +updatedResourceButton.click() + +fetch("../assets/validating_data.json") + .then((response) => response.json()) + .then(({ examples, versions }) => { + + // Dynamically set the CEL Go version + document.getElementById("version").innerText = versions["cel-go"]; + + // Load the examples into the select element + const examplesList = document.getElementById("examples"); + examples.forEach((example) => { + const option = document.createElement("option"); + option.value = example.name; + option.innerText = example.name; + + if (example.name === "default") { + if (!urlParams.has("content")) { + celEditor.setValue(example.vap, -1); + dataEditorOriginal.setValue(example.dataOriginal, -1); + dataEditorUpdated.setValue(example.dataUpdated, -1); + } + } else { + examplesList.appendChild(option); + } + }); + + selectInstance.update(); + + examplesList.addEventListener("change", (event) => { + const example = examples.find( + (example) => example.name === event.target.value + ); + celEditor.setValue(example.vap, -1); + dataEditorOriginal.setValue(example.dataOriginal, -1); + dataEditorUpdated.setValue(example.dataUpdated, -1); + }); + }) + .catch((err) => { + console.error(err); + }); \ No newline at end of file diff --git a/web/assets/main.wasm.gz b/web/assets/main.wasm.gz index 295f48c..a4d4e8a 100755 Binary files a/web/assets/main.wasm.gz and b/web/assets/main.wasm.gz differ diff --git a/web/assets/validating_data.json b/web/assets/validating_data.json new file mode 100644 index 0000000..79ea859 --- /dev/null +++ b/web/assets/validating_data.json @@ -0,0 +1,13 @@ +{ + "examples": [ + { + "name": "default", + "vap": "apiVersion: admissionregistration.k8s.io/v1alpha1\nkind: ValidatingAdmissionPolicy\nmetadata:\n name: \"force-ha-in-prod\"\nspec:\n failurePolicy: Fail\n matchConstraints:\n resourceRules:\n - apiGroups: [\"apps\"]\n apiVersions: [\"v1\"]\n operations: [\"CREATE\", \"UPDATE\"]\n resources: [\"deployments\"]\n validations:\n - expression: \"object.spec.replicas >= 3\"\n message: \"All production deployments should be HA with at least three replicas\"\n", + "dataOriginal": "", + "dataUpdated": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n labels:\n app: kubernetes-bootcamp\n name: kubernetes-bootcamp\n namespace: default\nspec:\n progressDeadlineSeconds: 600\n replicas: 1\n revisionHistoryLimit: 10\n selector:\n matchLabels:\n app: kubernetes-bootcamp\n strategy:\n rollingUpdate:\n maxSurge: 25%\n maxUnavailable: 25%\n type: RollingUpdate\n template:\n metadata:\n creationTimestamp: null\n labels:\n app: kubernetes-bootcamp\n spec:\n containers:\n - image: gcr.io/google-samples/kubernetes-bootcamp:v1\n imagePullPolicy: IfNotPresent\n name: kubernetes-bootcamp\n resources: {}\n terminationMessagePath: /dev/termination-log\n terminationMessagePolicy: File\n dnsPolicy: ClusterFirst\n restartPolicy: Always\n schedulerName: default-scheduler\n securityContext: {}\n terminationGracePeriodSeconds: 30\n" + } + ], + "versions": { + "cel-go": "v0.16.0" + } +} diff --git a/web/validating.html b/web/validating.html new file mode 100644 index 0000000..0b6ea33 --- /dev/null +++ b/web/validating.html @@ -0,0 +1,157 @@ + + + + + +
+ +