Merge pull request #79201 from damemi/kubectl-rbac
Move pkg/kubectl/util/rbac to staging Kubernetes-commit: 70b1c436576d8fe778e7f8cf8975ce89b8ade9f0
This commit is contained in:
commit
465b117514
2
go.sum
2
go.sum
|
|
@ -81,7 +81,6 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
|
|||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
|
|
@ -112,7 +111,6 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
|
|||
gopkg.in/inf.v0 v0.9.0 h1:3zYtXIO92bvsdS3ggAdA8Gb4Azj0YU+TVY1uGYNFA8o=
|
||||
gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright 2017 The Kubernetes 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 hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
// ConfigMapHash returns a hash of the ConfigMap.
|
||||
// The Data, Kind, and Name are taken into account.
|
||||
func ConfigMapHash(cm *v1.ConfigMap) (string, error) {
|
||||
encoded, err := encodeConfigMap(cm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h, err := encodeHash(hash(encoded))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// SecretHash returns a hash of the Secret.
|
||||
// The Data, Kind, Name, and Type are taken into account.
|
||||
func SecretHash(sec *v1.Secret) (string, error) {
|
||||
encoded, err := encodeSecret(sec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h, err := encodeHash(hash(encoded))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// encodeConfigMap encodes a ConfigMap.
|
||||
// Data, Kind, and Name are taken into account.
|
||||
func encodeConfigMap(cm *v1.ConfigMap) (string, error) {
|
||||
// json.Marshal sorts the keys in a stable order in the encoding
|
||||
m := map[string]interface{}{"kind": "ConfigMap", "name": cm.Name, "data": cm.Data}
|
||||
if len(cm.BinaryData) > 0 {
|
||||
m["binaryData"] = cm.BinaryData
|
||||
}
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// encodeSecret encodes a Secret.
|
||||
// Data, Kind, Name, and Type are taken into account.
|
||||
func encodeSecret(sec *v1.Secret) (string, error) {
|
||||
// json.Marshal sorts the keys in a stable order in the encoding
|
||||
data, err := json.Marshal(map[string]interface{}{"kind": "Secret", "type": sec.Type, "name": sec.Name, "data": sec.Data})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// encodeHash extracts the first 40 bits of the hash from the hex string
|
||||
// (1 hex char represents 4 bits), and then maps vowels and vowel-like hex
|
||||
// characters to consonants to prevent bad words from being formed (the theory
|
||||
// is that no vowels makes it really hard to make bad words). Since the string
|
||||
// is hex, the only vowels it can contain are 'a' and 'e'.
|
||||
// We picked some arbitrary consonants to map to from the same character set as GenerateName.
|
||||
// See: https://github.com/kubernetes/apimachinery/blob/dc1f89aff9a7509782bde3b68824c8043a3e58cc/pkg/util/rand/rand.go#L75
|
||||
// If the hex string contains fewer than ten characters, returns an error.
|
||||
func encodeHash(hex string) (string, error) {
|
||||
if len(hex) < 10 {
|
||||
return "", fmt.Errorf("the hex string must contain at least 10 characters")
|
||||
}
|
||||
enc := []rune(hex[:10])
|
||||
for i := range enc {
|
||||
switch enc[i] {
|
||||
case '0':
|
||||
enc[i] = 'g'
|
||||
case '1':
|
||||
enc[i] = 'h'
|
||||
case '3':
|
||||
enc[i] = 'k'
|
||||
case 'a':
|
||||
enc[i] = 'm'
|
||||
case 'e':
|
||||
enc[i] = 't'
|
||||
}
|
||||
}
|
||||
return string(enc), nil
|
||||
}
|
||||
|
||||
// hash hashes `data` with sha256 and returns the hex string
|
||||
func hash(data string) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(data)))
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
Copyright 2017 The Kubernetes 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 hash
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func TestConfigMapHash(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
cm *v1.ConfigMap
|
||||
hash string
|
||||
err string
|
||||
}{
|
||||
// empty map
|
||||
{"empty data", &v1.ConfigMap{Data: map[string]string{}, BinaryData: map[string][]byte{}}, "42745tchd9", ""},
|
||||
// one key
|
||||
{"one key", &v1.ConfigMap{Data: map[string]string{"one": ""}}, "9g67k2htb6", ""},
|
||||
// three keys (tests sorting order)
|
||||
{"three keys", &v1.ConfigMap{Data: map[string]string{"two": "2", "one": "", "three": "3"}}, "f5h7t85m9b", ""},
|
||||
// empty binary data map
|
||||
{"empty binary data", &v1.ConfigMap{BinaryData: map[string][]byte{}}, "dk855m5d49", ""},
|
||||
// one key with binary data
|
||||
{"one key with binary data", &v1.ConfigMap{BinaryData: map[string][]byte{"one": []byte("")}}, "mk79584b8c", ""},
|
||||
// three keys with binary data (tests sorting order)
|
||||
{"three keys with binary data", &v1.ConfigMap{BinaryData: map[string][]byte{"two": []byte("2"), "one": []byte(""), "three": []byte("3")}}, "t458mc6db2", ""},
|
||||
// two keys, one with string and another with binary data
|
||||
{"two keys with one each", &v1.ConfigMap{Data: map[string]string{"one": ""}, BinaryData: map[string][]byte{"two": []byte("")}}, "698h7c7t9m", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
h, err := ConfigMapHash(c.cm)
|
||||
if SkipRest(t, c.desc, err, c.err) {
|
||||
continue
|
||||
}
|
||||
if c.hash != h {
|
||||
t.Errorf("case %q, expect hash %q but got %q", c.desc, c.hash, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretHash(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
secret *v1.Secret
|
||||
hash string
|
||||
err string
|
||||
}{
|
||||
// empty map
|
||||
{"empty data", &v1.Secret{Type: "my-type", Data: map[string][]byte{}}, "t75bgf6ctb", ""},
|
||||
// one key
|
||||
{"one key", &v1.Secret{Type: "my-type", Data: map[string][]byte{"one": []byte("")}}, "74bd68bm66", ""},
|
||||
// three keys (tests sorting order)
|
||||
{"three keys", &v1.Secret{Type: "my-type", Data: map[string][]byte{"two": []byte("2"), "one": []byte(""), "three": []byte("3")}}, "dgcb6h9tmk", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
h, err := SecretHash(c.secret)
|
||||
if SkipRest(t, c.desc, err, c.err) {
|
||||
continue
|
||||
}
|
||||
if c.hash != h {
|
||||
t.Errorf("case %q, expect hash %q but got %q", c.desc, c.hash, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeConfigMap(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
cm *v1.ConfigMap
|
||||
expect string
|
||||
err string
|
||||
}{
|
||||
// empty map
|
||||
{"empty data", &v1.ConfigMap{Data: map[string]string{}}, `{"data":{},"kind":"ConfigMap","name":""}`, ""},
|
||||
// one key
|
||||
{"one key", &v1.ConfigMap{Data: map[string]string{"one": ""}}, `{"data":{"one":""},"kind":"ConfigMap","name":""}`, ""},
|
||||
// three keys (tests sorting order)
|
||||
{"three keys", &v1.ConfigMap{Data: map[string]string{"two": "2", "one": "", "three": "3"}}, `{"data":{"one":"","three":"3","two":"2"},"kind":"ConfigMap","name":""}`, ""},
|
||||
// empty binary map
|
||||
{"empty data", &v1.ConfigMap{BinaryData: map[string][]byte{}}, `{"data":null,"kind":"ConfigMap","name":""}`, ""},
|
||||
// one key with binary data
|
||||
{"one key", &v1.ConfigMap{BinaryData: map[string][]byte{"one": []byte("")}}, `{"binaryData":{"one":""},"data":null,"kind":"ConfigMap","name":""}`, ""},
|
||||
// three keys with binary data (tests sorting order)
|
||||
{"three keys", &v1.ConfigMap{BinaryData: map[string][]byte{"two": []byte("2"), "one": []byte(""), "three": []byte("3")}}, `{"binaryData":{"one":"","three":"Mw==","two":"Mg=="},"data":null,"kind":"ConfigMap","name":""}`, ""},
|
||||
// two keys, one string and one binary values
|
||||
{"two keys with one each", &v1.ConfigMap{Data: map[string]string{"one": ""}, BinaryData: map[string][]byte{"two": []byte("")}}, `{"binaryData":{"two":""},"data":{"one":""},"kind":"ConfigMap","name":""}`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, err := encodeConfigMap(c.cm)
|
||||
if SkipRest(t, c.desc, err, c.err) {
|
||||
continue
|
||||
}
|
||||
if s != c.expect {
|
||||
t.Errorf("case %q, expect %q but got %q from encode %#v", c.desc, c.expect, s, c.cm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSecret(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
secret *v1.Secret
|
||||
expect string
|
||||
err string
|
||||
}{
|
||||
// empty map
|
||||
{"empty data", &v1.Secret{Type: "my-type", Data: map[string][]byte{}}, `{"data":{},"kind":"Secret","name":"","type":"my-type"}`, ""},
|
||||
// one key
|
||||
{"one key", &v1.Secret{Type: "my-type", Data: map[string][]byte{"one": []byte("")}}, `{"data":{"one":""},"kind":"Secret","name":"","type":"my-type"}`, ""},
|
||||
// three keys (tests sorting order) - note json.Marshal base64 encodes the values because they come in as []byte
|
||||
{"three keys", &v1.Secret{Type: "my-type", Data: map[string][]byte{"two": []byte("2"), "one": []byte(""), "three": []byte("3")}}, `{"data":{"one":"","three":"Mw==","two":"Mg=="},"kind":"Secret","name":"","type":"my-type"}`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, err := encodeSecret(c.secret)
|
||||
if SkipRest(t, c.desc, err, c.err) {
|
||||
continue
|
||||
}
|
||||
if s != c.expect {
|
||||
t.Errorf("case %q, expect %q but got %q from encode %#v", c.desc, c.expect, s, c.secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
// hash the empty string to be sure that sha256 is being used
|
||||
expect := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
sum := hash("")
|
||||
if expect != sum {
|
||||
t.Errorf("expected hash %q but got %q", expect, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// warn devs who change types that they might have to update a hash function
|
||||
// not perfect, as it only checks the number of top-level fields
|
||||
func TestTypeStability(t *testing.T) {
|
||||
errfmt := `case %q, expected %d fields but got %d
|
||||
Depending on the field(s) you added, you may need to modify the hash function for this type.
|
||||
To guide you: the hash function targets fields that comprise the contents of objects,
|
||||
not their metadata (e.g. the Data of a ConfigMap, but nothing in ObjectMeta).
|
||||
`
|
||||
cases := []struct {
|
||||
typeName string
|
||||
obj interface{}
|
||||
expect int
|
||||
}{
|
||||
{"ConfigMap", v1.ConfigMap{}, 4},
|
||||
{"Secret", v1.Secret{}, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
val := reflect.ValueOf(c.obj)
|
||||
if num := val.NumField(); c.expect != num {
|
||||
t.Errorf(errfmt, c.typeName, c.expect, num)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SkipRest returns true if there was a non-nil error or if we expected an error that didn't happen,
|
||||
// and logs the appropriate error on the test object.
|
||||
// The return value indicates whether we should skip the rest of the test case due to the error result.
|
||||
func SkipRest(t *testing.T, desc string, err error, contains string) bool {
|
||||
if err != nil {
|
||||
if len(contains) == 0 {
|
||||
t.Errorf("case %q, expect nil error but got %q", desc, err.Error())
|
||||
} else if !strings.Contains(err.Error(), contains) {
|
||||
t.Errorf("case %q, expect error to contain %q but got %q", desc, contains, err.Error())
|
||||
}
|
||||
return true
|
||||
} else if len(contains) > 0 {
|
||||
t.Errorf("case %q, expect error to contain %q but got nil error", desc, contains)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
Copyright 2018 The Kubernetes 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 rbac
|
||||
|
||||
import (
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type simpleResource struct {
|
||||
Group string
|
||||
Resource string
|
||||
ResourceNameExist bool
|
||||
ResourceName string
|
||||
}
|
||||
|
||||
// CompactRules combines rules that contain a single APIGroup/Resource, differ only by verb, and contain no other attributes.
|
||||
// this is a fast check, and works well with the decomposed "missing rules" list from a Covers check.
|
||||
func CompactRules(rules []rbacv1.PolicyRule) ([]rbacv1.PolicyRule, error) {
|
||||
compacted := make([]rbacv1.PolicyRule, 0, len(rules))
|
||||
|
||||
simpleRules := map[simpleResource]*rbacv1.PolicyRule{}
|
||||
for _, rule := range rules {
|
||||
if resource, isSimple := isSimpleResourceRule(&rule); isSimple {
|
||||
if existingRule, ok := simpleRules[resource]; ok {
|
||||
// Add the new verbs to the existing simple resource rule
|
||||
if existingRule.Verbs == nil {
|
||||
existingRule.Verbs = []string{}
|
||||
}
|
||||
existingRule.Verbs = append(existingRule.Verbs, rule.Verbs...)
|
||||
} else {
|
||||
// Copy the rule to accumulate matching simple resource rules into
|
||||
simpleRules[resource] = rule.DeepCopy()
|
||||
}
|
||||
} else {
|
||||
compacted = append(compacted, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// Once we've consolidated the simple resource rules, add them to the compacted list
|
||||
for _, simpleRule := range simpleRules {
|
||||
compacted = append(compacted, *simpleRule)
|
||||
}
|
||||
|
||||
return compacted, nil
|
||||
}
|
||||
|
||||
// isSimpleResourceRule returns true if the given rule contains verbs, a single resource, a single API group, at most one Resource Name, and no other values
|
||||
func isSimpleResourceRule(rule *rbacv1.PolicyRule) (simpleResource, bool) {
|
||||
resource := simpleResource{}
|
||||
|
||||
// If we have "complex" rule attributes, return early without allocations or expensive comparisons
|
||||
if len(rule.ResourceNames) > 1 || len(rule.NonResourceURLs) > 0 {
|
||||
return resource, false
|
||||
}
|
||||
// If we have multiple api groups or resources, return early
|
||||
if len(rule.APIGroups) != 1 || len(rule.Resources) != 1 {
|
||||
return resource, false
|
||||
}
|
||||
|
||||
// Test if this rule only contains APIGroups/Resources/Verbs/ResourceNames
|
||||
simpleRule := &rbacv1.PolicyRule{APIGroups: rule.APIGroups, Resources: rule.Resources, Verbs: rule.Verbs, ResourceNames: rule.ResourceNames}
|
||||
if !reflect.DeepEqual(simpleRule, rule) {
|
||||
return resource, false
|
||||
}
|
||||
|
||||
if len(rule.ResourceNames) == 0 {
|
||||
resource = simpleResource{Group: rule.APIGroups[0], Resource: rule.Resources[0], ResourceNameExist: false}
|
||||
} else {
|
||||
resource = simpleResource{Group: rule.APIGroups[0], Resource: rule.Resources[0], ResourceNameExist: true, ResourceName: rule.ResourceNames[0]}
|
||||
}
|
||||
|
||||
return resource, true
|
||||
}
|
||||
|
||||
// BreakdownRule takes a rule and builds an equivalent list of rules that each have at most one verb, one
|
||||
// resource, and one resource name
|
||||
func BreakdownRule(rule rbacv1.PolicyRule) []rbacv1.PolicyRule {
|
||||
subrules := []rbacv1.PolicyRule{}
|
||||
for _, group := range rule.APIGroups {
|
||||
for _, resource := range rule.Resources {
|
||||
for _, verb := range rule.Verbs {
|
||||
if len(rule.ResourceNames) > 0 {
|
||||
for _, resourceName := range rule.ResourceNames {
|
||||
subrules = append(subrules, rbacv1.PolicyRule{APIGroups: []string{group}, Resources: []string{resource}, Verbs: []string{verb}, ResourceNames: []string{resourceName}})
|
||||
}
|
||||
|
||||
} else {
|
||||
subrules = append(subrules, rbacv1.PolicyRule{APIGroups: []string{group}, Resources: []string{resource}, Verbs: []string{verb}})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-resource URLs are unique because they only combine with verbs.
|
||||
for _, nonResourceURL := range rule.NonResourceURLs {
|
||||
for _, verb := range rule.Verbs {
|
||||
subrules = append(subrules, rbacv1.PolicyRule{NonResourceURLs: []string{nonResourceURL}, Verbs: []string{verb}})
|
||||
}
|
||||
}
|
||||
|
||||
return subrules
|
||||
}
|
||||
|
||||
// SortableRuleSlice is used to sort rule slice
|
||||
type SortableRuleSlice []rbacv1.PolicyRule
|
||||
|
||||
func (s SortableRuleSlice) Len() int { return len(s) }
|
||||
func (s SortableRuleSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
||||
func (s SortableRuleSlice) Less(i, j int) bool {
|
||||
return strings.Compare(s[i].String(), s[j].String()) < 0
|
||||
}
|
||||
Loading…
Reference in New Issue