1
0
Fork 0
mirror of https://github.com/kyverno/kyverno.git synced 2025-03-24 08:36:46 +00:00

Cel HTTP Lib (#12241)

* Implement HTTP CEL lib for external API calls

Signed-off-by: Frank Jogeleit <frank.jogeleit@web.de>

* fix lint errors

Signed-off-by: Frank Jogeleit <frank.jogeleit@web.de>

---------

Signed-off-by: Frank Jogeleit <frank.jogeleit@web.de>
This commit is contained in:
Frank Jogeleit 2025-03-06 16:13:13 +01:00 committed by GitHub
parent 1cc5b7a3ab
commit da1fbd9475
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 483 additions and 1 deletions

111
pkg/cel/libs/http/http.go Normal file
View file

@ -0,0 +1,111 @@
package http
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/kyverno/kyverno/pkg/tracing"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
type HttpInterface interface {
Get(url string, headers map[string]string) (map[string]any, error)
Post(url string, data map[string]any, headers map[string]string) (map[string]any, error)
Client(caBundle string) (HttpInterface, error)
}
type ClientInterface interface {
Do(*http.Request) (*http.Response, error)
}
type HTTP struct {
HttpInterface
}
type HttpProvider struct {
client ClientInterface
}
func (r *HttpProvider) Get(url string, headers map[string]string) (map[string]any, error) {
req, err := http.NewRequestWithContext(context.TODO(), "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
for h, v := range headers {
req.Header.Add(h, v)
}
return r.executeRequest(r.client, req)
}
func (r *HttpProvider) Post(url string, data map[string]any, headers map[string]string) (map[string]any, error) {
body, err := buildRequestData(data)
if err != nil {
return nil, fmt.Errorf("failed to encode request data: %v", err)
}
req, err := http.NewRequestWithContext(context.TODO(), "POST", url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
for h, v := range headers {
req.Header.Add(h, v)
}
return r.executeRequest(r.client, req)
}
func (r *HttpProvider) executeRequest(client ClientInterface, req *http.Request) (map[string]any, error) {
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %s", resp.Status)
}
body := make(map[string]any)
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("Unable to decode JSON body %v", err)
}
return body, nil
}
func (r *HttpProvider) Client(caBundle string) (HttpInterface, error) {
if caBundle == "" {
return r, nil
}
caCertPool := x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM([]byte(caBundle)); !ok {
return nil, fmt.Errorf("failed to parse PEM CA bundle for APICall")
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
},
}
return &HttpProvider{
client: &http.Client{
Transport: tracing.Transport(transport, otelhttp.WithFilter(tracing.RequestFilterIsInSpan)),
},
}, nil
}
func buildRequestData(data map[string]any) (io.Reader, error) {
buffer := new(bytes.Buffer)
if err := json.NewEncoder(buffer).Encode(data); err != nil {
return nil, fmt.Errorf("failed to encode HTTP POST data %v: %w", data, err)
}
return buffer, nil
}

84
pkg/cel/libs/http/impl.go Normal file
View file

@ -0,0 +1,84 @@
package http
import (
"fmt"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/kyverno/kyverno/pkg/cel/utils"
)
type impl struct {
types.Adapter
}
func (c *impl) get_request_with_client_string(args ...ref.Val) ref.Val {
if request, err := utils.ConvertToNative[HTTP](args[0]); err != nil {
return types.WrapErr(err)
} else if url, err := utils.ConvertToNative[string](args[1]); err != nil {
return types.WrapErr(err)
} else if header, err := utils.ConvertToNative[map[string]string](args[2]); err != nil {
return types.WrapErr(err)
} else {
data, err := request.Get(url, header)
if err != nil {
return types.NewErr("request failed: %v", err)
}
return c.NativeToValue(data)
}
}
func (c *impl) get_request_string(request, url ref.Val) ref.Val {
return c.get_request_with_client_string(request, url, c.NativeToValue(make(map[string]string, 0)))
}
func (c *impl) get_request_with_headers_string(args ...ref.Val) ref.Val {
return c.get_request_with_client_string(args...)
}
func (c *impl) post_request_string_with_client(args ...ref.Val) ref.Val {
if request, err := utils.ConvertToNative[HTTP](args[0]); err != nil {
return types.WrapErr(err)
} else if url, err := utils.ConvertToNative[string](args[1]); err != nil {
return types.WrapErr(err)
} else if data, err := utils.ConvertToNative[map[string]any](args[2]); err != nil {
return types.WrapErr(err)
} else if header, err := utils.ConvertToNative[map[string]string](args[3]); err != nil {
return types.WrapErr(err)
} else {
data, err := request.Post(url, data, header)
if err != nil {
return types.NewErr("request failed: %v", err)
}
return c.NativeToValue(data)
}
}
func (c *impl) http_client_string(request, caBundle ref.Val) ref.Val {
fmt.Println("http_client_string")
if request, err := utils.ConvertToNative[HTTP](request); err != nil {
fmt.Println("conv request")
return types.WrapErr(err)
} else if caBundle, err := utils.ConvertToNative[string](caBundle); err != nil {
fmt.Println("conv ca bundle")
return types.WrapErr(err)
} else {
fmt.Println("call client")
caRequest, err := request.Client(caBundle)
if err != nil {
return types.NewErr("request failed: %v", err)
}
return c.NativeToValue(caRequest)
}
}
func (c *impl) post_request_string(args ...ref.Val) ref.Val {
return c.post_request_string_with_client(args[0], args[1], args[2], c.NativeToValue(make(map[string]string, 0)))
}
func (c *impl) post_request_with_headers_string(args ...ref.Val) ref.Val {
return c.post_request_string_with_client(args...)
}

View file

@ -0,0 +1,215 @@
package http
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/stretchr/testify/assert"
)
var pemExample = `-----BEGIN CERTIFICATE-----
MIICMzCCAZygAwIBAgIJALiPnVsvq8dsMA0GCSqGSIb3DQEBBQUAMFMxCzAJBgNV
BAYTAlVTMQwwCgYDVQQIEwNmb28xDDAKBgNVBAcTA2ZvbzEMMAoGA1UEChMDZm9v
MQwwCgYDVQQLEwNmb28xDDAKBgNVBAMTA2ZvbzAeFw0xMzAzMTkxNTQwMTlaFw0x
ODAzMTgxNTQwMTlaMFMxCzAJBgNVBAYTAlVTMQwwCgYDVQQIEwNmb28xDDAKBgNV
BAcTA2ZvbzEMMAoGA1UEChMDZm9vMQwwCgYDVQQLEwNmb28xDDAKBgNVBAMTA2Zv
bzCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAzdGfxi9CNbMf1UUcvDQh7MYB
OveIHyc0E0KIbhjK5FkCBU4CiZrbfHagaW7ZEcN0tt3EvpbOMxxc/ZQU2WN/s/wP
xph0pSfsfFsTKM4RhTWD2v4fgk+xZiKd1p0+L4hTtpwnEw0uXRVd0ki6muwV5y/P
+5FHUeldq+pgTcgzuK8CAwEAAaMPMA0wCwYDVR0PBAQDAgLkMA0GCSqGSIb3DQEB
BQUAA4GBAJiDAAtY0mQQeuxWdzLRzXmjvdSuL9GoyT3BF/jSnpxz5/58dba8pWen
v3pj4P3w5DoOso0rzkZy2jEsEitlVM2mLSbQpMM+MUVQCQoiG6W9xuCFuxSrwPIS
pAqEAuV4DNoxQKKWmhVv+J0ptMWD25Pnpxeq5sXzghfJnslJlQND
-----END CERTIFICATE-----`
type testClient struct {
doFunc func(req *http.Request) (*http.Response, error)
}
func (t testClient) Do(req *http.Request) (*http.Response, error) {
return t.doFunc(req)
}
func Test_impl_get_request(t *testing.T) {
opts := Lib()
base, err := cel.NewEnv(opts)
assert.NoError(t, err)
assert.NotNil(t, base)
options := []cel.EnvOption{
cel.Variable("http", HTTPType),
}
env, err := base.Extend(options...)
assert.NoError(t, err)
assert.NotNil(t, env)
ast, issues := env.Compile(`http.Get("http://localhost:8080")`)
fmt.Println(issues.String())
assert.Nil(t, issues)
assert.NotNil(t, ast)
prog, err := env.Program(ast)
assert.NoError(t, err)
assert.NotNil(t, prog)
out, _, err := prog.Eval(map[string]any{
"http": HTTP{&HttpProvider{
client: testClient{
doFunc: func(req *http.Request) (*http.Response, error) {
assert.Equal(t, req.URL.String(), "http://localhost:8080")
assert.Equal(t, req.Method, "GET")
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil
},
},
}},
})
assert.NoError(t, err)
body := out.Value().(map[string]any)
assert.Equal(t, body["body"], "ok")
}
func Test_impl_get_request_with_headers(t *testing.T) {
opts := Lib()
base, err := cel.NewEnv(opts)
assert.NoError(t, err)
assert.NotNil(t, base)
options := []cel.EnvOption{
cel.Variable("http", HTTPType),
}
env, err := base.Extend(options...)
assert.NoError(t, err)
assert.NotNil(t, env)
ast, issues := env.Compile(`http.Get("http://localhost:8080", {"Authorization": "Bearer token"})`)
fmt.Println(issues.String())
assert.Nil(t, issues)
assert.NotNil(t, ast)
prog, err := env.Program(ast)
assert.NoError(t, err)
assert.NotNil(t, prog)
out, _, err := prog.Eval(map[string]any{
"http": HTTP{&HttpProvider{
client: testClient{
doFunc: func(req *http.Request) (*http.Response, error) {
assert.Equal(t, req.URL.String(), "http://localhost:8080")
assert.Equal(t, req.Method, "GET")
assert.Equal(t, req.Header.Get("Authorization"), "Bearer token")
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil
},
},
}},
})
assert.NoError(t, err)
body := out.Value().(map[string]any)
assert.Equal(t, body["body"], "ok")
}
func Test_impl_post_request(t *testing.T) {
opts := Lib()
base, err := cel.NewEnv(opts)
assert.NoError(t, err)
assert.NotNil(t, base)
options := []cel.EnvOption{
cel.Variable("http", HTTPType),
}
env, err := base.Extend(options...)
assert.NoError(t, err)
assert.NotNil(t, env)
ast, issues := env.Compile(`http.Post("http://localhost:8080", {"key": "value"})`)
fmt.Println(issues.String())
assert.Nil(t, issues)
assert.NotNil(t, ast)
prog, err := env.Program(ast)
assert.NoError(t, err)
assert.NotNil(t, prog)
out, _, err := prog.Eval(map[string]any{
"http": HTTP{&HttpProvider{
client: testClient{
doFunc: func(req *http.Request) (*http.Response, error) {
assert.Equal(t, req.URL.String(), "http://localhost:8080")
assert.Equal(t, req.Method, "POST")
data := make(map[string]string, 0)
json.NewDecoder(req.Body).Decode(&data)
assert.Equal(t, data["key"], "value")
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil
},
},
}},
})
assert.NoError(t, err)
body := out.Value().(map[string]any)
assert.Equal(t, body["body"], "ok")
}
func Test_impl_post_request_with_headers(t *testing.T) {
opts := Lib()
base, err := cel.NewEnv(opts)
assert.NoError(t, err)
assert.NotNil(t, base)
options := []cel.EnvOption{
cel.Variable("http", HTTPType),
}
env, err := base.Extend(options...)
assert.NoError(t, err)
assert.NotNil(t, env)
ast, issues := env.Compile(`http.Post("http://localhost:8080", {"key": "value"}, {"Authorization": "Bearer token"})`)
fmt.Println(issues.String())
assert.Nil(t, issues)
assert.NotNil(t, ast)
prog, err := env.Program(ast)
assert.NoError(t, err)
assert.NotNil(t, prog)
out, _, err := prog.Eval(map[string]any{
"http": HTTP{&HttpProvider{
client: testClient{
doFunc: func(req *http.Request) (*http.Response, error) {
assert.Equal(t, req.URL.String(), "http://localhost:8080")
assert.Equal(t, req.Method, "POST")
assert.Equal(t, req.Header.Get("Authorization"), "Bearer token")
data := make(map[string]string, 0)
json.NewDecoder(req.Body).Decode(&data)
assert.Equal(t, data["key"], "value")
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil
},
},
}},
})
assert.NoError(t, err)
body := out.Value().(map[string]any)
assert.Equal(t, body["body"], "ok")
}
func Test_impl_http_client_string(t *testing.T) {
opts := Lib()
base, err := cel.NewEnv(opts)
assert.NoError(t, err)
assert.NotNil(t, base)
options := []cel.EnvOption{
cel.Variable("pem", types.StringType),
cel.Variable("http", HTTPType),
}
env, err := base.Extend(options...)
assert.NoError(t, err)
assert.NotNil(t, env)
ast, issues := env.Compile(`http.Client(pem)`)
fmt.Println(issues.String())
assert.Nil(t, issues)
assert.NotNil(t, ast)
prog, err := env.Program(ast)
assert.NoError(t, err)
assert.NotNil(t, prog)
out, _, err := prog.Eval(map[string]any{
"pem": pemExample,
"http": HTTP{&HttpProvider{}},
})
assert.NoError(t, err)
reqProvider := out.Value().(*HttpProvider)
assert.NotNil(t, reqProvider)
}

69
pkg/cel/libs/http/lib.go Normal file
View file

@ -0,0 +1,69 @@
package http
import (
"net/http"
"reflect"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/ext"
apiservercel "k8s.io/apiserver/pkg/cel"
)
const libraryName = "kyverno.http"
var HTTPType = types.DynType
type lib struct{}
func Lib() cel.EnvOption {
// create the cel lib env option
return cel.Lib(&lib{})
}
func Types() []*apiservercel.DeclType {
return []*apiservercel.DeclType{}
}
func (*lib) LibraryName() string {
return libraryName
}
func (c *lib) CompileOptions() []cel.EnvOption {
return []cel.EnvOption{
ext.NativeTypes(reflect.TypeFor[http.Request]()),
c.extendEnv,
}
}
func (*lib) ProgramOptions() []cel.ProgramOption {
return []cel.ProgramOption{}
}
func (c *lib) extendEnv(env *cel.Env) (*cel.Env, error) {
// create implementation, recording the envoy types aware adapter
impl := impl{
Adapter: env.CELTypeAdapter(),
}
// build our function overloads
libraryDecls := map[string][]cel.FunctionOpt{
"Get": {
cel.MemberOverload("get_request_string", []*cel.Type{HTTPType, types.StringType}, types.NewMapType(types.StringType, types.AnyType), cel.BinaryBinding(impl.get_request_string)),
cel.MemberOverload("get_request_with_headers_string", []*cel.Type{HTTPType, types.StringType, types.NewMapType(types.StringType, types.StringType)}, types.NewMapType(types.StringType, types.AnyType), cel.FunctionBinding(impl.get_request_with_headers_string)),
},
"Post": {
cel.MemberOverload("post_request_string", []*cel.Type{HTTPType, types.StringType, types.NewMapType(types.StringType, types.AnyType)}, types.NewMapType(types.StringType, types.AnyType), cel.FunctionBinding(impl.post_request_string)),
cel.MemberOverload("post_request__with_headers_string", []*cel.Type{HTTPType, types.StringType, types.NewMapType(types.StringType, types.AnyType), types.NewMapType(types.StringType, types.StringType)}, types.NewMapType(types.StringType, types.AnyType), cel.FunctionBinding(impl.post_request_with_headers_string)),
},
"Client": {
cel.MemberOverload("http_client_string", []*cel.Type{HTTPType, types.StringType}, HTTPType, cel.BinaryBinding(impl.http_client_string)),
},
}
// create env options corresponding to our function overloads
options := []cel.EnvOption{}
for name, overloads := range libraryDecls {
options = append(options, cel.Function(name, overloads...))
}
// extend environment with our function overloads
return env.Extend(options...)
}

View file

@ -9,6 +9,7 @@ import (
engine "github.com/kyverno/kyverno/pkg/cel"
vpolautogen "github.com/kyverno/kyverno/pkg/cel/autogen"
"github.com/kyverno/kyverno/pkg/cel/libs/context"
"github.com/kyverno/kyverno/pkg/cel/libs/http"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
apiservercel "k8s.io/apiserver/pkg/cel"
@ -16,6 +17,7 @@ import (
const (
ContextKey = "context"
HttpKey = "http"
NamespaceObjectKey = "namespaceObject"
ObjectKey = "object"
OldObjectKey = "oldObject"
@ -108,6 +110,7 @@ func (c *compiler) compileForKubernetes(policy *policiesv1alpha1.ValidatingPolic
declTypes = append(declTypes, context.Types()...)
options := []cel.EnvOption{
cel.Variable(ContextKey, context.ContextType),
cel.Variable(HttpKey, http.HTTPType),
cel.Variable(NamespaceObjectKey, NamespaceType.CelType()),
cel.Variable(ObjectKey, cel.DynType),
cel.Variable(OldObjectKey, cel.DynType),
@ -125,7 +128,7 @@ func (c *compiler) compileForKubernetes(policy *policiesv1alpha1.ValidatingPolic
panic(err)
}
options = append(options, declOptions...)
options = append(options, context.Lib())
options = append(options, context.Lib(), http.Lib())
// TODO: params, authorizer, authorizer.requestResource ?
env, err := base.Extend(options...)
if err != nil {