reinvocation_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. /*
  2. Copyright 2019 The Kubernetes Authors.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package admissionwebhook
  14. import (
  15. "crypto/tls"
  16. "crypto/x509"
  17. "encoding/json"
  18. "fmt"
  19. "io/ioutil"
  20. "net/http"
  21. "net/http/httptest"
  22. "reflect"
  23. "strings"
  24. "sync"
  25. "testing"
  26. "time"
  27. "k8s.io/api/admission/v1beta1"
  28. admissionv1beta1 "k8s.io/api/admissionregistration/v1beta1"
  29. registrationv1beta1 "k8s.io/api/admissionregistration/v1beta1"
  30. corev1 "k8s.io/api/core/v1"
  31. v1 "k8s.io/api/core/v1"
  32. schedulingv1 "k8s.io/api/scheduling/v1"
  33. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  34. "k8s.io/apimachinery/pkg/types"
  35. "k8s.io/apimachinery/pkg/util/wait"
  36. clientset "k8s.io/client-go/kubernetes"
  37. "k8s.io/client-go/rest"
  38. kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
  39. "k8s.io/kubernetes/test/integration/framework"
  40. )
  41. const (
  42. testReinvocationClientUsername = "webhook-reinvocation-integration-client"
  43. )
  44. // TestWebhookReinvocationPolicy ensures that the admission webhook reinvocation policy is applied correctly.
  45. func TestWebhookReinvocationPolicy(t *testing.T) {
  46. reinvokeNever := registrationv1beta1.NeverReinvocationPolicy
  47. reinvokeIfNeeded := registrationv1beta1.IfNeededReinvocationPolicy
  48. type testWebhook struct {
  49. path string
  50. policy *registrationv1beta1.ReinvocationPolicyType
  51. objectSelector *metav1.LabelSelector
  52. }
  53. testCases := []struct {
  54. name string
  55. initialPriorityClass string
  56. webhooks []testWebhook
  57. expectLabels map[string]string
  58. expectInvocations map[string]int
  59. expectError bool
  60. errorContains string
  61. }{
  62. { // in-tree (mutation), webhook (no mutation), no reinvocation required
  63. name: "no reinvocation for in-tree only mutation",
  64. initialPriorityClass: "low-priority", // trigger initial in-tree mutation
  65. webhooks: []testWebhook{
  66. {path: "/noop", policy: &reinvokeIfNeeded},
  67. },
  68. expectInvocations: map[string]int{"/noop": 1},
  69. },
  70. { // in-tree (mutation), webhook (mutation), reinvoke in-tree (no-mutation), no webhook reinvocation required
  71. name: "no webhook reinvocation for webhook when no in-tree reinvocation mutations",
  72. initialPriorityClass: "low-priority", // trigger initial in-tree mutation
  73. webhooks: []testWebhook{
  74. {path: "/addlabel", policy: &reinvokeIfNeeded},
  75. },
  76. expectInvocations: map[string]int{"/addlabel": 1},
  77. },
  78. { // in-tree (mutation), webhook (mutation), reinvoke in-tree (mutation), webhook (no-mutation), both reinvoked
  79. name: "webhook is reinvoked after in-tree reinvocation",
  80. initialPriorityClass: "low-priority", // trigger initial in-tree mutation
  81. webhooks: []testWebhook{
  82. // Priority plugin is ordered to run before mutating webhooks
  83. {path: "/setpriority", policy: &reinvokeIfNeeded}, // trigger in-tree reinvoke mutation
  84. },
  85. expectInvocations: map[string]int{"/setpriority": 2},
  86. },
  87. { // in-tree (mutation), webhook A (mutation), webhook B (mutation), reinvoke in-tree (no-mutation), reinvoke webhook A (no-mutation), no reinvocation of webhook B required
  88. name: "no reinvocation of webhook B when in-tree or prior webhook mutations",
  89. initialPriorityClass: "low-priority", // trigger initial in-tree mutation
  90. webhooks: []testWebhook{
  91. {path: "/addlabel", policy: &reinvokeIfNeeded},
  92. {path: "/conditionaladdlabel", policy: &reinvokeIfNeeded},
  93. },
  94. expectLabels: map[string]string{"x": "true", "a": "true", "b": "true"},
  95. expectInvocations: map[string]int{"/addlabel": 2, "/conditionaladdlabel": 1},
  96. },
  97. { // in-tree (mutation), webhook A (mutation), webhook B (mutation), reinvoke in-tree (no-mutation), reinvoke webhook A (mutation), reinvoke webhook B (mutation), both webhooks reinvoked
  98. name: "all webhooks reinvoked when any webhook reinvocation causes mutation",
  99. initialPriorityClass: "low-priority", // trigger initial in-tree mutation
  100. webhooks: []testWebhook{
  101. {path: "/settrue", policy: &reinvokeIfNeeded},
  102. {path: "/setfalse", policy: &reinvokeIfNeeded},
  103. },
  104. expectLabels: map[string]string{"x": "true", "fight": "false"},
  105. expectInvocations: map[string]int{"/settrue": 2, "/setfalse": 2},
  106. },
  107. { // in-tree (mutation), webhook A is SKIPPED due to objectSelector not matching, webhook B (mutation), reinvoke in-tree (no-mutation), webhook A is SKIPPED even though the labels match now, because it's not called in the first round. No reinvocation of webhook B required
  108. name: "no reinvocation of webhook B when in-tree or prior webhook mutations",
  109. initialPriorityClass: "low-priority", // trigger initial in-tree mutation
  110. webhooks: []testWebhook{
  111. {path: "/conditionaladdlabel", policy: &reinvokeIfNeeded, objectSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"a": "true"}}},
  112. {path: "/addlabel", policy: &reinvokeIfNeeded},
  113. },
  114. expectLabels: map[string]string{"x": "true", "a": "true"},
  115. expectInvocations: map[string]int{"/addlabel": 1, "/conditionaladdlabel": 0},
  116. },
  117. {
  118. name: "invalid priority class set by webhook should result in error from in-tree priority plugin",
  119. webhooks: []testWebhook{
  120. // Priority plugin is ordered to run before mutating webhooks
  121. {path: "/setinvalidpriority", policy: &reinvokeIfNeeded},
  122. },
  123. expectError: true,
  124. errorContains: "no PriorityClass with name invalid was found",
  125. expectInvocations: map[string]int{"/setinvalidpriority": 1},
  126. },
  127. {
  128. name: "'reinvoke never' policy respected",
  129. webhooks: []testWebhook{
  130. {path: "/conditionaladdlabel", policy: &reinvokeNever},
  131. {path: "/addlabel", policy: &reinvokeNever},
  132. },
  133. expectLabels: map[string]string{"x": "true", "a": "true"},
  134. expectInvocations: map[string]int{"/conditionaladdlabel": 1, "/addlabel": 1},
  135. },
  136. {
  137. name: "'reinvoke never' (by default) policy respected",
  138. webhooks: []testWebhook{
  139. {path: "/conditionaladdlabel", policy: nil},
  140. {path: "/addlabel", policy: nil},
  141. },
  142. expectLabels: map[string]string{"x": "true", "a": "true"},
  143. expectInvocations: map[string]int{"/conditionaladdlabel": 1, "/addlabel": 1},
  144. },
  145. }
  146. roots := x509.NewCertPool()
  147. if !roots.AppendCertsFromPEM(localhostCert) {
  148. t.Fatal("Failed to append Cert from PEM")
  149. }
  150. cert, err := tls.X509KeyPair(localhostCert, localhostKey)
  151. if err != nil {
  152. t.Fatalf("Failed to build cert with error: %+v", err)
  153. }
  154. recorder := &invocationRecorder{counts: map[string]int{}}
  155. webhookServer := httptest.NewUnstartedServer(newReinvokeWebhookHandler(recorder))
  156. webhookServer.TLS = &tls.Config{
  157. RootCAs: roots,
  158. Certificates: []tls.Certificate{cert},
  159. }
  160. webhookServer.StartTLS()
  161. defer webhookServer.Close()
  162. s := kubeapiservertesting.StartTestServerOrDie(t, kubeapiservertesting.NewDefaultTestServerOptions(), []string{
  163. "--disable-admission-plugins=ServiceAccount",
  164. }, framework.SharedEtcd())
  165. defer s.TearDownFn()
  166. // Configure a client with a distinct user name so that it is easy to distinguish requests
  167. // made by the client from requests made by controllers. We use this to filter out requests
  168. // before recording them to ensure we don't accidentally mistake requests from controllers
  169. // as requests made by the client.
  170. clientConfig := rest.CopyConfig(s.ClientConfig)
  171. clientConfig.Impersonate.UserName = testReinvocationClientUsername
  172. clientConfig.Impersonate.Groups = []string{"system:masters", "system:authenticated"}
  173. client, err := clientset.NewForConfig(clientConfig)
  174. if err != nil {
  175. t.Fatalf("unexpected error: %v", err)
  176. }
  177. for priorityClass, priority := range map[string]int{"low-priority": 1, "high-priority": 10} {
  178. _, err = client.SchedulingV1().PriorityClasses().Create(&schedulingv1.PriorityClass{ObjectMeta: metav1.ObjectMeta{Name: priorityClass}, Value: int32(priority)})
  179. if err != nil {
  180. t.Fatal(err)
  181. }
  182. }
  183. _, err = client.CoreV1().Pods("default").Create(reinvocationMarkerFixture)
  184. if err != nil {
  185. t.Fatal(err)
  186. }
  187. for i, tt := range testCases {
  188. t.Run(tt.name, func(t *testing.T) {
  189. upCh := recorder.Reset()
  190. ns := fmt.Sprintf("reinvoke-%d", i)
  191. _, err = client.CoreV1().Namespaces().Create(&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}})
  192. if err != nil {
  193. t.Fatal(err)
  194. }
  195. webhooks := []admissionv1beta1.MutatingWebhook{}
  196. for j, webhook := range tt.webhooks {
  197. name := fmt.Sprintf("admission.integration.test.%d.%s", j, strings.TrimPrefix(webhook.path, "/"))
  198. fail := admissionv1beta1.Fail
  199. endpoint := webhookServer.URL + webhook.path
  200. webhooks = append(webhooks, admissionv1beta1.MutatingWebhook{
  201. Name: name,
  202. ClientConfig: admissionv1beta1.WebhookClientConfig{
  203. URL: &endpoint,
  204. CABundle: localhostCert,
  205. },
  206. Rules: []admissionv1beta1.RuleWithOperations{{
  207. Operations: []admissionv1beta1.OperationType{admissionv1beta1.OperationAll},
  208. Rule: admissionv1beta1.Rule{APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}},
  209. }},
  210. ObjectSelector: webhook.objectSelector,
  211. FailurePolicy: &fail,
  212. ReinvocationPolicy: webhook.policy,
  213. AdmissionReviewVersions: []string{"v1beta1"},
  214. })
  215. }
  216. cfg, err := client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Create(&admissionv1beta1.MutatingWebhookConfiguration{
  217. ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("admission.integration.test-%d", i)},
  218. Webhooks: webhooks,
  219. })
  220. if err != nil {
  221. t.Fatal(err)
  222. }
  223. defer func() {
  224. err := client.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Delete(cfg.GetName(), &metav1.DeleteOptions{})
  225. if err != nil {
  226. t.Fatal(err)
  227. }
  228. }()
  229. // wait until new webhook is called the first time
  230. if err := wait.PollImmediate(time.Millisecond*5, wait.ForeverTestTimeout, func() (bool, error) {
  231. _, err = client.CoreV1().Pods("default").Patch(reinvocationMarkerFixture.Name, types.JSONPatchType, []byte("[]"))
  232. select {
  233. case <-upCh:
  234. return true, nil
  235. default:
  236. t.Logf("Waiting for webhook to become effective, getting marker object: %v", err)
  237. return false, nil
  238. }
  239. }); err != nil {
  240. t.Fatal(err)
  241. }
  242. pod := &corev1.Pod{
  243. ObjectMeta: metav1.ObjectMeta{
  244. Namespace: ns,
  245. Name: "labeled",
  246. Labels: map[string]string{"x": "true"},
  247. },
  248. Spec: corev1.PodSpec{
  249. Containers: []v1.Container{{
  250. Name: "fake-name",
  251. Image: "fakeimage",
  252. }},
  253. },
  254. }
  255. if tt.initialPriorityClass != "" {
  256. pod.Spec.PriorityClassName = tt.initialPriorityClass
  257. }
  258. obj, err := client.CoreV1().Pods(ns).Create(pod)
  259. if tt.expectError {
  260. if err == nil {
  261. t.Fatalf("expected error but got none")
  262. }
  263. if tt.errorContains != "" {
  264. if !strings.Contains(err.Error(), tt.errorContains) {
  265. t.Errorf("expected an error saying %q, but got: %v", tt.errorContains, err)
  266. }
  267. }
  268. return
  269. }
  270. if err != nil {
  271. t.Fatal(err)
  272. }
  273. if tt.expectLabels != nil {
  274. labels := obj.GetLabels()
  275. if !reflect.DeepEqual(tt.expectLabels, labels) {
  276. t.Errorf("expected labels '%v', but got '%v'", tt.expectLabels, labels)
  277. }
  278. }
  279. if tt.expectInvocations != nil {
  280. for k, v := range tt.expectInvocations {
  281. if recorder.GetCount(k) != v {
  282. t.Errorf("expected %d invocations of %s, but got %d", v, k, recorder.GetCount(k))
  283. }
  284. }
  285. }
  286. })
  287. }
  288. }
  289. type invocationRecorder struct {
  290. mu sync.Mutex
  291. upCh chan struct{}
  292. upOnce sync.Once
  293. counts map[string]int
  294. }
  295. // Reset zeros out all counts and returns a channel that is closed when the first admission of the
  296. // marker object is received.
  297. func (i *invocationRecorder) Reset() chan struct{} {
  298. i.mu.Lock()
  299. defer i.mu.Unlock()
  300. i.counts = map[string]int{}
  301. i.upCh = make(chan struct{})
  302. i.upOnce = sync.Once{}
  303. return i.upCh
  304. }
  305. func (i *invocationRecorder) MarkerReceived() {
  306. i.mu.Lock()
  307. defer i.mu.Unlock()
  308. i.upOnce.Do(func() {
  309. close(i.upCh)
  310. })
  311. }
  312. func (i *invocationRecorder) GetCount(path string) int {
  313. i.mu.Lock()
  314. defer i.mu.Unlock()
  315. return i.counts[path]
  316. }
  317. func (i *invocationRecorder) IncrementCount(path string) {
  318. i.mu.Lock()
  319. defer i.mu.Unlock()
  320. i.counts[path]++
  321. }
  322. func newReinvokeWebhookHandler(recorder *invocationRecorder) http.Handler {
  323. patch := func(w http.ResponseWriter, patch string) {
  324. w.Header().Set("Content-Type", "application/json")
  325. pt := v1beta1.PatchTypeJSONPatch
  326. json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{
  327. Response: &v1beta1.AdmissionResponse{
  328. Allowed: true,
  329. PatchType: &pt,
  330. Patch: []byte(patch),
  331. },
  332. })
  333. }
  334. allow := func(w http.ResponseWriter) {
  335. w.Header().Set("Content-Type", "application/json")
  336. json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{
  337. Response: &v1beta1.AdmissionResponse{
  338. Allowed: true,
  339. },
  340. })
  341. }
  342. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  343. defer r.Body.Close()
  344. data, err := ioutil.ReadAll(r.Body)
  345. if err != nil {
  346. http.Error(w, err.Error(), 400)
  347. }
  348. review := v1beta1.AdmissionReview{}
  349. if err := json.Unmarshal(data, &review); err != nil {
  350. http.Error(w, err.Error(), 400)
  351. }
  352. if review.Request.UserInfo.Username != testReinvocationClientUsername {
  353. // skip requests not originating from this integration test's client
  354. allow(w)
  355. return
  356. }
  357. if len(review.Request.Object.Raw) == 0 {
  358. http.Error(w, err.Error(), 400)
  359. }
  360. pod := &corev1.Pod{}
  361. if err := json.Unmarshal(review.Request.Object.Raw, pod); err != nil {
  362. http.Error(w, err.Error(), 400)
  363. }
  364. // When resetting between tests, a marker object is patched until this webhook
  365. // observes it, at which point it is considered ready.
  366. if pod.Namespace == reinvocationMarkerFixture.Namespace && pod.Name == reinvocationMarkerFixture.Name {
  367. recorder.MarkerReceived()
  368. allow(w)
  369. return
  370. }
  371. recorder.IncrementCount(r.URL.Path)
  372. switch r.URL.Path {
  373. case "/noop":
  374. allow(w)
  375. case "/settrue":
  376. patch(w, `[{"op": "replace", "path": "/metadata/labels/fight", "value": "true"}]`)
  377. case "/setfalse":
  378. patch(w, `[{"op": "replace", "path": "/metadata/labels/fight", "value": "false"}]`)
  379. case "/addlabel":
  380. labels := pod.GetLabels()
  381. if a, ok := labels["a"]; !ok || a != "true" {
  382. patch(w, `[{"op": "add", "path": "/metadata/labels/a", "value": "true"}]`)
  383. return
  384. }
  385. allow(w)
  386. case "/conditionaladdlabel": // if 'a' is set, set 'b' to true
  387. labels := pod.GetLabels()
  388. if _, ok := labels["a"]; ok {
  389. patch(w, `[{"op": "add", "path": "/metadata/labels/b", "value": "true"}]`)
  390. return
  391. }
  392. allow(w)
  393. case "/setpriority": // sets /spec/priorityClassName to high-priority if it is not already set
  394. if pod.Spec.PriorityClassName != "high-priority" {
  395. if pod.Spec.Priority != nil {
  396. patch(w, `[{"op": "add", "path": "/spec/priorityClassName", "value": "high-priority"},{"op": "remove", "path": "/spec/priority"}]`)
  397. } else {
  398. patch(w, `[{"op": "add", "path": "/spec/priorityClassName", "value": "high-priority"}]`)
  399. }
  400. return
  401. }
  402. allow(w)
  403. case "/setinvalidpriority":
  404. patch(w, `[{"op": "add", "path": "/spec/priorityClassName", "value": "invalid"}]`)
  405. default:
  406. http.NotFound(w, r)
  407. }
  408. })
  409. }
  410. var reinvocationMarkerFixture = &corev1.Pod{
  411. ObjectMeta: metav1.ObjectMeta{
  412. Namespace: "default",
  413. Name: "marker",
  414. },
  415. Spec: corev1.PodSpec{
  416. Containers: []v1.Container{{
  417. Name: "fake-name",
  418. Image: "fakeimage",
  419. }},
  420. },
  421. }