mirror of
https://github.com/kyverno/kyverno.git
synced 2025-03-13 19:28:55 +00:00
Support @
for mutate targets (#3998)
Signed-off-by: ShutingZhao <shuting@nirmata.com>
This commit is contained in:
parent
c9f8a68d8a
commit
85b486eb27
5 changed files with 384 additions and 179 deletions
|
@ -49,6 +49,9 @@ type Interface interface {
|
|||
// AddOldResource merges resource json under request.oldObject
|
||||
AddOldResource(data map[string]interface{}) error
|
||||
|
||||
// AddTargetResource merges resource json under target
|
||||
AddTargetResource(data map[string]interface{}) error
|
||||
|
||||
// AddUserInfo merges userInfo json under kyverno.userInfo
|
||||
AddUserInfo(userInfo kyvernov1beta1.RequestInfo) error
|
||||
|
||||
|
@ -165,6 +168,11 @@ func (ctx *context) AddOldResource(data map[string]interface{}) error {
|
|||
return addToContext(ctx, data, "request", "oldObject")
|
||||
}
|
||||
|
||||
// AddTargetResource adds data at path: target
|
||||
func (ctx *context) AddTargetResource(data map[string]interface{}) error {
|
||||
return addToContext(ctx, data, "target")
|
||||
}
|
||||
|
||||
// AddUserInfo adds userInfo at path request.userInfo
|
||||
func (ctx *context) AddUserInfo(userRequestInfo kyvernov1beta1.RequestInfo) error {
|
||||
return addToContext(ctx, userRequestInfo, "request")
|
||||
|
|
|
@ -97,6 +97,14 @@ func Mutate(policyContext *PolicyContext) (resp *response.EngineResponse) {
|
|||
continue
|
||||
}
|
||||
|
||||
if !policyContext.AdmissionOperation && rule.IsMutateExisting() {
|
||||
policyContext := policyContext.Copy()
|
||||
if err := policyContext.JSONContext.AddTargetResource(patchedResource.Object); err != nil {
|
||||
log.Log.Error(err, "failed to add target resource to the context")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.V(4).Info("apply rule to resource", "rule", rule.Name, "resource namespace", patchedResource.GetNamespace(), "resource name", patchedResource.GetName())
|
||||
var ruleResp *response.RuleResponse
|
||||
if rule.Mutation.ForEachMutation != nil {
|
||||
|
|
|
@ -1035,134 +1035,220 @@ func Test_mutate_existing_resources(t *testing.T) {
|
|||
name string
|
||||
policy []byte
|
||||
trigger []byte
|
||||
target []byte
|
||||
targets [][]byte
|
||||
targetList string
|
||||
patches []string
|
||||
}{
|
||||
{
|
||||
name: "test-different-trigger-target",
|
||||
policy: []byte(`{
|
||||
"apiVersion": "kyverno.io/v1",
|
||||
"kind": "ClusterPolicy",
|
||||
"metadata": {
|
||||
"name": "test-post-mutation"
|
||||
},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"name": "mutate-deploy-on-configmap-update",
|
||||
"match": {
|
||||
"any": [
|
||||
{
|
||||
"resources": {
|
||||
"kinds": [
|
||||
"ConfigMap"
|
||||
],
|
||||
"names": [
|
||||
"dictionary"
|
||||
],
|
||||
"namespaces": [
|
||||
"staging"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"preconditions": {
|
||||
"any": [
|
||||
{
|
||||
"key": "{{ request.object.data.foo }}",
|
||||
"operator": "Equals",
|
||||
"value": "bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutate": {
|
||||
"targets": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Deployment",
|
||||
"name": "example-A",
|
||||
"namespace": "staging"
|
||||
}
|
||||
],
|
||||
"patchStrategicMerge": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"foo": "bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`),
|
||||
"apiVersion": "kyverno.io/v1",
|
||||
"kind": "ClusterPolicy",
|
||||
"metadata": {
|
||||
"name": "test-post-mutation"
|
||||
},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"name": "mutate-deploy-on-configmap-update",
|
||||
"match": {
|
||||
"any": [
|
||||
{
|
||||
"resources": {
|
||||
"kinds": [
|
||||
"ConfigMap"
|
||||
],
|
||||
"names": [
|
||||
"dictionary"
|
||||
],
|
||||
"namespaces": [
|
||||
"staging"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"preconditions": {
|
||||
"any": [
|
||||
{
|
||||
"key": "{{ request.object.data.foo }}",
|
||||
"operator": "Equals",
|
||||
"value": "bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutate": {
|
||||
"targets": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Deployment",
|
||||
"name": "example-A",
|
||||
"namespace": "staging"
|
||||
}
|
||||
],
|
||||
"patchStrategicMerge": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"foo": "bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`),
|
||||
trigger: []byte(`{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
}
|
||||
}`),
|
||||
target: []byte(`{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": "example-A",
|
||||
"namespace": "staging",
|
||||
"labels": {
|
||||
"app": "nginx"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"replicas": 1,
|
||||
"selector": {
|
||||
"matchLabels": {
|
||||
"app": "nginx"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"app": "nginx"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"name": "nginx",
|
||||
"image": "nginx:1.14.2",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
}
|
||||
}`),
|
||||
targets: [][]byte{[]byte(`{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": "example-A",
|
||||
"namespace": "staging",
|
||||
"labels": {
|
||||
"app": "nginx"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"replicas": 1,
|
||||
"selector": {
|
||||
"matchLabels": {
|
||||
"app": "nginx"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"app": "nginx"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"name": "nginx",
|
||||
"image": "nginx:1.14.2",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)},
|
||||
targetList: "DeploymentList",
|
||||
patches: []string{`{"op":"add","path":"/metadata/labels/foo","value":"bar"}`},
|
||||
},
|
||||
{
|
||||
name: "test-same-trigger-target",
|
||||
policy: []byte(`{
|
||||
"apiVersion": "kyverno.io/v1",
|
||||
"kind": "ClusterPolicy",
|
||||
"metadata": {
|
||||
"name": "test-post-mutation"
|
||||
},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"name": "mutate-deploy-on-configmap-update",
|
||||
"match": {
|
||||
"any": [
|
||||
{
|
||||
"resources": {
|
||||
"kinds": [
|
||||
"ConfigMap"
|
||||
],
|
||||
"names": [
|
||||
"dictionary"
|
||||
],
|
||||
"namespaces": [
|
||||
"staging"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"preconditions": {
|
||||
"any": [
|
||||
{
|
||||
"key": "{{ request.object.data.foo }}",
|
||||
"operator": "Equals",
|
||||
"value": "bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutate": {
|
||||
"targets": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
}
|
||||
],
|
||||
"patchStrategicMerge": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"foo": "bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`),
|
||||
trigger: []byte(`{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
}
|
||||
}`),
|
||||
targets: [][]byte{[]byte(`{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
}
|
||||
}`)},
|
||||
targetList: "ComfigMapList",
|
||||
patches: []string{`{"op":"add","path":"/metadata/labels","value":{"foo":"bar"}}`},
|
||||
},
|
||||
{
|
||||
name: "test-in-place-variable",
|
||||
policy: []byte(`
|
||||
{
|
||||
"apiVersion": "kyverno.io/v1",
|
||||
"kind": "ClusterPolicy",
|
||||
"metadata": {
|
||||
"name": "test-post-mutation"
|
||||
"name": "sync-cms"
|
||||
},
|
||||
"spec": {
|
||||
"mutateExistingOnPolicyUpdate": false,
|
||||
"rules": [
|
||||
{
|
||||
"name": "mutate-deploy-on-configmap-update",
|
||||
"name": "concat-cm",
|
||||
"match": {
|
||||
"any": [
|
||||
{
|
||||
|
@ -1171,72 +1257,168 @@ func Test_mutate_existing_resources(t *testing.T) {
|
|||
"ConfigMap"
|
||||
],
|
||||
"names": [
|
||||
"dictionary"
|
||||
"cmone"
|
||||
],
|
||||
"namespaces": [
|
||||
"staging"
|
||||
"foo"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"preconditions": {
|
||||
"any": [
|
||||
{
|
||||
"key": "{{ request.object.data.foo }}",
|
||||
"operator": "Equals",
|
||||
"value": "bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutate": {
|
||||
"targets": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
"name": "cmtwo",
|
||||
"namespace": "bar"
|
||||
}
|
||||
],
|
||||
"patchStrategicMerge": {
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"foo": "bar"
|
||||
}
|
||||
"data": {
|
||||
"keytwo": "{{@}}-{{request.object.data.keyone}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`),
|
||||
trigger: []byte(`{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
}
|
||||
}`),
|
||||
target: []byte(`{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "dictionary",
|
||||
"namespace": "staging"
|
||||
`),
|
||||
trigger: []byte(`
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"keyone": "valueone"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "cmone",
|
||||
"namespace": "foo"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
`),
|
||||
targets: [][]byte{[]byte(`
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"keytwo": "valuetwo"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "cmtwo",
|
||||
"namespace": "bar"
|
||||
}
|
||||
}
|
||||
`)},
|
||||
targetList: "ComfigMapList",
|
||||
patches: []string{`{"op":"add","path":"/metadata/labels","value":{"foo":"bar"}}`},
|
||||
patches: []string{`{"op":"replace","path":"/data/keytwo","value":"valuetwo-valueone"}`},
|
||||
},
|
||||
{
|
||||
name: "test-in-place-variable",
|
||||
policy: []byte(`
|
||||
{
|
||||
"apiVersion": "kyverno.io/v1",
|
||||
"kind": "ClusterPolicy",
|
||||
"metadata": {
|
||||
"name": "sync-cms"
|
||||
},
|
||||
"spec": {
|
||||
"mutateExistingOnPolicyUpdate": false,
|
||||
"rules": [
|
||||
{
|
||||
"name": "concat-cm",
|
||||
"match": {
|
||||
"any": [
|
||||
{
|
||||
"resources": {
|
||||
"kinds": [
|
||||
"ConfigMap"
|
||||
],
|
||||
"names": [
|
||||
"cmone"
|
||||
],
|
||||
"namespaces": [
|
||||
"foo"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutate": {
|
||||
"targets": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"name": "cmtwo",
|
||||
"namespace": "bar"
|
||||
},
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"name": "cmthree",
|
||||
"namespace": "bar"
|
||||
}
|
||||
],
|
||||
"patchStrategicMerge": {
|
||||
"data": {
|
||||
"key": "{{@}}-{{request.object.data.keyone}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`),
|
||||
trigger: []byte(`
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"keyone": "valueone"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "cmone",
|
||||
"namespace": "foo"
|
||||
}
|
||||
}
|
||||
`),
|
||||
targets: [][]byte{
|
||||
[]byte(`
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"key": "valuetwo"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "cmtwo",
|
||||
"namespace": "bar"
|
||||
}
|
||||
}
|
||||
`),
|
||||
[]byte(`
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"data": {
|
||||
"key": "valuethree"
|
||||
},
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "cmthree",
|
||||
"namespace": "bar"
|
||||
}
|
||||
}
|
||||
`),
|
||||
},
|
||||
targetList: "ComfigMapList",
|
||||
patches: []string{`{"op":"replace","path":"/data/key","value":"valuetwo-valueone"}`, `{"op":"replace","path":"/data/key","value":"valuethree-valueone"}`},
|
||||
},
|
||||
}
|
||||
|
||||
var policyContext *PolicyContext
|
||||
for _, test := range tests {
|
||||
var policy kyverno.ClusterPolicy
|
||||
err := json.Unmarshal(test.policy, &policy)
|
||||
|
@ -1245,38 +1427,41 @@ func Test_mutate_existing_resources(t *testing.T) {
|
|||
trigger, err := utils.ConvertToUnstructured(test.trigger)
|
||||
assert.NilError(t, err)
|
||||
|
||||
target, err := utils.ConvertToUnstructured(test.target)
|
||||
assert.NilError(t, err)
|
||||
for _, target := range test.targets {
|
||||
target, err := utils.ConvertToUnstructured(target)
|
||||
assert.NilError(t, err)
|
||||
|
||||
ctx := context.NewContext()
|
||||
err = ctx.AddResource(trigger.Object)
|
||||
assert.NilError(t, err)
|
||||
ctx := context.NewContext()
|
||||
err = ctx.AddResource(trigger.Object)
|
||||
assert.NilError(t, err)
|
||||
|
||||
gvrToListKind := map[schema.GroupVersionResource]string{
|
||||
{Group: target.GroupVersionKind().Group, Version: target.GroupVersionKind().Version, Resource: target.GroupVersionKind().Kind}: test.targetList,
|
||||
gvrToListKind := map[schema.GroupVersionResource]string{
|
||||
{Group: target.GroupVersionKind().Group, Version: target.GroupVersionKind().Version, Resource: target.GroupVersionKind().Kind}: test.targetList,
|
||||
}
|
||||
|
||||
objects := []runtime.Object{target}
|
||||
scheme := runtime.NewScheme()
|
||||
dclient, err := client.NewMockClient(scheme, gvrToListKind, objects...)
|
||||
assert.NilError(t, err)
|
||||
dclient.SetDiscovery(client.NewFakeDiscoveryClient(nil))
|
||||
|
||||
_, err = dclient.GetResource(target.GetAPIVersion(), target.GetKind(), target.GetNamespace(), target.GetName())
|
||||
assert.NilError(t, err)
|
||||
|
||||
policyContext = &PolicyContext{
|
||||
Client: dclient,
|
||||
Policy: &policy,
|
||||
JSONContext: ctx,
|
||||
NewResource: *trigger,
|
||||
}
|
||||
}
|
||||
|
||||
objects := []runtime.Object{target}
|
||||
scheme := runtime.NewScheme()
|
||||
dclient, err := client.NewMockClient(scheme, gvrToListKind, objects...)
|
||||
assert.NilError(t, err)
|
||||
dclient.SetDiscovery(client.NewFakeDiscoveryClient(nil))
|
||||
|
||||
_, err = dclient.GetResource(target.GetAPIVersion(), target.GetKind(), target.GetNamespace(), target.GetName())
|
||||
assert.NilError(t, err)
|
||||
|
||||
policyContext := &PolicyContext{
|
||||
Client: dclient,
|
||||
Policy: &policy,
|
||||
JSONContext: ctx,
|
||||
NewResource: *trigger,
|
||||
}
|
||||
|
||||
er := Mutate(policyContext)
|
||||
|
||||
for _, rr := range er.PolicyResponse.Rules {
|
||||
assert.Equal(t, test.patches[0], string(rr.Patches[0]))
|
||||
assert.Equal(t, rr.Status, response.RuleStatusPass, rr.Status)
|
||||
for i, p := range rr.Patches {
|
||||
assert.Equal(t, test.patches[i], string(p), "test %s failed:\nGot %s\nExpected: %s", test.name, rr.Patches[i], test.patches[i])
|
||||
assert.Equal(t, rr.Status, response.RuleStatusPass, rr.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -285,7 +285,7 @@ func substituteReferencesIfAny(log logr.Logger) jsonUtils.Action {
|
|||
}
|
||||
|
||||
if resolvedReference == nil {
|
||||
return data.Element, fmt.Errorf("failed to resolve %v at path %s: %v", v, data.Path, err)
|
||||
return data.Element, fmt.Errorf("got nil resolved variable %v at path %s: %v", v, data.Path, err)
|
||||
}
|
||||
|
||||
log.V(3).Info("reference resolved", "reference", v, "value", resolvedReference, "path", data.Path)
|
||||
|
@ -348,12 +348,16 @@ func substituteVariablesIfAny(log logr.Logger, ctx context.EvalInterface, vr Var
|
|||
variable := replaceBracesAndTrimSpaces(v)
|
||||
|
||||
if variable == "@" {
|
||||
pathPrefix := "target"
|
||||
if _, err := ctx.Query("target"); err != nil {
|
||||
pathPrefix = "request.object"
|
||||
}
|
||||
path := getJMESPath(data.Path)
|
||||
var val string
|
||||
if strings.HasPrefix(path, "[") {
|
||||
val = fmt.Sprintf("request.object%s", path)
|
||||
val = fmt.Sprintf("%s%s", pathPrefix, path)
|
||||
} else {
|
||||
val = fmt.Sprintf("request.object.%s", path)
|
||||
val = fmt.Sprintf("%s.%s", pathPrefix, path)
|
||||
}
|
||||
|
||||
variable = strings.Replace(variable, "@", val, -1)
|
||||
|
|
|
@ -31,9 +31,9 @@ import (
|
|||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
)
|
||||
|
||||
var allowedVariables = regexp.MustCompile(`request\.|serviceAccountName|serviceAccountNamespace|element|elementIndex|@|images\.|([a-z_0-9]+\()[^{}]`)
|
||||
var allowedVariables = regexp.MustCompile(`request\.|serviceAccountName|serviceAccountNamespace|element|elementIndex|@|images\.|target\.|([a-z_0-9]+\()[^{}]`)
|
||||
|
||||
var allowedVariablesBackground = regexp.MustCompile(`request\.|element|elementIndex|@|images\.|([a-z_0-9]+\()[^{}]`)
|
||||
var allowedVariablesBackground = regexp.MustCompile(`request\.|element|elementIndex|@|images\.|target\.|([a-z_0-9]+\()[^{}]`)
|
||||
|
||||
// wildCardAllowedVariables represents regex for the allowed fields in wildcards
|
||||
var wildCardAllowedVariables = regexp.MustCompile(`\{\{\s*(request\.|serviceAccountName|serviceAccountNamespace)[^{}]*\}\}`)
|
||||
|
@ -405,7 +405,7 @@ func ValidateOnPolicyUpdate(p kyvernov1.PolicyInterface, onPolicyUpdate bool) er
|
|||
}
|
||||
|
||||
if err := hasInvalidVariables(p, onPolicyUpdate); err != nil {
|
||||
return fmt.Errorf("policy contains invalid variables: %s", err.Error())
|
||||
return fmt.Errorf("update event, policy contains invalid variables: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := containsUserVariables(p, vars); err != nil {
|
||||
|
|
Loading…
Add table
Reference in a new issue